Case study
Smakowity Plan
Smakowity Plan (“Tasty Plan”) is a PWA that plans a family's meals for the week, generates recipes via AI, and turns them into a shopping list grouped by category.
Context
We have two kids. My wife figured out what to cook every day — not because she couldn't, but because it's work nobody sees and that never ends. The app exists to take it off her plate: a week of meals, recipes, and a shopping list in one step, instead of a daily “what's for dinner.”
What I built
- A weekly meal plan generated by a language model, accounting for household size and preferences.
- A recipe under every meal: macros, ingredients with weights, preparation steps.
- A shopping list assembled automatically from the whole week and grouped by store category.
- A history of saved meal plans with favorites.
- An admin panel with usage analytics and an audit log.
Crux
Technical decisions
Why a PIN instead of passwords?
This is an app for the household, not the internet — the user base is closed, there's no registration. Under that assumption a password is a cost with no upside: it has to be invented, remembered, reset. A PIN types with one hand, standing at the stove.
“Good enough for now” is a decision here, not an oversight — and that's
why the rest is built so the decision can be reversed: PINs live in
users.json, not in the code, and the session rests on a token
signed with HMAC. Swapping out the login mechanism alone doesn't require
touching the rest of the app.
Why is the rate limit per endpoint, not global?
A counter split into buckets (gemini, auth) solves a
concrete problem: without it, five meal-plan generations would exhaust the
limit and lock the household out of logging in. The limit protects the
API key, not the family's access.
Why does the limiter let a request through when it can't write the counter?
Fail-open. When the counter store is unavailable, the request goes through — better to allow one extra request than to cut the household off from the app over a disk problem. With a limit that protects a budget, not data, that's the right trade-off.
Why doesn't the PIN comparison stop the loop on a match?
The loop runs through every PIN and uses hash_equals() instead
of ===. Without it, response time would leak how many PINs had
been checked before a match — and with a four-digit PIN, every hint
shortens the attack.
Why does the model API key never reach the browser?
Requests go through my own PHP endpoint, which attaches the key server-side. The app can pick a model, but only from a whitelist validated on the server — the client doesn't decide what my key is used for.
Why does the key file have its own guard, when .htaccess already blocks it?
Defense in depth. config.php halts execution if it wasn't
included by the app itself — that still holds even if the Apache rule
disappears during a hosting migration, or someone overwrites
.htaccess. Two independent layers, because the first one
will fail eventually.
What it looks like
Stack
- Vite
- PWA / Workbox
- React
- PHP 8
- Gemini 2.5 / 3.5 Flash
- react-pdf
- Apache / OVH
The app is locked with a PIN — it protects the API key and the household's data. Happy to set up access if you'd like to see it in action.