03Apr. 2026Sole developerShipped

Freezery

Know what you have before you shop.

A grocery inventory tracker on Node, Express and raw PostgreSQL, where item status is computed by database triggers rather than application code, and a Llama model on Groq turns the inventory into prioritised restocking advice citing real quantities and dates.

The problem

I kept buying rice I already had and running out of things I was sure I had plenty of.

The underlying issue is not storage or organisation. It is that "am I low on rice" requires remembering a number nobody wrote down. Every attempt to solve this with a list fails the same way: the list is accurate for two days and then silently is not, and an inventory you do not trust is worse than none, because you check it, believe it, and buy the wrong things anyway.

There is a second problem sitting behind the first. Even a perfectly accurate list of low items is a set of facts, not a decision. Knowing there is one box of pasta and that the yoghurt expires Thursday does not tell you what to do. Turning facts into "buy pasta, eat the yoghurt first" is the part that actually saves you anything, and it is the part I wanted to automate.

The constraints

Free hosting, which turned out to shape more of the architecture than any technical preference.

GitHub Pages serves static files only, so the frontend could not have a build step if I wanted deployment to be a git push. Render's free tier hosts the API and the database, and spins the service down after fifteen minutes of inactivity.

Those two facts put the frontend and the API on different origins, which is the root of the authentication decision below. It is a good example of a hosting choice determining a security design.

I also wanted to write the SQL. Not because ORMs are bad, but because this project had exactly the kind of logic that belongs in the database, and I wanted to find out what that felt like rather than reading about it.

What I built

A three-tier application. Vanilla HTML, CSS and JavaScript on GitHub Pages, a Node and Express API on Render, and PostgreSQL behind it.

Items carry quantity, unit, location, expiry date, purchase price, minimum quantity and notes. The inventory list has plus and minus buttons for adjusting quantity without opening a form, search, category and status filters, and sorting on any column. It renders as a table on desktop and as cards on mobile.

The dashboard shows counts for total, low, out and expired, a recent items table, and a needs-attention list with a restock action that takes what you bought and updates the count in place.

Each item carries a status of ok, low, out or expired, and that status is computed by the database rather than by the API.

The recommendations endpoint sends your live inventory to a Llama model on Groq and returns one prioritised suggestion per item, each citing your actual quantities and dates.

What this is and is not

It is a working multi-user application. Registration, login, per-user isolation and the recommendations all function against a real Postgres database with real accounts.

It is not production infrastructure, and the gap is specific. Passwords are hashed and every query is scoped by user_id, but there is no row level security in the database, so isolation depends entirely on me never writing a query that forgets its WHERE clause. That is a discipline, not a guarantee, and the difference matters.

There is no email verification and no password reset. There is no rate limiting on the recommendations route, which costs money per call, so one account could run through my Groq quota without doing anything clever.

And the recommendations are advice from a language model. They are grounded in your real data and the prompt forbids inventing numbers, but nothing validates the output beyond deduplication. If the model says something wrong, it goes on screen.

The technical decisions

Decision Reasoning
Item status computed by a Postgres trigger A BEFORE INSERT OR UPDATE trigger sets status on every write. In the application, every code path touching quantity would have to remember to recompute it, and eventually one would not. In the database it is not possible to write a row with a wrong status
Precedence: out, then expired, then low, then ok Zero quantity beats an expiry date, because an item you do not have cannot spoil. This is a judgement call rather than an obvious truth, and it is the kind of ordering that needs to exist in exactly one place
Raw pg rather than an ORM The schema is three tables. An ORM would have added a mapping layer between me and logic I specifically wanted in SQL, and would have made the triggers feel like something fighting the framework
JWT accepted alongside session cookies The frontend and API are on different origins, and mobile Safari blocks third-party cookies. A session-only design was broken on iPhones. The same middleware accepts a Bearer token or a cookie
Vanilla frontend, no framework The UI is forms and a table. A framework buys reactivity I did not need and costs a build step that would have ended push-to-deploy
Categories cloned per user at registration Eleven defaults copied into each account. It duplicates rows, and it means renaming a category cannot affect anyone else
category_id is ON DELETE SET NULL, user_id is ON DELETE CASCADE Deleting a category should orphan items, not destroy them. Deleting a user should take their inventory with it. The two behaviours are deliberately different
Prompt forbids approximation and generic reasons It explicitly bans "quantity below minimum quantity" as a reason. Vague advice is worse than none, because it is still a card the user has to read to discover it says nothing
Model output deduplicated by item_id The model sometimes repeats an item despite being told not to. A Set keeping the first occurrence is a three-line defence against a failure I could not prompt away

The hardest thing

The authentication, and specifically the two days I spent convinced I had a CORS bug.

It worked locally. It worked in the deployed version on my laptop. On my phone, login appeared to succeed and then every subsequent request came back unauthenticated. The browser console showed a CORS error, so I did what the error told me to and rewrote the CORS configuration several times. Origins, credentials, preflight handling, headers. None of it changed anything, which should have been the clue much sooner than it was.

The actual cause was that the frontend is on github.io and the API is on onrender.com, so the session cookie is a third-party cookie, and mobile Safari blocks those by default. The cookie was never being stored. Every request after login was genuinely anonymous. The CORS error was a downstream symptom of a request failing for an unrelated reason, and it was pointing me at the wrong layer the entire time.

The fix was to issue a JWT on login and register, return it in the response body, store it in localStorage, and send it as a Bearer header. I kept session support rather than ripping it out, so the same middleware accepts either. Carrying two mechanisms is not elegant, and the alternative was telling people their browser is wrong.

What I took from it was to distrust the error message when the fix does not change the symptom. Two identical rewrites producing identical failure was information, and I ignored it because the error named a thing I knew how to fix. The second lesson is that free hosting decided this. Same-origin deployment would have made the whole problem not exist, and I did not see that a hosting choice was also a security architecture choice until it cost me a weekend.

There is a related note in the README warning that a cold start on Render's free tier surfaces as a CORS error too. That warning exists because I hit the same misleading symptom twice for two different reasons.

What I would do differently

I would move status computation from a trigger to a generated column or a view. The trigger runs on insert and update, so an item that quietly passes its expiry date while nobody touches it keeps its old status until something writes the row. The dashboard undercounts expired items, which is exactly the failure mode the whole project exists to prevent. It is the bug I am least happy about, because it undermines the premise rather than a feature.

I would add row level security so isolation is enforced by Postgres rather than by my diligence. The current design works and I would not defend it as the right one.

I would replace the in-process recommendation cache before a second instance exists. It is a module-level Map with a five minute TTL and no eviction, so it grows for the life of the process and would give inconsistent results across instances. It is correct for exactly the deployment it has, which is a fragile kind of correct.

And I would make a malformed model response distinguishable from an empty one. Right now the catch sets recommendations to an empty array, so a parse failure and a genuinely empty inventory look identical to the user.

What came of it

It is deployed and I use it. More than that, it is the project that taught me what belongs in a database. Putting status computation in a trigger felt like showing off when I did it, and then I watched it quietly hold the invariant across every code path including the ones I wrote later and would have forgotten.

It is also the README I now write from. It has a stack table with a rationale column, and the honest note about cold starts appearing as CORS errors. Explaining why a choice was made turns out to be the part worth reading, and it is the format I have carried into everything since, including this case study.