The bug report was in the user's own words (the user is me, but the words are real): the shopping list "just constantly has items week in week out, getting more and more."
The loop behind it is a genuine lose-lose. Clear the Done section and you lose the record of what you bought — the app's whole money story runs on that record. Don't clear it, and the working list grows forever until it stops being a working list. Every list app makes you pick one.
The exit turned out to cost one schema field — because the record wasn't actually being lost. This app never hard-deletes: cleared items were sitting in the table as soft-deleted rows the whole time. Nothing was reading them.
TL;DR — Bought = checked, and cleared rows survive (soft deletes) — the ledger already existed; the only missing fact was when. Add
ShoppingItem.checkedAtwith three write semantics: a manual tick stamps now into the mutation variables (so an offline tick replayed tomorrow keeps today's moment); untick nulls it; a receipt match stamps the printed purchase date at midday local. Then one documented exception to the house convention — history reads do not filter_deleted— turns Clear Done from destruction into archival. Week view is the raw shop; month/year views aggregate ×N by name; and the clear dialog stops apologising.
(Part 18 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The insight: the ledger already exists
Inventory of what the table already knew:
- Bought = checked. A manual tick sets it, and receipt matching sets it for every matched line.
- Clear Done soft-deletes. The rows survive; the soft-delete convention was paying dividends again — Part 10 mined these rows for frequency; nobody had mined them for history.
- Missing: when. No timestamp recorded the purchase moment, and no screen bucketed purchases by period.
So the feature is one field + one hook + one screen — not a re-architecture, and pointedly not a new model. No Trip, no Purchase table: the new-kind test from Parts 11/13/14 says the rows already are the record. The design sentence that anchored the whole thing: the working list stays a working list; History becomes the log.
One field, three time semantics
checkedAt: a.datetime() — and every writer answers "when?" differently, each for a reason.
A manual tick stamps now — into the mutation variables. This is a Part 7 consequence that's easy to miss: offline mutations persist their variables and replay later. Compute the timestamp inside the mutation function and a tick made in a signal-dead supermarket on Saturday, replayed when the app reopens on Sunday, records Sunday. So the moment is stamped where it survives:
// checkedAt is stamped INTO the vars by onMutate (like addItems ids) so the
// tick's real moment survives a persisted offline mutation being replayed.
export type ToggleItemVars = {
id: string;
listId: string;
checked: boolean;
checkedAt?: string | null;
};
An untick nulls it. Unchecking means "I didn't buy this after all" — the item must vanish from history, not linger with a stale moment.
A receipt match stamps the receipt's printed date, at midday local:
// …so no timezone can nudge it into a neighbouring day. No date on the
// receipt → now.
export const checkedAtFromPurchaseDate = (date?: string | null): string =>
(date ? dayjs(date).hour(12).minute(0).second(0).millisecond(0) : dayjs()).toISOString();
You might scan Tuesday's receipt on Friday; those purchases belong to Tuesday's week. It's Part 14's purchaseDate-vs-capturedAt reasoning extended item by item — and midday local because a date pinned to noon can't drift into a neighbouring day in any timezone the phone wakes up in.
And the fourth, quiet semantic: rows checked before the feature existed have no checkedAt, so the hook falls back to updatedAt — approximate (an edit after buying shifts it), honest, and self-extinguishing since it only affects pre-feature rows.
The documented exception
Every read in this app filters _deleted: { ne: true }. Purchase history is the one deliberate exception, and the hook's docstring owns it:
/**
* Bought = checked. The fetch deliberately has NO not-deleted filter — items
* cleared from the Done section are soft-deleted but remain the archive this
* screen reads. An item deleted while UNCHECKED never had a checkedAt, so
* mistaken adds stay invisible.
*/
The exception holds because the argument is complete in both directions: an item deleted while unchecked never got a checkedAt, so mistaken adds and abandoned items can't pollute history; an item deleted after being checked was genuinely bought — that's precisely the archive. Clear Done stops being destruction and becomes filing.
Which enabled my favorite change in the feature — a line of copy. The clear-confirmation dialog used to carry the implicit apology every destructive dialog has. Now it says: "Clear 12 done items? **They stay in History* — you can find them by week any time."* Clearing became the recommended rhythm, and the list finally stopped growing. The feature isn't the screen; the feature is that the dialog stopped apologising.
A week is a shop; a month is a habit
The history screen (Week | Month | Year segmented control, ‹ 20–26 Jul › navigation, forward chevron disabled at the current period) renders different shapes per granularity, not the same list with wider date bounds:
- Week = the raw shop. Items grouped by aisle, each with quantity, unit, and price where known — because a week is small enough that the individual shop is the interesting object.
- Month/Year = aggregates. Rows like "Milk — ×4 · £4.92", keyed by normalised name (the same keying Part 16's savings chose, for the same reason — barcodes don't reach back through history), sorted by frequency. A month of raw rows would be noise; the aggregate answers the question a month actually poses: what do I actually buy?
Each period's summary line counts the unpriced rather than quietly under-totalling — Part 13's honesty note, still on duty.
One creating action
History is read-only except for one button: "Add these N again." It turns whatever period you're looking at into next week's list — skipping anything already sitting unchecked on the active list and de-duplicating within the period, so N is what would actually be added. The archive loops back into the working list in one tap, which is the moment the lose-lose from the top of this post fully dissolves: clear freely, because history is where next week's list comes from anyway.
What it deliberately is not
-
Not a new model. Checked items and receipts already are the record; a
Triptable would be a second copy of the truth to keep synced. - Not a filter on the live list. "Which week am I looking at?" is a question the working list must never answer — it is always now. History is a different screen because it's a different tense.
- Not auto-clearing. Clearing stays a human action; the app just removed the punishment for it.
What I took away
- Sometimes the feature is one timestamp. The lose-lose loop, the growing list, the "where did my history go" anxiety — all resolved by recording when.
- Conventions can carry documented exceptions — when the coherence argument is airtight in both directions, write it in the docstring and break the rule on purpose.
- Stamp time into mutation variables. Anything computed at execution time is recomputed at replay time; offline apps must capture the moment where it persists.
- Shape data per granularity. A week and a month aren't the same query with different bounds; they're different questions.
- The copy is part of the architecture. The feature shipped when the clear dialog stopped apologising.
Next up
The ledger immediately became a load-bearing wall: the Expenses tab's headline now sums purchases rather than list state — which is what finally let budgets move off individual lists and onto months, with a carry-forward rule that stops editing the past from rewriting the present. That rework is Part 19.
Where does your app hoard state because deleting feels like losing data? A timestamp and a read-path exception might be the whole fix — what's your equivalent?


Top comments (0)