Project

Skaii-Meals

A meal planning and grocery application built around an annotated ingredient catalogue. Allergens, dietary preferences and cuisine ratings drive a generated meal pool, and accepting a plan produces a priced grocery list.

Fail safe, because this data is a medical decision

Allergen and nutrition data govern what somebody with a medical condition eats. That single fact settles most of the design arguments in this codebase before they start.

Uncertainty resolves toward exclusion. Unknown never renders as safe. A value that cannot be determined honestly is left visibly wrong or removed outright rather than estimated into place, because an estimate that looks like a measurement is the failure mode that actually hurts someone. A plausible-looking number is worse than a blank, and a blank is worse than a refusal to plan around it.

That rule shapes the schema before it shapes the screen. An ingredient's annotation carries what is known and what is not as separate states rather than collapsing both into a nullable field, and substitution logic reads the difference. A missing annotation is a reason to exclude, not a reason to shrug.

Two surfaces, one API, and one that has no API at all

The user surface is a standalone Blazor WebAssembly progressive web application, running MudBlazor and talking to the API over Refit. WebAssembly is the choice because the planner is interactive, the catalogue is large, and the thing a user is doing is manipulating a plan rather than requesting pages.

The admin surface is Blazor Server, invite-only, and it calls its module directly through dependency injection. There is deliberately no management HTTP API. An endpoint that only the administrator uses is still an endpoint the internet can reach, and the catalogue is the asset worth protecting here. Not building it is cheaper than defending it.

The contracts project is framework-free so WebAssembly can consume it, which is the constraint that keeps validation logic honest: anything that would drag a server dependency into contracts does not belong in contracts.

The API runs loopback-only in production behind a same-origin proxy, which means the browser and the API share an origin and there is no cross-origin configuration to get wrong. It also means the API is not addressable from outside the machine, so the only way in is the path the proxy allows.

Migrations that assume the table already has rows

Schema changes run through DbUp as plain SQL, held append-only. A migration that has run is never edited, because editing it means the database somebody already has and the database a fresh checkout produces are two different shapes wearing the same version number.

The rule that follows is that every migration is written against a table that already holds rows. Adding a non-nullable column is a three-step change, not a one-line one. Dropping anything is a decision with a date on it rather than a tidy-up. It is slower, and it is the difference between a deployment and an incident.

How it is put together

  • .NET 10 throughout, with the user-facing API built on FastEndpoints under a versioned route prefix, running loopback-only in production behind a same-origin proxy.
  • EF Core with Npgsql on PostgreSQL in the application and management modules.
  • Blazor WebAssembly with MudBlazor for the user application, Blazor Server for admin, and Refit between the client and the API.
  • Redis for distributed caching and as the SignalR backplane.
  • DbUp for migrations, held append-only, run by a dedicated migrator project.

Development runs through a specification-first flow rather than straight to code, and the governing principle that bites most often is that nothing is reported done without the command output that proves it. A commit is confirmed present on its branch before it is called shipped, and an opened merge request is never treated as the pipeline being green.

The other principle that earns its keep is that a decision lands everywhere or not at all. Changing a rule means walking every producer and every consumer of it before calling it done, because the failure mode here is not a crash, it is one surface enforcing a rule the other has stopped enforcing. That has gone wrong in this codebase three separate times, which is why it is written down rather than assumed.

The priced grocery list: items grouped by category, each showing its variant, how many meals it feeds, the pack size and the price, with a running total and a cart progress bar.

The far end of the pipeline. Items group by aisle category, and each one records the variant, the pack size, and how many meals in the plan depend on it, which is the field that makes a substitution answerable rather than a guess. A real generated plan from the development environment, priced against live catalogue data.

The application is not open yet. If you want to be told when it is, say so through the contact form and I will keep your address for exactly that.