This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A fleet management dashboard for smart micro-retail stores. Monitor store status, inventory health, alerts, and sensor data across an entire network in real time — built with tiered polling, optimistic updates, and a data freshness system.
The same write-ups, grouped by what they are rather than when they happened.
Most were found by building something else, not by looking for them.
Where I picked a side and can say why.
Choices about what the operator sees and believes.
Choices about the next person, including future me.
Where the work was moved, and what it bought.
Where it has ended up, before any of the story of getting there. This is an operator dashboard for unattended retail: someone runs a few dozen smart fridges and micro-markets in lobbies and gyms, and needs to know which ones need attention, what sold, what to restock, and what it all earned.
The fleet page is the landing: every store as a card sorted worst-first, a stats bar, filters by status and name, a health donut, a 24-hour alert trend, per-store inventory comparison, and fleet-wide sales with a day, week, month or year range.
Clicking a store opens the detail page, which is eight tabs. Inventory has per-slot stock and starts a restock. Alerts has active and resolved views with a severity filter and a seven-day trend. Activity is the audit feed. Planogram is a drag-and-drop shelf layout with per-slot sensor re-sync. Sales is headline totals, a revenue trend, top sellers and recent transactions. Pricing is a discount and profit calculator plus scheduled promotions. Tax derives GST, HST, PST and QST from the store's province and shows what is owed. Restock History is every completed session for the store, with what was counted against what was expected.
Off the fleet page there are now five fleet-wide tools, each its own page. Plan a location models a new store's revenue and payback before you commit to it. Product performance ranks every product by revenue against its own category average, dead SKUs included. Shrink & loss reconciles completed restock counts into unexplained shrink versus reasoned removals. Finance is weekly payouts with the fees shown rather than folded in. And Search is a keyboard-first combobox over stores, products and the tools themselves. They mirror what Micromart's own platform ships; the write-ups above cover how each was built.
It is a real backend, not fixtures. Postgres behind an Express service, reached through a backend-for-frontend layer in this app so the browser never talks to the API directly. Every read and every write falls back to an in-memory seed if that service is unreachable, so the demo behaves identically whether or not the backend is awake. Aggregations that could get expensive (fleet sales, the alert trend, promotion performance, and now the planner benchmarks, product performance, shrink and finance rollups) are grouped in SQL rather than pulled into Node and summed.
The five fleet tools started life computing their numbers in the BFF from the seed, and only later got the real SQL endpoints behind them, wired the same live-first-then-fall-back-to-seed way as every other read. Because that arithmetic now lives in both repos, a parity test asserts the app's models and the API's copies produce identical numbers for the same canonical inputs, so a drift fails a millisecond test rather than surfacing as quietly wrong figures on a chart.
Reads poll on a tier: alerts every 15 seconds because they are what you are watching for, stores every 30, inventory and sales and planogram every 60. Writes are optimistic with rollback. Nothing polls in a background tab.
Every bucket resolves in the store's own timezone. A Vancouver store's day starts at midnight in Vancouver, in the charts and in the SQL. The fleet view cannot be everyone's local day at once, so it picks one and names it on screen.
Restocking is a session, not a button. Walk the shelf slot by slot on a phone, optionally confirm a physical count, add and remove with a reason on anything taken out, review, complete. Skipping a count is recorded as a decision rather than left blank. Nothing touches stock until the session completes, in one transaction, and that is the only path that ever writes inventory.
Promotions run and then report back. Schedule a discount on a product or a whole store, and afterwards see units and revenue against the equal period before it. Both raw numbers, not just the delta, with the caveat that it is a comparison and not proof of cause.
Anyone, with no account, including the writes. That is deliberate: this exists so somebody can open a link and drive the real thing. The API instead trusts a shared secret only this app's server holds, so a visitor is unaffected while someone calling the API directly is not. Writes are rate limited, and the demo data reseeds nightly.
Worth its own section, because almost every bug in this feature was found by a different tier than the one you would expect, and a few were found by no tier at all. The useful question about a test suite is not how many tests it has. It is what each layer is structurally incapable of seeing, and whether anything else covers that.
| Tier | Answers | Cannot see |
|---|---|---|
| Unit | Does this pure function compute the right answer, across timezones, DST and empty input | Whether anything calls it |
| Component | Does this component render and behave correctly in isolation | The seam between two components |
| Integration | Do the hooks, routes and components agree on a contract, with the network stubbed | Whether the real service honours that contract |
| E2E (seed) | Does the whole flow work in a real browser | Anything about the database |
| E2E (live) | Does the flow work against a real API and a real Postgres | Production data and scale |
| SQL smoke | Will Postgres actually accept these statements | Whether the results are right |
| Cross-repo parity | Do this app's models and the API's copies compute the same numbers | Whether either is correct on its own |
The last row is newer than the rest, and it exists because the five fleet aggregations are computed twice — once in this app's seed fallback, once in the API's SQL — so the live path and the demo agree. Nothing structural stops the two copies drifting, and the only tier that would otherwise catch it is the live E2E, which is slow and needs a database. So both repos assert the same canonical inputs against the same expected outputs; a formula changed in one and not the other fails a millisecond unit test. It is the cheap guard standing in for the shared package the duplication really wants.
“Start restock” led to another “Start restock”. Two taps for one action. The component test rendered the flow on its own, so it never saw the button above it. A component test cannot catch a seam between components; that is not a gap in the test, it is the definition of the tier. It took a screenshot to notice, and an E2E spec to pin.
Two aggregate queries no database would accept.Every test in that module mocks the repository, and a mocked repository will happily return rows for SQL Postgres rejects outright. Hence the SQL smoke tier: it executes the real statements against a real database, and skips when there is no DATABASE_URL. They are all SELECTs on purpose, because a developer's DATABASE_URL often points at the deployed database, and a test suite that can write is a test suite that can destroy.
That sentence turned out to be describing my own machine. The API repo's DATABASE_URL pointed at the production proxy host, so pnpm dev read and wrote live data and pnpm migrate migrated production. Writing SELECT-only tests was the right instinct aimed at the wrong target: I was hardening the tests against the hazard instead of removing it.
The actual cause was smaller and more stupid than it sounds. The compose file had a Postgres service but published no host port, so nothing running outside the compose network could reach it. There was no local database to point at, which made production the path of least resistance. One line of YAML, and the alternative exists. Redis had been set up correctly this whole time and the README even documented the pattern, which is the part I find worth writing down: the fix was already in the same file, applied to a different service.
The server would not boot. Two files claiming the same Next convention is a startup fatal, and every route 404'd. Unit, integration, typecheck, lint and the dead-export check all passed, because not one of them starts Next. Same shape on the API side: a rate limiter whose key generator used a raw IP was rejected at boot by the library, because an IPv6 user is handed a whole /64 and could have minted a fresh budget per request by varying the low bits. All 271 API tests missed it, since they mock the limiter or never construct it. Two real bugs whose only witness was a process starting.
An endpoint that never existed. The Sales and Tax tabs called a route the API did not implement. They rendered empty against the real backend and perfectly against fixtures, for weeks. Which brings up the one that took the longest to see.
The BFF falls back to seeded data when the API is unreachable. That is a genuinely good feature: the demo stays usable when the backend is asleep, which for something anyone can open without an account is most of its value. But the same mechanism that keeps the demo alive is the mechanism that hides whether the real path works, and I underrated that for a long time.
It went furthest in the E2E specs. They navigated to a hardcoded seed store id. With a real backend serving, the fleet comes back as UUIDs, the API 404s that id, the BFF falls back, and the specs pass — identically, whether or not the backend works. A test that cannot fail when the thing it covers is broken is not a test. That is also, exactly, how the missing sales endpoint survived.
Pointing them at the real fleet made CI go red, correctly: the deployed API was on an older release with no restock routes. But that was the wrong question for that tier. These specs exist to catch a seam between two components; pinning them to a separately-deployed service means the suite reports somebody else's deploy state and changes colour for reasons unrelated to the change under review. A test that fails for unrelated reasons gets ignored, and an ignored test is worse than no test.
So the compromise, stated rather than stumbled into: the seed is the default because it is the one fixture that is deterministic and always present, and OPERATOR_E2E_LIVE=1 switches to resolving the store off the fleet and driving whatever is really serving. The default does not cover integration. Saying so is the whole point — the previous version implied it did.
Writing down a blind spot is better than drifting into one, but it is not a fix: nothing would ever have run the live mode, so an integration regression still had nowhere to fail. So there is a CI tier that stands up Postgres, builds the API from source, applies the migrations, seeds the operator tables, points this app at it and drives the whole restock flow. It picks up an API branch of the same name when one exists, so a frontend change that needs a backend change is tested as the pair it actually is rather than against whatever shipped last week.
It went green on its first working run, which is exactly when to be suspicious. Had the API not come up, the BFF would have fallen back, served seed ids, and every assertion would still have passed — a green run proving nothing, which is the precise failure the tier was built to prevent. Live mode now asserts the fleet gave it a real UUID and names the seed id it got instead. A passing test is a claim, and a claim is worth checking when the cost of it being wrong is that you stop looking.
The live tier died on its first attempt because the two repos pin different pnpm majors — this one on 8 with a v6 lockfile, the API on 10 with a v9 one — so one pnpm cannot install both. It reads the version out of the API's own packageManager field rather than hardcoding it here, where it would drift silently the next time the API upgrades and leave someone debugging a lockfile error in a repo they never touched.
And the E2E credential loader checked truthiness rather than presence, so setting a variable to empty on purpose was overwritten by the file it was meant to override. The effect was that there was no way to run the public tier without attempting a real Auth0 login — a small bug with a disproportionate cost, because it made the cheap half of the suite depend on the expensive half.
The mock-registration bug above got repaired, and then turned up again a few days later in a passing build: fifty-four unmatched requests and thirteen silent fallbacks, still there. The repair had used a pattern that only recognised registrations written on a single line, so the four written across several lines kept their bare paths and kept missing.
What makes this one worth recording is that a partial fix and a complete one produce identical test output. Everything passed before, everything passed after, and the only difference lived in a log nobody had a reason to open. So the registrations are asserted against the source file now — not against behaviour, because the behaviour is indistinguishable either way.
The same shape showed up in the dependencies. A suite ran twice against an older build of a package than the manifest asked for, proving a fix that was not installed, and passed both times. That is checked at startup now, with a message naming the fix rather than leaving someone to work out why a green run disagreed with reality.
Both guards were tested by making them fail on purpose before being trusted, which is the same standard this page applies to everything else. A guard nobody has watched fail is just another claim.
Every one of those was found by reading a passing build. The suite carried thirty React warnings about state settling after a test had finished, forty-five lines about a canvas the environment does not implement, and forty-odd unmatched requests, and I had been treating all of it as background.
It was not background. The React warnings were the visible half of two endpoints with no mock at all. The canvas lines came from a non-null assertion that would have thrown in any browser without 2d support. One unmatched request was a test reaching for a public blockchain node on the open internet, kept off the wire only because the mocker rejects anything it does not recognise.
Noise is not a property of output. It is a decision to stop reading, made once and then kept, and it is how the original fallback bug survived in plain sight for days. The run is clean now — no unmatched requests, no warnings, no environment chatter — so the next unexpected line is worth the look.
The heavy tiers run nightly or on demand, not per commit, because spending several minutes of CI on every push to catch something twice a month is a bad trade. Quarantined flaky tests run nightly and never block a merge, on the view that a flake is fixed on its own clock rather than by blocking everyone else.
And one I only found by opening the release PR and watching the heavy jobs decline to run. Both of them — the full accessibility pass and the live-backend operator run — were conditioned on a schedule or a manual dispatch. A release PR is neither, so the job whose own name ends in pre-release sat out the one merge that is actually a release. It had been correct in every situation except the one it was named for, which is the sort of thing that survives review because the name reads like a guarantee. They now run on any pull request into the release branch; releases are rare, so the minutes are cheap.
A related one, found by thinking about what the release run had actually proved rather than that it was green: the live tier always pulled the API's development branch. For a release that is the wrong target. It would pass against backend code that is not deployed and say nothing about whether the version in production can serve the release — green for reasons unrelated to the thing it claims to check, which is the same shape as everything else on this page. Both branches happened to be identical the day I noticed, which is precisely when to fix it rather than after it has quietly waved through a release it should have stopped.
Making the gate run on releases immediately earned its keep, in a way I did not enjoy. The restock specs failed there with my own error text saying the service token was missing. The code was right: a rejected write is a misconfiguration rather than an outage, so it refused to fall back and fake success. The job was wrong. It runs against the deployed API, and those specs drive writes, so the obvious fix — hand CI the secret — would have converted a red build into scheduled write traffic against the production database. The only reason it had never written a real row is that the auth it lacked also happened to stop it. That is luck standing in for a decision, and luck is not a control. They are tagged and excluded from that job now; the live tier already covers them against a database built and thrown away per run.
The real gap is structural and worth naming: CI only triggers for pull requests into main and develop. This work shipped as a stack of eight PRs targeting each other, so none of them ran CI automatically — every run in this stack was dispatched by hand, and that is the only reason three of these bugs were found before merge rather than after. Widening the trigger to the branch prefix would fix it and cost CI minutes across every stack. That is a spending decision rather than a correctness one, which is why it is written down here instead of quietly changed.
Everything below is how it got here: the decisions behind each of those, the reasoning I would want to be asked about, the tradeoffs I took knowingly, and the several things I got wrong and had to go back and fix.
It reads in two parts. First the original build write-up — why the thing exists, how it was put together, and what I already knew was weak when it shipped. Then every dated update since, newest first, each one a thing I changed my mind about or got wrong. The jump list at the top of the page goes straight to any of them.
The operator dashboard is a demo of what a real-time fleet management tool looks like for smart vending machines, lobby fridges, and micro-retail kiosks. The kind of thing where an operator manages 20-50 physical locations and needs to know at a glance which ones need attention — low stock, sensor offline, temperature alert, door left open.
It's not connected to real hardware. The data layer uses in-memory mock stores seeded from factory functions, with realistic product catalogs, sensor readings, and alert histories. The interesting part isn't the data — it's how the UI handles real-time updates, stale data, and operator actions without feeling sluggish.
Not all data changes at the same rate, so not all data should poll at the same interval. The dashboard uses three tiers:
All three use staleTime: 0 and refetchOnWindowFocus: true — an operator who tabs back to the dashboard after five minutes should see fresh data immediately, not stale numbers from the last poll cycle.
When an operator clicks "Mark Restocked" on a low-stock item, the stock bar fills immediately. When they dismiss an alert, it vanishes from the list. The UI doesn't wait for the server round-trip.
This uses TanStack Query's onMutate / onError / onSettled lifecycle — the same pattern as the calendar events. onMutate cancels in-flight queries for the affected store, snapshots the cache, and applies the change immediately. onError restores the snapshot. The user sees the change before the request completes, and if it fails, the UI rolls back cleanly.
The bulk actions ("Mark All Restocked" and "Acknowledge All Alerts") show a confirmation modal before executing, since they affect multiple records. A single misclick shouldn't dismiss twenty alerts.
The fleet overview sorts stores worst-first: offline stores at the top, then degraded stores with active alerts, then degraded without alerts, then healthy stores at the bottom. Within each tier, stores sort by name for stability.
This is a deliberate UX choice. An alphabetically sorted grid means the store that needs the most attention might be halfway down the page. Severity-first sorting puts the fires at the top of the screen — the operator opens the dashboard and immediately sees what needs action without scanning.
Store cards also use visual signals: a red left-border accent for critical items in the inventory, amber border for stale sensor data, and color-coded status badges (green for online, amber for degraded, red for offline).
In a real deployment, sensor data can go stale. A fridge might lose WiFi, a temperature probe might die, a payment terminal might stop reporting. The operator needs to know not just "what is the temperature?" but "how old is this reading?"
The freshness system uses three tiers with deterministic thresholds:
The ConnectionQuality indicator shows signal bars (strong, weak, poor, offline) based on the same thresholds. When sensors haven't reported in 30+ minutes, a SensorOfflineCallout banner appears on the inventory tab with the offline duration and last known reading.
All threshold functions accept a now parameter instead of calling Date.now() internally — deterministic inputs for deterministic tests.
A collapsible analytics section sits between the stats bar and the store grid. Three Recharts visualizations: a donut chart showing fleet health distribution (online/degraded/offline), an area chart bucketing alerts into 24 one-hour slots to show whether frequency is rising or falling, and a horizontal bar chart comparing per-store inventory health percentages.
The section defaults to collapsed and persists collapse state in localStorage. Operators who prefer the compact view don't re-collapse every visit. The data transforms are pure functions in their own module — status counting, hourly alert bucketing with a 24h cutoff, and per-store health averaging with zero-capacity safety.
Each store has four tabs: Inventory, Alerts, Activity, and Planogram. The active tab is synced to a ?tab= URL search param so it survives refresh and back/forward navigation.
Quick actions (bulk restock, bulk dismiss, force refresh) show toast notifications on completion. The toast system is framework-agnostic — a createToastStore function returns a plain object with add, remove, and subscribe methods. React binds to it via useSyncExternalStore. Toasts auto-dismiss after 3 seconds.
This pattern keeps the toast state fully testable without rendering any React components — the store is a plain function call that can be tested with timers and subscriber assertions.
The data layer follows the same BFF pattern as the rest of the app: Next.js API routes serve as the proxy layer, and the operator routes use an in-memory data store seeded from factory functions instead of a real backend. This means the dashboard works without any external dependencies — no database, no backend service, just the Next.js dev server.
Pure utility functions live in dedicated modules: operator-utils.ts for sorting and filtering, operator-freshness.ts for threshold calculations, operator-detail.ts for tab helpers and stock categorization, operator-chart-transforms.ts for chart data shaping. Every function is pure, takes explicit inputs, and returns new values — no side effects, no internal state.
One thing that surprised us: Next.js bundles each route handler independently, so a plain module-level variable in operator-data.ts ended up as a separate instance per route. The dismiss route updated its copy of the alerts map, but the alerts GET route read from a different copy where nothing had changed. The fix was to attach the data store to globalThis behind a singleton accessor — the same pattern the Next.js docs recommend for Prisma clients in development mode. Every route handler now shares the same maps regardless of bundling.
After the feature was fully built and working, I went back through it the way I'd review someone else's PR. Not looking for "does it work" — the tests answer that. Looking for "what will bite us in six months." I audited in order of severity: correctness bugs first, then performance, then UX gaps, then code quality, then test coverage.
The in-memory data layer was mutating objects directly — alert.acknowledged = true instead of returning a new object. Not a visible bug in demo mode, but in production React's diffing relies on reference identity. If the object reference doesn't change, React doesn't re-render, and the UI gets out of sync with the data. Fixed by returning new objects from every mutation.
The dismiss button had shared loading state across all alert rows. Dismissing one alert disabled the button on every alert in the list. Fixed by tracking in-flight alert IDs in a Set so each row manages its own state independently.
Two time-dependent functions — getConnectionQuality and toAlertTrendData — called Date.now() internally instead of accepting a now parameter. Every other freshness function in the codebase already took an injectable time value for deterministic testing. These two were the inconsistent ones. Fixed to match the pattern.
A subtler one: the factory generated lastPing timestamps 0-2 hours in the past at module load time, but the connection quality thresholds mark anything over 10 minutes as offline. So every store drifted into "Offline" signal and triggered sensor offline callouts as the dev server ran. Fixed by recomputing lastPing relative to Date.now() on every read from the store accessors, so demo data never goes stale regardless of how long the server has been running.
The trickiest one: dismissing an alert would vanish it momentarily (the optimistic update worked) then it would pop right back on the next poll. The dismiss PATCH route and the alerts GET route each got their own instance of operator-data.ts because Next.js bundles route handlers independently. So the dismiss mutated one copy of the in-memory map while the poll read from a separate copy where the alert was never dismissed. Fixed by attaching the data store to globalThis behind a singleton accessor — the same pattern Next.js docs recommend for Prisma clients in dev mode.
The fleet overview was making 2N+1 parallel requests per poll cycle — one alert query and one inventory query per store, plus the store list. At 6 stores that's 13 requests. At 30 stores it's 61. The useMemo that aggregated query results had unstable dependencies — the query result arrays got new references on every render — so the memo ran every render anyway.
Replaced the entire fan-out with a single /api/operator/fleet-summary endpoint that returns aggregated alert counts, inventory health, and fleet stats per store in one request. The dashboard went from N parallel queries to 1. Chart transforms that were recomputing on every render got wrapped in useMemo with stable dependencies.
When the stores fetch failed, the error state was a dead end — no retry button, no way to recover without reloading the page. Individual store sub-query failures were completely silent; the store card just showed zero alerts. Empty search results didn't suggest clearing filters. The restock button had no per-item feedback — all rows showed "Restocking..." at once and there was no success indicator after completion.
Each of these is the kind of thing that works fine in a demo but would frustrate a real operator. Added retry buttons on error states, per-store error indicators on cards, "clear filters" in empty states, and per-item restock feedback with a brief success checkmark after completion. Also added the analytics expand/collapse animation that was missing.
The Bone skeleton component was copy-pasted into four files. STATUS_CONFIG was defined twice with different shapes. Inline SVG icons were scattered across components. FleetAnalytics was flattening an alert map that the parent already had in flat form. None of these were bugs, but each one makes the next developer slower. Extracted shared components, unified configs, pushed transforms to where the data naturally lives.
The original test suite covered utility functions well but had gaps at the integration level. No test for the fleet overview rendering with real data and verifying sort order. No tests for error or empty states in tab components. No test for the RefreshBar reading from the query cache. The restock rollback test only asserted the final state — a mutant that removed the optimistic update entirely would still pass because the stock level never changed from its original value.
Backfilled all four gaps. The rollback test was the interesting one — it now verifies the optimistic update fires first (stock jumps to capacity) and then verifies it reverts after the 500 response. That's the difference between "the final state is correct" and "the rollback mechanism actually works."
The dashboard works well as a demo, but there are real things that would matter if this were serving actual operators managing actual stores.
Polling at 15-second intervals means a critical alert could sit for up to 14 seconds before the operator sees it. For a real deployment, a WebSocket connection or Server-Sent Events stream would push alerts the moment they fire. The current polling architecture is a pragmatic starting point — it works with any HTTP backend and doesn't require connection management — but the latency ceiling matters when a fridge temperature is climbing fast.
An operator managing 40 stores is not sitting on the dashboard all day. Critical alerts need to reach them on their phone. A notification layer (push notifications, Slack/Teams integration, SMS for urgent failures) would close the loop between "something went wrong" and "someone knows about it." Right now the dashboard only works if the operator is looking at it.
The inventory sparklines show 7 days of simulated history, but real historical data could power anomaly detection — flagging a fridge that's selling 3x faster than usual (likely needs an early restock) or a store whose sensor readings are drifting (might need calibration). The alert trend chart is a start, but with real data you could build baselines and surface deviations automatically.
Currently there's no auth on the operator routes. A production version would need operator accounts, role-based permissions (fleet manager vs. field technician vs. read-only viewer), and multi-tenant isolation so each operator only sees their own stores. The Auth0 integration from the rest of the app could extend here with custom claims for operator roles.
The person restocking a fridge is on their phone, not a laptop. A dedicated mobile view optimized for the field workflow — scan barcode, confirm restock, acknowledge alert, move to next store — would be a different UI from the desktop fleet overview. The current responsive layout adapts to mobile but it's still a desktop-first design. A truly mobile-first version for field techs would prioritize single-store actions over fleet comparisons.
When stores have physical locations, a map overlay with color-coded pins (green for healthy, red for critical) would give operators spatial context. A cluster of degraded stores in one building might indicate a shared infrastructure issue (power outage, network switch down) rather than individual sensor failures.
The in-memory data store means every server restart seeds fresh data. This is fine for a demo but means you can't test long-running scenarios or cross-session state. The tradeoff was intentional — wiring up a real database for demo data would have added deployment complexity without adding much to the frontend story. One gotcha that came up: static lastPing timestamps generated at module load time drifted past the freshness thresholds as the server ran, making every store show "Offline." The fix was to recompute timestamps relative to now on every read, so the demo data stays realistic regardless of server uptime.
Two tradeoffs from the initial build have since been resolved. The per-store fan-out pattern (N parallel queries for alerts and inventory) was replaced by a single /api/operator/fleet-summary endpoint that returns aggregated data in one request. The chart transforms that recomputed on every render are now memoized with stable dependencies. Both were acceptable at demo scale but would have been real problems at fleet size, so fixing them early was the right call.
Update — August 5, 2026
I ran a whole-project review looking for bad engineering, overfit architecture, and anything that made the code harder than it needs to be — the full pass and its reasoning live in the refactor write-up. The operator subsystem was the densest footprint in the repo, so two of its findings landed here.
useOperatorSales, useOperatorStores, useOperatorInventory, useOperatorActivity and useOperatorPlanogram each wrote the same React Query wrapper — fetch, throw on a bad response, schema.parse, expose { data, loading, error } — differing only in the key, the URL, the schema, the response field, and the error text. That's now one useOperatorResource factory, and each hook is an ~8-line adapter over it.
The how, and the two decisions that mattered. Each adapter keeps its own public return shape ({ sales }, { items }) so not a single component that consumes them had to change — the refactor stops at the hook boundary. The response field is passed as a select function, not a magic string, because a string key quietly assumes every endpoint has the same envelope shape; a function lets an odd one out map itself without breaking the abstraction. And the polling tiers stay as explicit per-hook config rather than a shared default: they're intentional — 15s for urgent alerts, 30s for store status, 60s for inventory, none for historical activity — and hiding them in the factory would erase a real decision. The tradeoff I accepted is a slightly larger call site per hook in exchange for the tiers staying legible. The existing hook contract test passed unchanged, which is how I know the behaviour held.
This page had grown into the single biggest file in the repo — the chat view plus forty summary sections in one component, too big to read or edit in a single pass and genuinely expensive for an AI to load just to touch one paragraph. It's now a 32-line orchestrator plus five focused section files (the chat, the timeline and overview, the original build write-up, and the dated updates in two halves).
Why it was safe. I cut only at <section> sibling boundaries, so every chunk is balanced JSX and no prose was edited — the content is byte-identical, just relocated. The proof is that this page's test suite is unusually strict (72 assertions on exact wording, the order of the sections, and that every timeline and index anchor still resolves), and it passed completely unchanged after the split. A pure rearrangement with no behaviour change is exactly the kind of review-churn that earns its own PR rather than riding along with logic, so it did. The remaining 1,000–2,000-line write-ups are the same job for another day.
Update — August 4, 2026
After wiring the features to the real backend I went back over the whole thing the way I'd review someone else's stack, and it turned up real problems — some of which I'd shipped. Writing them down is only worth anything if the ones that can be fixed get fixed, so here is what I found and what I did about it.
The worst one. The shrink report reconciles completed restock counts, the app's seed generates them, and the database seed did not — only stores, inventory, sales and alerts. So the feature I'd done the most groundwork for rendered a perfectly honest empty page against the very backend I'd just built for it, while showing rich data on the seed fallback. A feature that only works on the fallback is not wired up. The API's seed builder now generates the same completed-session history the app does — a shortfall, a reasoned removal, a skipped count, a clean count, scaled per store — and two tests pin that the seed carries real unexplained shrink to find.
To make the live numbers equal the seed ones I'd mirrored the app's models into the API by hand. That is a deliberate duplication with a precedent here, but the precedent came with a test pinning the copies agree, and I hadn't written one. The only thing that would have caught a drift was the heavy live-backend E2E. So both repos now carry a parity test that runs the same canonical scenarios against the same expected outputs, the literals identical on both sides. Change a formula in one repo and its parity test fails against the shared expectation, in milliseconds instead of a ten-minute browser run. The honest fix for the duplication is one shared package; the parity test is the cheaper guard that buys most of the safety today, and I said so.
Six of the seven stacked frontend PRs were getting no CI at all, because this app's workflow only triggers on pull requests into develop and main, and a stacked PR targets a feature branch. The API repo already triggers on every branch; the app now does too. And the product-performance loader had been mapping a range id to a day count and back to call the API, which is lossy the moment a caller passes a window that isn't 7, 30 or 90 — it passes the range id straight through now, so the two sides can't disagree on what “30d” means.
Not every finding wants code. The platform fee assumes one unit per store because there is no unit count in the schema; a migration to add one is more machinery than a demo's fee nuance earns, so I made the assumption an explicit, named constant in both repos and left it at that — honest and one line to change later, rather than gold-plated now. Revenue Protect stays out until there is failed-transaction data to reconcile it from. “Average sold per day” stays units-over-window until there is stock-availability history to make it units-while-in-stock. The shrink query loads raw lines and groups in the app, which is fine at demo scale and worth pushing into SQL only at fleet scale. And the stack ended up seven deep for features that are mostly independent — a structure I'd avoid next time, but not one worth unpicking after the fact. Knowing which findings to leave documented is the other half of a review.
Update — August 4, 2026
An honest admission first: the five features I'd just built — the planner, product performance, shrink, search and finance — all computed their numbers in the BFF from the in-memory seed. Every one carried a comment promising a production build would compute it in SQL, and none of them did. Unlike the reads that came before them, they never even tried the real API; they read the seed directly, with no live path at all. So this pass closes that gap for real, across both repos.
In the API, each feature becomes a grouped query: benchmarks and finance sum sales in one pass, product performance groups by product within a window, shrink joins completed restock lines to their store and the item's price, search returns stores plus distinct products. The trick was not writing the SQL — it was making the live numbers equal the seed ones. So the API grew a pure aggregations module that mirrors the app's pure models line for line, fed by the grouped rows. The database does the fan-in; the same arithmetic shapes the result on both sides, so switching a feature from seed to live cannot change what it shows.
On the app side, each loader stops reading the seed directly and does what every other operator read does: try the API, and on any failure log the fallback and serve the seed. That one pattern is why the two pull requests don't have to land together. If the app ships first, its loaders 404 against the older API and fall back — the demo is unchanged. If the API ships first, nothing calls the new routes yet. Only once both are live does the data become real, and no intermediate state is broken. The paired branches share a name, so the app's live-backend CI builds the matching API branch from source and drives the whole thing against a real Postgres, which is the only tier that ever parses this SQL before production.
The response shape is not described twice and hoped to match. The API returns it, and the BFF parses it through the exact Zod schema the feature already defined, so a drift in either repo surfaces as a validation error the fallback catches rather than as quietly wrong numbers on a chart. Which is the same lesson this whole page keeps arriving at: a boundary you can't see is a boundary that's lying to you, so make it speak.
Update — August 4, 2026
The last stacked feature is the smallest, and it is the one whose bug would be quietest: a “Download CSV” on the product performance page, so the numbers can leave the app and land in a bookkeeper's spreadsheet. Export is where a dashboard stops being a wall someone reads and starts being data someone uses.
The reason it is a tested module and not a one-line join(",") is the failure mode. A product called “Nuts, Mixed” run through a naive join puts a comma in the middle of a row, and every column after it shifts one to the right — silently, with no error, in a file nobody opens until it is wrong in someone else's system. So the serializer follows RFC 4180: any field with a comma, a quote or a newline is wrapped in quotes and its inner quotes doubled, and five tests hold it to that. The download itself is a Blob and an anchor click, but the escaping is the part worth writing down, because it is the part that fails without telling you.
That closes the arc I set out to build from the Micromart scan: plan a location, see what sells, find what walks, jump anywhere, read what landed, and take it with you. Six stacked pull requests, each merging onto the last.
Update — August 4, 2026
The fifth stacked feature is finance: weekly payouts at /operator/finance, reconciled from real sales. Gross revenue is the number an operator already knows; the useful one is what lands after fees, and the useful skill is showing the fees rather than folding them into a single figure. A slow week and an expensive week can net to the same payout, and an operator needs to tell them apart.
The interesting part is where the fee numbers come from. The location planner already projects payback using a transaction cut and a platform fee; the finance page pays out using the same two. If those lived in two places they would drift, and the planner would quote a return the finance page never delivers. So there is one FEE_MODEL, imported by both. The number you are sold on and the number you are paid are the same number by construction, which is the kind of consistency that is invisible when it holds and infuriating when it doesn't.
Micromart's finance page has a Revenue Protect line — money auto-credited back for failed transactions and card declines. I left it out, on purpose. The demo's sales are all successful sales; there is no record of a decline anywhere in the data, so any “protected revenue” figure I printed would be a number I invented and dressed as a measurement. That is exactly the thing this whole project refuses to do. Revenue Protect is honest to build the day there is failed-transaction data to reconcile it from, and dishonest to fake before then, so it is a labelled gap rather than a fabricated total.
Update — August 4, 2026
Micromart's platform has a global search — find any store, product, cabinet or promotion from one box, fast. An operator running thirty stores does not want to scroll a grid to reach one; they want to type three letters and be there. So the fourth stacked feature is a quick-search at /operator/search over stores, fleet products and the operator tools themselves.
No search library. The whole matcher is a scoring function: a prefix beats a word-boundary hit beats any substring beats a loose subsequence, and a match in the category or status counts for a fraction, never enough to outrank a real hit on the name. That last tier — subsequence — is what makes fast typing feel right: “cbc” finds Cold Brew Coffee because the letters appear in order, even though it is nobody's substring. A dependency would have done the same thing less legibly, and this is thirty lines I can test exhaustively.
A search box that only works with a mouse is half a feature, and the honest version of keyboard support here is the ARIA combobox pattern, which has a counter-intuitive core: as you arrow through the results, focus never leaves the input. The input carries aria-activedescendant pointing at the highlighted option, so a screen reader announces the moving selection while the caret stays put and you can keep typing. It is the right pattern precisely because the naive one — moving DOM focus onto each result — breaks the moment the user types another character.
That same fact settled a lint complaint honestly rather than by reflex. The result rows have a mouse click but no key handler, which the accessibility linter flags. The reflex is to bolt a key handler onto each row; the truth is that focus never lands on a row, so a key handler there could never fire — the keyboard lives on the input, where it belongs. So the rule is suppressed on that line with a comment saying exactly why, which is the difference between silencing a warning and answering it.
The combobox has no idea what a route is. It ranks, highlights, and calls onSelect with the chosen item; a thin page wrapper is the only thing that turns a pick into a route change. That split is not ceremony. It means the component tests drive real keystrokes — type, arrow, enter — and assert the callback fires with the right target, with no router to mock and no navigation to stub. The one integration seam that needs a router is ten lines that barely do anything, and everything interesting is tested without it. Four stacked pull requests now, each a piece an operator would actually open.
Update — August 4, 2026
When I scanned the field for what micro-market operators actually ask for, one answer drowned out the rest: shrink. Not another calculator, not a nicer chart — where the stock is going. Every vendor writing about this sells against theft, and the thing they all describe is the same reconciliation: the count the system reports against the count on the shelf. So this is the feature I most wanted to build, and it is the one that made me do the groundwork before I could.
A missing unit is not a missing unit. If a restocker pulled six yogurts because they expired and logged the reason, that is a loss, but an accounted one — you know where it went. If the system expected ten and a physical count found seven, and nobody logged anything, those three are unexplained shrink: the theft-or-miscount signal, the money that leaves without a trace. The report keeps the two apart and leads with the unexplained number, because netting them together — "total loss $40" — buries the one figure an operator is supposed to chase under the one they already expected.
A surplus never counts as negative shrink, either. Counting more than expected is its own miscount, not a credit against a real shortfall somewhere else, so the two never quietly cancel.
Then I went to wire it up and found the hole I had already flagged: the restock sessions that carry the counts are only ever created at runtime. A fresh seed has none, so the report would have rendered a perfectly honest empty page on a demo anyone can open — correct, and useless. So the first half of this work was seeding history: a couple of completed sessions per store, each walking a few slots, cycling deterministically through a shortfall, a reasoned removal, a skipped count, and a clean match. Deterministic on purpose — the counts are generated from the slot index, not Math.random, so the report shows the same numbers every server start and the tests can trust them. This is the shrink page I sequenced behind the product one for exactly this reason, and here it is.
The subtle honesty is coverage. A slot the restocker skipped counting cannot reveal shrink — it says nothing either way. A report that quietly treated skipped slots as zero shrink would read a shelf nobody checked as a clean one, which is the same fabrication the rest of this dashboard exists to avoid. So skipped counts are their own line, and the page tells you what share of slots were actually counted. Low coverage is not low shrink; it is not knowing.
The rest is the pattern the last two features already set: a pure reconciliation model with the arithmetic under test, the fleet rollup aggregated in the BFF with the standing caveat that a production build would push it into SQL, a semantic table ranked worst-first, and loss framed in dollars because units are what happened but dollars are what it cost. Three stacked pull requests now — plan a location, see what sells, find what walks — each merging in order onto the last.
Update — August 4, 2026
The planner answers a question about a store that doesn't exist yet. This one answers a question about the stores that do: what is actually selling, and what is dead weight on the shelf. Micromart ships it as "Sales by Product," with an average sales rate and a performance figure against the category. A new /operator/products page ranks every product across the fleet by revenue, over a 7, 30 or 90 day window.
Ranking by raw revenue is easy and nearly useless: the sandwiches always win and the gum always loses, and you learn nothing you didn't already know from the prices. So the performance figure is relative to the product's own category — revenue against the average product revenue in its category, where 100 is average. A gum that outsells other gum reads as above average even though it earns a fraction of a sandwich's revenue. Judging a snack against a snack is the only version of the number that tells you something actionable.
A report that lists what sold cannot tell you what to cut, because the things worth cutting are the ones that didn't sell. So a stocked product with no sales in the window stays in the table, flagged, rather than dropping out. It is the same honesty rule as the rest of this dashboard: an absence is information, and letting it vanish is a quiet lie. Those flagged rows are the entire point of the page.
Micromart's "Avg Sold" is the rate while a product was available. I don't keep per-product stock history, so mine is units per day over the whole window, which reads a slow-selling product and an often-out-of-stock one the same way. That is a real limitation and I would rather write it down than dress the number up as something it isn't. The honest version needs an availability signal this demo doesn't carry yet.
The table is a plain table with scoped headers and a caption, not a data-grid component. A few dozen rows sorted server-side do not need virtualisation or a grid runtime, and the semantic table is what a screen reader actually wants. The aggregation is the same coarse fleet rollup as the planner benchmarks, computed in the BFF with the same caveat: a production build would push it into SQL rather than fold every store's sales in the app.
The scan said the loudest real demand is shrink — reconciling the count the system reports against the shelf. I wanted to build it, and then I looked: the restock sessions that would feed it are only ever created at runtime, so a fresh seed has no completed counts, and a shrink report would render an honest but empty page on a demo anyone can open. Shipping a feature that shows nothing is worse than sequencing it. So this went first, because it stands on data the fleet already has, and shrink is next once there is count history to seed it from. Both shipped as stacked pull requests, in the order they have to merge.
Update — August 4, 2026
I went back through Micromart's site the way a competitor would, listing everything they ship that this dashboard does not, and one gap stood out as pure frontend: the payback calculator on their pricing page. Every other gap needed a backend I don't have — team roles, a payout ledger, an AR visualiser. This one is the first question a real operator asks before opening store number six: will it pay for itself, and how fast. So I built it. A new /operator/planner page: foot traffic and conversion drive orders, a basket size and price drive revenue, a margin and the platform's fees drive profit, and the payback period falls out of hardware cost over monthly net profit. Move a slider, the whole projection moves.
projectLocation takes the six inputs and returns every figure derived, nothing stored, so there is no second ledger of numbers to drift out of sync with the sliders. The one part I care about is the payback field: it is a number, or it is null. When net profit after the platform fee and the per-order transaction cut is zero or negative, the hardware never earns itself back, and the honest thing is to say exactly that rather than print a payback of 900 months that reads like a real estimate. That is the same rule the rest of this dashboard already holds to — a zero is a claim, and so is a fabricated month count.
I also capped margin at 100%. Their calculator lets it run to 120%, but a gross margin above total revenue is not a margin, so mine clamps and I wrote down why. Copying a competitor's input range is not the same as copying a correct one.
A calculator full of round-number defaults invites the reader to distrust it, so the planner offers the fleet's own averages: GET /api/operator/planner/benchmarks derives the mean basket price and items per order from real sales history. This is the backend part, and I made a deliberate compromise in it. Every other read here proxies a single store through to the API; a benchmark is one coarse fleet-wide number, and fanning a read out per store to build it would be N calls for a single average. So I aggregate it in the BFF instead. A production version would compute it in SQL in the API next to the fleet sales aggregation that already lives there, and I said so in the code rather than pretending the BFF is where this belongs. It is offered as a nudge, not forced — a shared link keeps the sender's numbers and never overwrites them.
The payback bar is a div with a width and a role="img" label, not a chart component; one value against a fixed horizon does not need a charting runtime, and adding one would have been weight for nothing. No date library, because there is no date math here, only arithmetic. I did keep react-query for the single benchmarks call, not because one fetch needs it but because matching how every other read in this dashboard works buys the caching and dedup for free and costs the next reader no surprise. And the shareable-link state uses history.replaceState rather than the Next router, which keeps the component free of a router dependency so it tests exactly like the pricing tab does, with no navigation mock, while the URL still carries the whole scenario.
The revenue test hung, and I read the failure as "the value isn't rendering." It was rendering — twice. At one unit, gross revenue per year and revenue per unit per year are the same number, so a bare text query matched two elements and threw inside the retry loop, which looks identical to a value that never appeared. The fix was to scope the assertion to the specific figure, and the lesson was that "not found" and "found more than once" wear the same face in a waitFor.
Then my pushes looked like they landed while the remote sat a commit behind, because the tool that filters my command output was swallowing the one line that would have told me. A green "ok" proving nothing is the exact failure this whole page keeps circling, and here it was again in my own workflow. I confirmed the push against the remote ref directly, the way I now check everything I cannot see the raw bytes of.
The scan turned up more than I built. The loudest thing real operators ask for, across every forum and vendor writing about micro-markets, is not another calculator — it is theft and shrink: reconciling the count the system reports against the count on the shelf, and a report of what walked. The restock sessions already capture the raw material for it. That is the next one.
Update — August 3, 2026
“Turkey Club Sandwich out of stock” came back. I had already fixed it, written it up, and moved on. It had two causes and I had fixed one of them.
The first is the more embarrassing. The API stopped inventing alerts a release ago; this app has its own copy of the seed, and that copy still picked a random message from a fixed list. It is the copy that serves the demo whenever the backend is asleep, which for something anyone can open without an account is most of the time — so I had fixed the path fewer people take and declared the bug closed. Duplicated logic is a known cost; what I underrated is that fixing one copy feels exactly like fixing the bug.
The second is that the backend was serving stale rows. Its code was correct and its data predated the fix, because nothing had re-seeded since. A correct migration of behaviour does not migrate the records already written under the old behaviour, and a fix that only applies to future writes will look broken for as long as the old data outlives it. Checking the deployed data found eleven contradictions across six stores.
Worth stating plainly, since it is the part I would want a reviewer to press on: the reseed job exists, but I have not confirmed it is deployed as a scheduled service, and an earlier note of mine claims the demo data reseeds nightly. If that claim is wrong, this returns on its own. A fix that depends on an unverified cron is a fix with a countdown on it.
Update — August 3, 2026
I had written that auth here was deferred rather than solved: a shared service token stops anyone writing to the API directly, but it authenticates the app, not a person, so the audit trail recorded the same hardcoded actor for everybody and per-visitor rate limiting was impossible. The obvious next question is whether a mix would fix it, and the answer is mostly yes, as long as you are clear about which layer does what.
The mistake would be thinking of these as "more auth". They answer different questions. The service token answers can this caller write at all, which is a security boundary. A visitor id answers which visitor is this, which is fairness. And optional sign-in answers who is this, really, which is identity. Stacking them only helps because they are not the same thing.
The app already minted a stable, opaque, httpOnly cookie in the proxy so server-side flag rollouts could put a visitor in the same bucket every visit. The operator routes now forward that to the API alongside the service token, where it becomes the rate limit key and the actor on anything written. Nothing derived from the person goes into it: no fingerprint, no IP hash, no name, just a value the server issues and later reads.
Sign-in is wired but optional. A signed-in caller is attributed properly, an anonymous one carries on, and the demo still works without an account because that is the entire point of it.
It reuses that cookie because I first built it as a second one, and that was wrong twice over. Two ids for one browser means two lifetimes to keep in step and a second thing to explain, for no gain. And the file I put it in was a middleware.ts — a convention Next 16 renamed to proxy, and this app already had a proxy.ts. Having both is a boot-time fatal: the dev server refused to start and every route returned a 404.
Worth sitting with how far that got. The unit tests passed, the integration tests passed, the typecheck passed, the linter passed, the dead-export check passed. Not one of them starts Next, so not one of them could see it. It took the first real browser request to find a bug that broke the entire application. That is the argument for end-to-end tests in a sentence: the layers below verify the pieces, and this was a fault in how the pieces are assembled. I had been carrying these specs as written-but-never-run, which is the same as not having them.
Running them turned up a second thing, quieter than the crash and worse in the long run. The specs navigated to a hardcoded seed id, store-002. With the backend up the real fleet comes back as UUIDs, the API returns a 404 for that id, and the BFF falls back to the seed exactly as designed — so the tests passed identically whether or not the backend worked. A test that cannot fail when the thing it covers is broken is not a test. It is the same blind spot that let a missing sales endpoint sit unnoticed for weeks: the fallback that keeps the demo alive when the API is asleep also hides whether the real path works at all.
So I pointed them at the real fleet, and CI went red. The test was right: the deployed API is on an older release with no restock-session routes and no migrations applied, so the flow genuinely could not open a session. But it was answering a question this tier should not be asking. These specs exist to catch the seam between two components — the bug where “Start restock” led to another “Start restock” — and pinning a UI-composition test to a separately-deployed service means the suite reports somebody else's deploy state and changes colour for reasons that have nothing to do with the change under review. A test that fails for reasons unrelated to the diff gets ignored, and an ignored test is worse than no test.
The settled answer is two modes: the seed by default, chosen deliberately and written down rather than arrived at by accident, because it is the one fixture that is deterministic and always present; and an opt-in live mode that resolves the store off the fleet and drives whatever is really serving. That is how the flow was verified end to end against Postgres, six real restock sessions, before any of this landed. The difference between this and where it started is not the default — it is that the file now says out loud what it does not cover, instead of letting a silent fallback imply otherwise.
Which left one thing still not honest. Choosing the seed and writing down why is better than drifting into it, but a documented blind spot is still a blind spot: nothing would have run the live mode, so an integration regression had nowhere to fail. So there is now a CI tier that stands up Postgres, builds the API from source, applies the migrations, seeds the operator tables, points this app at it and drives the whole restock flow. It picks up an API branch of the same name when one exists, so a frontend change that needs a backend change gets tested as the pair it actually is instead of against whatever shipped last week. The distinction I care about: the earlier change made the gap visible, and this one closes it. Only the second is a fix.
It went green on its first working run, which is exactly when to be suspicious. If the API had not come up, the BFF would have fallen back, served seed ids, and every assertion would still have passed — a green run proving nothing, which is the precise failure the tier was built to prevent. So live mode now asserts the fleet gave it a real UUID and names the seed id it got instead. The lesson I keep relearning here is that a passing test is a claim, and a claim is worth checking when the cost of it being wrong is that you stop looking.
Rate limiting that works. Every operator request reaches the API server-side from this app, so limiting by IP put the whole world in one bucket: one person in a loop could have started returning 429s to everyone else. Keyed by visitor, a runaway caller now only exhausts their own budget.
An audit trail that says something. Every restock session used to record operator@smartstore.example, which is worse than useless: it looks like an answer. Sessions now carry either a real signed-in subject or anonymous:v_…, deliberately prefixed so nobody mistakes it for a username. Two restocks sharing one are the same browser, which is a real and useful fact about a shift.
The visitor id is self-asserted. Clear the cookie and you are a stranger with a fresh budget. That sounds fatal until you notice the service token already decides who can reach these endpoints at all, so this never has to resist an attacker; it has to tell honest visitors apart, which is all a fairness limit needs. It would be the wrong thing to hang a security decision on and I have not hung one on it.
And the thing it genuinely cannot do: you cannot have both no login and trustworthy attribution for the same action. That is definitional, not an engineering gap. An anonymous id tells you two actions came from the same browser and can never tell you who was holding the phone. So the honest answer is real attribution for people who identify themselves, honest labelling for people who do not, and no pretending the second is the first.
The service token also stays a bearer secret. Anyone who obtains it has full write access, there is no revoking one caller without revoking all of them, and rotating it means coordinating two deploys. Fixing that properly means short-lived signed tokens or mutual TLS, and neither is worth it for a demo.
Solved now: writes are closed to anything but this app, limits are per visitor rather than per egress IP, and the audit trail distinguishes callers instead of naming a constant.
Not yet, and roughly in the order I would do it: roles, so a restocker cannot read finances; a real login for operators, which turns anonymous:v_… into a name and is now a small change rather than a redesign, because the plumbing that carries identity already exists; token rotation without a synchronised deploy; and per-tenant isolation the day there is more than one operator. None of that is blocked on anything. It is waiting for a reason, which is a better position than being blocked on plumbing.
Update — August 2, 2026
Someone looked at the fleet page and every store reported 0% inventory. Nothing had errored on screen. The cards rendered, the numbers were formatted, the layout was fine. It just was not true.
Three separate places had each decided, reasonably on its own, to keep going quietly. The summary request failed and the fallback said nothing. The response was cast rather than parsed, so nothing checked it. And a store with no summary rendered ?? 0, which is the line that turns an absence into a fact. Any one of those alone is defensible. Together they produced a confident dashboard full of zeroes.
An operator who opens the Tax tab and sees nothing concludes the store made no sales. That is a conclusion they act on: not chasing a remittance, not questioning a number that should have been there. A fleet reporting zero critical alerts and zero average fill reads as good news, so they stop looking. The failure mode of a silent error is not confusion, it is misplaced confidence, and it costs more than an error message ever would.
So the rule I settled on is that the interface has to distinguish three states it had been collapsing into one. Loading is not knowing yet. Absent is not knowing at all. Zero is a measurement. Only the third is a number, and the other two now render as a pulsing placeholder and an em dash respectively. There is a test asserting the word "null" never reaches the screen, because it briefly did.
An empty list from a failed request is a lie with a plausible shape, so the layer that produced it stopped producing it. When the API is unreachable and the seeded demo data has nothing for that store, the response is a 503 saying so rather than a 200 with an empty array. The tab shows that it could not load, states plainly that this is an error and not an empty store, reassures that nothing was changed, and offers a retry.
It also offers a way to tell me. That is not boilerplate. Anyone can use this dashboard without an account, which is the whole point of it, and the flip side is that nobody has a support channel by default. Without a contact route their only options are to assume the zero is real or to close the tab, and both of those lose the person and the bug report at once.
The charts drawn with divs had no way to get a number out of them. You could see that one month was taller than another and never find out by how much, which makes a chart decorative rather than useful: the shape is the summary and the value is the answer. The ones built on a chart library already had tooltips, so the hand-rolled half of the same dashboard was quietly worse for no reason anyone had decided on.
They all have hover values now. Deliberately not focusable, though: making every bar a tab stop would add seven to twelve of them per chart, and it buys nothing for anyone using a screen reader, because each chart already carries a list of the same values beside it. The tooltip is a mouse affordance layered on an accessible path that existed first, rather than the only way to read the number.
Update — August 2, 2026
Wiring this to a database turned up a run of bugs in a couple of hours. Rather than list them, I want to sort them, because the interesting question is which ones were real and which ones only existed because I had spent months faking the data and had built habits around that.
Two queries no database would accept. Making the buckets timezone-aware left the GROUP BY repeating an interpolated expression. Drizzle re-emits a sql fragment with fresh parameter numbers each time it is used, so the GROUP BY copy read $5 and $6 where the SELECT read $1 and $2. Postgres compares parse trees, decided those were two different expressions, and rejected both queries for selecting an ungrouped column. Nothing about fake data caused that and nothing about real data would have prevented it.
Every time bucket was UTC. A Toronto store's day started at 8pm the previous evening. That is a correctness bug about the world, not about my fixtures, and it would have been quietly wrong in production for as long as nobody checked which day a sale landed on.
A cast where a parse belonged. The fleet summary was read with res.json() as FleetSummaryResponse. A blind assertion at a trust boundary, with the Zod schema for it sitting unused in the same codebase. Real data drifts more than fixtures do, so this is worse in production, not better.
Alerts that contradicted the inventory. Every store was stamped with the same four alerts, so a store with a full shelf still reported a sandwich out of stock and a store at 4C still warned it had reached 8.2C. That specific bug only exists because I wrote the alert text by hand; a real deployment generates alerts from the same readings the inventory tab shows, so they agree by construction.
Except the failure it imitates is extremely real. The moment alerts come from a separate service, or a cached rollup, or a nightly job reading a snapshot, you get exactly this: two screens describing the same shelf and disagreeing. I have now written tests that assert an alert can never contradict the row it describes, and those tests would keep earning their keep against a real pipeline.
Store cards showing 0% everywhere. The frontend falls back to seeded data when the API is unreachable, which is what keeps the demo working. When the fleet summary started failing, the store list still came from the API with real ids while the summaries fell back to seeded ones with different ids. Nothing matched, and the UI rendered absent as zero.
A production deployment has no seed to fall back to, so it would have shown an error instead. But strip the fixtures away and the real lesson stands: a partial failure produced a page that looked fine and was entirely wrong, and no layer said anything. The fallback was silent, so nothing logged. The response was cast rather than parsed, so nothing validated. Absent data rendered as a real number, so nothing looked broken. Three separate places each chose to keep going quietly, and the result was a confident dashboard full of zeroes.
The seeded store outliving its own shape. The fixtures live on globalThis so one dev server shares a copy, which means they survive hot reloads. A store created before a collection existed kept coming back without it, and writes failed while reads worked. Purely a development artefact, since a real process starts clean. It is still a cache invalidation bug, and it still cost me twenty minutes of blaming the wrong thing.
The fixture-shaped bugs were the cheap ones. The expensive pattern was that I had trained myself, across months of building against fake data, to treat every failure as survivable. Fall back, cast, carry on. That is a reasonable instinct when the only thing behind the wire is a file of made-up stores. It becomes a liability the moment something real is on the other end, because the same instinct turns a loud failure into a quiet lie.
So the fixes were less about the individual bugs and more about deciding, in each place, whether silence was still the right answer. An unreachable API stays survivable, because that is what the fallback is for and the demo has to work. A rejected token does not, because that is a configuration mistake pretending to be an outage. Absent data now renders as absent rather than as zero. And SQL gets executed against a real Postgres in a test, because a mocked repository will cheerfully return rows for a query no database would ever accept.
Update — August 2, 2026
The instinct is to put user auth on the writes, and I had to be honest with myself about who this is actually for before I could see why that was wrong. This dashboard exists so somebody evaluating my work can open a link and use it. That is the whole brief. A hiring manager with ten minutes is not going to create an account to find out whether my restock flow is any good, and if the interesting half of the product sits behind a login then for that reader the interesting half does not exist.
So "anyone can land on this cold and drive the real thing immediately" is not a nice-to-have I am trading away for security. It is the requirement. Requiring a token from the visitor would have returned 401 on every write, the frontend would have fallen back to its in-memory seed, and the dashboard would have gone back to looking real while persisting nothing, which is the one thing this whole run has been about removing.
But the hole I actually had was a different one. Anyone could point curl at the API and change the data directly, never touching the app. Those are two separate problems and I had been treating them as one.
Writes now carry a shared secret that only the backend-for-frontend holds. The browser never sees it, because the browser never calls the API directly; it calls my Next server, which calls the API on its behalf. So a visitor is unaffected and a direct caller gets a 401. The comparison is constant time after a length check, and with no secret configured the guard is a deliberate no-op so a fresh clone and local development still work. There is nothing to forge when there is no secret.
The case for it is short. It closes the direct-write hole, it costs one header and one environment variable, it needs no session handling or token refresh, and it does not ask anything of the person using the dashboard. For a demo that has to stay open, that is most of the value of auth at almost none of the cost.
The case against it is longer and worth being straight about. It authenticates a service, not a person, and that has consequences I can point at in my own code. The restock audit trail I was so pleased with records an actor for every session, and that actor is the same hardcoded string for everybody, because the API genuinely does not know who is on the other end. I built a feature whose entire purpose is answering "who changed this and why" and I can currently only answer half of it.
It is a bearer secret, so anyone who obtains it has full write access and there is no revoking one caller without revoking all of them. Rotating it means coordinating two deploys, or teaching the API to accept two secrets during a changeover window, which is exactly the sort of thing that gets skipped and then bites a year later.
It also introduced a failure mode I had not thought through until I wrote it down. Set the secret on the API and forget it here, and every write comes back 401 while reads carry on fine. Worse, the backend-for-frontend caught that 401 in the same handler it uses for "the API is asleep" and fell through to the seed, so the write would have looked like it succeeded and persisted nothing. I had rebuilt the exact fiction I keep saying I removed, inside the code meant to protect it.
Those two cases deserve opposite treatment. An unreachable API is expected, and falling back is the right answer. A rejected token is my own mistake and should be loud. So the fallback now only catches the first: a 401 or 403 is rethrown with a message naming the variable to check, and the write fails visibly instead of pretending. A silent success is worse than an error, and it took writing the tradeoffs down to notice I had shipped one.
None of that makes it the wrong call for what this is. A portfolio piece has a different threat model from a product: the data is fake, it restores itself nightly, and the cost of a bad actor is a demo store showing odd numbers for a few hours. The cost of a login wall is that the person I built this for closes the tab. Weigh those honestly and the service credential is not a compromise, it is the right shape for the problem.
What I would want to be asked about it is what changes when it stops being a demo, and the answer is that real user auth, per-user limits and a truthful actor on the audit trail are one piece of work, starting the moment there is a real operator to protect rather than a reader to convince.
The seed was building each shelf by picking a random product per slot, so a six-slot store routinely showed the same sandwich three times and the pricing table looked broken. Real planograms do not stock one product in three slots and call it variety. It now walks the product list in order and only repeats once it runs out.
And I finally wrote the fallback tests. The BFF prefers the live API and drops to the in-memory seed when it is unreachable, which is what keeps the demo working when the backend is asleep. I had put that test in three separate plans and written it zero times. It exists now, and it drives whole features through the fallback rather than checking that a try/catch is present, because the thing worth pinning is that the seed can actually satisfy the same contract the API does.
Update — August 2, 2026
Three features were sitting in stacked pull requests waiting to go in. Before merging I went back over them the way I'd want someone to go over mine, and the most useful thing that came out of it was a claim of my own that turned out to be false.
The timezone work added a nullable timezone column, and I'd written in the pull request that the API would keep working if the migration hadn't run yet, because the resolver falls back to the province. Nullable column, safe fallback, no problem.
Except migrations in this project were manual at the time. Nothing in CI, the Dockerfile or the start script ran them, which is no longer true and is its own story further down. So the gap between merging and migrating was real, and I wanted to know exactly how bad it was rather than assume. I generated the SQL Drizzle actually emits:
select "id", "name", "location", "province", "timezone",
"status", "temperature", "uptime", "revenue_24h",
"last_ping", "created_at"
from "operator_stores"An explicit column list, not select *. Before the migration, Postgres raises 42703 and every store read returns a 500, which takes the fleet list, the store detail page, the fleet summary and everything that looks a store up on the way to doing something else. My fallback never runs, because the query never returns a row for it to run on.
The fix is not complicated once you know: run the migrations first, then merge. They're additive, one nullable column and three new tables, and the version currently in production doesn't select the column or know the tables exist, so the schema can sit ahead of the code with nothing noticing. Expand first, deploy second. What I find worth writing down is that I had the shape of the answer right and the direction backwards, and the only reason I caught it was checking a claim I was already confident about.
The obvious alternative is to run migrations automatically as part of the deploy, and I decided against it. It reads like better developer experience and mostly buys a worse failure mode: two deploys racing each other both try to migrate, a destructive migration goes out before I have read it, and a migration that fails halfway leaves a broken release with no obvious rollback. Keeping it manual costs me one command and keeps the ordering something I own rather than something that happens to me.
What makes that cost acceptable rather than a trap is the discipline it forces: every migration has to be safe to run against the currently deployed code. Add columns nullable, add tables nobody reads yet, and never drop or rename in the same release that stops using something. Do that and the ordering stops being dangerous, because the schema being ahead is always fine and the code being ahead never happens. Users notice none of it, which is the point.
Everything above is what I thought at the time, and I am leaving it there rather than quietly editing it, because the way I got it wrong is the useful part. Migrations now run automatically on deploy. The container entrypoint runs them and then starts the app.
What changed my mind was watching the cost land. I added a migration for the to-do list, shipped it, and only noticed while writing the release notes that nothing anywhere would apply it. Not CI, not the Dockerfile, not the start command. “One command” is only one command if you remember it, and the thing about a step that lives in your head is that it has no failure mode, it just has you.
So it is worth taking the three objections I raised seriously rather than pretending I had them wrong:
Two deploys racing each other. This one was answerable. Knex takes a lock before it migrates, so the second one waits rather than corrupting anything. The real version of the problem was the cron containers, which share the image and would have raced the web container for that lock, and a cron job that dies on a lock is a worse outcome than a migration landing a moment later. They skip it now.
A migration that fails halfway. Answered, and it turned out I had been worrying about something that could not happen. Postgres has transactional DDL and knex wraps the whole batch in one transaction, so a migration that dies after its third statement leaves nothing behind. I checked rather than assumed: a migration that creates a table and then throws leaves no table, no row in knex_migrations, and on a database that was already migrated, all thirteen earlier migrations still applied. Add set -e in the entrypoint and the whole failure mode is a failed deploy with the previous release still serving.
The honest footnote is that this was true before I automated anything. I had listed it as a cost of automating without checking whether it was a real property of the tool I was already using. There is now a test failing on CREATE INDEX CONCURRENTLY and on anything disabling transactions, because that is the one way to lose it, and losing it quietly is worse than never having had it.
A destructive migration going out before I have read it. This one is real and automation cannot touch it, so it is gated instead. A test reads the up() of every migration, looks for drops, renames, truncations and column tightening, and fails unless the file writes down why:
// DESTRUCTIVE: drops todos.detail, unused since 4.9.0 and // confirmed empty in production before this shipped.
An acknowledgement rather than a ban, because dropping a column is sometimes exactly right and the problem was never the drop, it was doing it without thinking about the code currently running against that schema. Writing the reason costs nothing when you have thought about it and is impossible to produce when you have not, and it lands as a line in the diff, which is the only place it does any good.
down() is deliberately not scanned. A down that drops the column its up added is exactly correct, and a check that flagged all of them would just teach me to ignore it. The usual right response to the test failing is not to write the comment at all, it is to expand first: add the new thing, ship the code that stops using the old thing, drop it a release later.
What I would want to have known earlier is underneath all three. I kept a manual gate because it felt like control. Two of the three things I thought it was protecting were properties of Postgres and knex that I already had and had never checked for, and the third was the expand-first convention, which belongs to how migrations are written rather than to who types the command. The gate added no safety. It added something to forget, and I duly forgot it.
By this point the operator module had twenty routes, nine of them writes, none of them authenticated or rate limited. The obvious move is to add checkJwt to the writes, and there's already a pattern for it in this codebase: the feature flags console forwards the visitor's Auth0 token through its BFF.
I didn't, and the reason is worth more than the change would have been. The operator client sends no token. So adding auth would 401 every restock and every promotion coming from the dashboard, the BFF would catch it and fall back to its in-memory seed, and the demo would carry on looking like it worked while persisting nothing. That's exactly the fiction I'd spent three features removing. Reintroducing it in the name of security would be the worst kind of change: defensible in a summary, actively harmful in practice.
What actually bounds the exposure is a rate limit, so every route got one. The blast radius is genuinely only demo data too: the operator repository touches nine tables and every one of them is an operator table, so there is nothing else in the API for those endpoints to reach. Anything an anonymous caller creates cascades off the stores, and the nightly reseed deletes the stores.
Then I got the limiter wrong, which is the more interesting half. I copied the numbers from the feature flags module without thinking about where the traffic comes from. Flags requests arrive from the visitor's browser, one bucket each. Operator requests do not: they all reach the API server side from the dashboard's own backend-for-frontend, so they share a handful of hosting IPs. One open dashboard polls about eight times a minute, which meant a 120 per minute ceiling would have started refusing real users at roughly fifteen concurrent tabs, while doing nothing whatsoever about distributed abuse, since anyone calling the API directly gets a fresh bucket. The limiter I added to protect the thing would have hurt it more than an attacker would.
So the numbers are much higher now, high enough that no amount of normal browsing trips them and low enough to stop a runaway loop, and trust proxy is set to one hop so the key is the real caller rather than the platform's edge. It's a backstop, not per-user fairness. Doing fairness properly needs the backend-for-frontend to forward who the caller is, which is the same plumbing auth would need, and that's the point at which both become one piece of work rather than two.
Auth belongs here the moment there's a real tenant to protect. It just isn't a decision to make quietly inside a cleanup.
The promotion performance query had no upper bound. An open-ended promotion left running for a year gives you a year-long window, and the baseline doubles the fetch, so measuring one would drag two years of sales through the app to answer a single question. It clamps to the most recent 180 days now, and the response reports the range it actually measured plus a note when the clamp applied. A smaller number honestly labelled beats a bigger one quietly measured over a period the reader didn't expect.
And the operator module had zero OpenAPI registrations while the rest of the API had 43. Adding twelve was routine. The part that wasn't was realising both new request schemas use .refine(), and that the library throws on some schema shapes with no symptom until the docs page falls over at runtime, long after CI went green. So there's a test now that just generates the document and asserts it didn't throw. Cheap, and it covers a failure that would otherwise surface as a support question.
None of this is glamorous work. It's the difference between three features that demo well and three features I'd be comfortable putting in front of real operators, which is the only distinction that matters once something is actually running.
Update — August 2, 2026
The Pricing tab I built models a discount and shows the revenue and profit tradeoff. It is careful to say it assumes volume holds, and it is a genuinely useful modelling tool. But it persists nothing, which means it can never be wrong out loud. You cannot run the promotion, and you certainly cannot go back afterwards and find out whether the prediction was any good.
Micromart shipped self-serve promotions about a month ago: create them, target by location or product, schedule them, and read built-in performance analytics. The scheduling and the after-the-fact measurement were the parts I did not have.
There was also a loose end in my own schema pointing straight at this. The price-update activity type had been in the enum since the beginning, with a label, a colour and an icon in the feed — and nothing had ever created one. Dead configuration waiting for a write path. This is that write path.
No status column. A promotion looks like it wants one — scheduled, active, ended — but a stored status needs a job to flip it and is wrong in between runs. Status is a comparison between the window and the clock, so it is derived on every read. The client derives it again rather than trusting the payload, because a tab left open overnight should not keep calling a finished promotion live. That is one of the tests.
No price mutation. Nothing writes the discounted price into operator_inventory.price. The discount applies at read time, so the list price survives — and the list price is the number every margin calculation needs. Overwriting it would mean losing the original the moment a promotion starts, and then reconstructing it from an audit log to answer the simplest question about profitability. Same derive-don't-store call the Tax tab and the calculator already make.
Performance compares units and revenue inside the promotion window against an equal-length baseline immediately before it. Equal length matters: comparing a two-week promotion against the previous month would flatter or punish it purely on duration. It is two grouped queries filtered in SQL, so measuring a fortnight does not drag eighteen months of sales into Node.
The part I care more about is what it does not claim. This is a before-and-after, not attribution. Seasonality, a new product on the next shelf, and a fridge that ran warm for a week all move the same number. So the API returns both raw totals rather than only a headline delta, ships a note field saying so in words, and the UI repeats it. A dashboard that quietly implies causation is worse than one that admits what it is showing — and it is the same instinct as the calculator saying it assumes volume holds rather than inventing an elasticity model.
This is the feature that made the other two worth doing in that order. A promotion window is a pair of instants that an operator thinks about as "starts Monday morning", and Monday morning is only meaningful in the store's timezone — which is why the timezone work had to land first, and why the schedule form names the zone rather than hoping. And the promotion writes a price-update into the same activity feed the restock sessions write to, so the store's history reads as one narrative instead of three disconnected logs.
It also forced me to pay a debt. Both earlier features shipped deliberately duplicated helpers — the same arithmetic in the Express service and the Next app, on purpose, because two tested copies of thirty lines beat coupling two deploys. Both times I wrote in the recap that the test justifying the duplication did not exist. It exists now: a parity block that runs the same vectors the API asserts through the client copies, so a change to one that is not mirrored fails the build. A design decision without the test that holds it up is just a comment.
I shipped the measurement endpoint before anything called it, which meant the headline claim was true of the API and not of the product. That gap is closed now: each promotion that has actually started has a Results control, and opening it shows before and during side by side rather than only the delta.
Showing both columns is the whole point. A single number reading "up 60%" invites you to read a cause into it. Two columns and a labelled change, with the range that was actually measured named in the store's timezone underneath, makes it obvious you are looking at two periods rather than an effect. Where there is no baseline at all the cell says "no baseline" instead of a percentage, because dividing by zero politely is still making something up.
Writing the tests for it turned up a bug I would not have found by clicking. The API had gained measuredFrom and measuredTo when I added the 180 day clamp, and I never added them to the client schema. Zod strips unknown keys silently, so the fields arrived and were quietly discarded, and the component threw Invalid time value on a date that was undefined. Silent stripping is usually the behaviour you want from a parser at a trust boundary, right up until the thing being dropped is something you added yourself.
Operators get a loop instead of a guess. Model a discount, schedule it in two clicks with the modelled numbers pre-filled, and afterwards see what actually happened next to what was predicted. Overlapping promotions resolve to the deepest rather than stacking, which is both predictable and the one that favours the person standing at the fridge.
Developers get a promotions table with no lifecycle job attached to it, which is one fewer thing that can be subtly wrong at 3am. Widening it to fleet-wide campaigns is a single migration making store_id nullable — I left it NOT NULL because guessing the grouping semantics before anyone has asked for them is how you end up maintaining a shape nobody wanted.
Update — August 2, 2026
Restocking is the single most documented workflow on Micromart's site, and it is documented as a phone task. Pick store, pick cabinet, tap a slot, see the expected count, optionally confirm a physical count, Add and Remove per slot with a required reason on removals, repeat, review, complete. Skipping the count is explicitly supported so a team can spot-check rather than count everything.
Mine was one button. It ran update operator_inventory set current_stock = capacity and wrote a single activity row reading "Restocked N item(s) to full capacity".
That is not a simplification, it is a fiction. It cannot express six yogurts binned because they expired, a sensor reading eight where the shelf held five, or a case damaged in the van. Shrinkage and miscounts are exactly where an unattended-retail operator's margin goes, and my data model had nowhere to put either of them. I had built the happy path and called it the feature.
A restock is now a session with one line per product touched, and inventory is never written directly. Lines accumulate while the restocker works the shelf; completing the session is the only thing that touches operator_inventory, in one transaction. One write path means the audit trail cannot be bypassed, which is the whole reason the feature is worth anything.
The subtle part is that counted_qty is nullable, and that is deliberate rather than lazy. Null means the restocker chose to skip counting that slot. That is a recorded decision, not absent data, and it is what lets a line be classified as matches-expected, correction, or not-counted. A spot-checked shelf and an unchecked one look identical in a schema that only stores the final number, and telling them apart is most of the value.
Three options, all defensible. Delete it and force the full flow; keep it as a second, un-audited path; or rewrite it. Deleting it turns "top everything up before I leave" into a six-step wizard, which is a worse product for a real operator on a real route. Keeping it un-audited leaves a hole straight through the feature I just built.
So I rewrote it. Quick-fill now opens a session, writes a line per item marked not counted with the top-up as the add, and completes it. The response shape is byte-identical for the existing client, so the optimistic mutation on the frontend did not change at all — but the shortcut now leaves the same trail as a walked shelf, and honestly labels itself as a fill nobody counted.
The API contract here is not a passive data pipe; it decided the shape of the UI in three places.
Completing twice is a 409, so the client can be dumb. A double submit from a phone with a flaky connection is the likeliest failure mode in this whole feature, and applying the adds and removes twice would silently corrupt the shelf. Because the server refuses the second one, the frontend does not need request de-duplication, an idempotency key, or a disabled-button race. It needs a disabled button for the common case and an error message for the rare one. Pushing that invariant server-side removed a category of client state.
Lines are upserted on (session_id, item_id), so saving is idempotent. That is what makes it safe to push a line on slot-save and retry without thinking. And it is why I push on save rather than on every tap: per-keystroke writes over a bad connection is the obvious wrong design, so the local draft is the source of truth until a slot is done, and then exactly one request goes out.
The session lives in the database, so resume is nearly free. One localStorage key holds the id; a reload re-fetches the session and carries on. That sounds like a small thing and is not: the target device is a phone in a parking garage or a stairwell, and losing twenty slots of counting to a backgrounded tab would make the whole feature untrustworthy. Real offline queueing needs conflict rules and is a project of its own, so I drew the line at surviving a refresh and said so.
Steppers, not number inputs. Every target at least 44px. The running result is in an aria-live="polite" region so it can be confirmed without looking away from the shelf. The reason picker is a real radiogroup that becomes required the instant anything is removed, with the message tied by aria-describedby rather than shown as a floating red string.
Two of my own component tests failed on the first run and both were real bugs, not bad tests. The skip-count control relabelled itself as it toggled, which reads as two different buttons; it now keeps one label and carries the state in aria-pressed. And "not counted" appeared twice on the same screen in two different meanings. Writing the assertion from the operator's point of view is what surfaced both.
Operators get the question they actually have an answer to at month end: where did the margin go. "Restocked 6 items" tells them nothing; "-5 (3 expired, 2 damaged), 2 corrections" is a sentence they can act on. And the correction count is a second, quieter signal — a slot that keeps disagreeing with the sensor is a hardware problem, not a stock problem.
Developers get one write path to inventory and a pure helper that owns the arithmetic on both sides. Every future feature that moves stock — fill targets, pick lists, returns — writes a session rather than inventing its own update, so the audit trail keeps working without anybody maintaining it. The reason codes being a constrained enum rather than free text is the same bet: it costs a migration to add one, and it makes "how much did we lose to expiry last month" a GROUP BY instead of a research project.
Update — August 2, 2026
I sat down and went through Micromart's product properly — the platform pages, the help centre, and most usefully their public changelog, which is dated and only lists what actually shipped. Their operator platform is organised as six areas: stores and monitoring, products and pricing, inventory and restocking, marketing and promotions, sales and insights, and finances and taxes. About seven months ago they shipped a release note that reads: dashboard data, timestamps, reports and CSV exports now display in local North American timezones.
I went to compare that against mine, expecting to write down a missing feature. What I found instead was that mine was wrong.
Every bucket boundary in the whole stack was UTC. buildPeriods floored with Date.UTC, alertsByDay divided epoch milliseconds by 86,400,000, and on the API side the SQL truncated with a bare date_trunc(granularity, occurred_at), which resolves in whatever timezone the database session happens to be in.
For a Toronto store in summer that puts the day boundary at 8pm the previous evening. For Vancouver it's 5pm. The busiest part of an operator's afternoon was being filed under tomorrow. Nobody noticed because the seed data is spread evenly and every store was treated identically — the bug is invisible right up until you care which day a sale landed in, which is the entire reason a sales chart exists.
That's the honest version of "competitive analysis" for me. Reading someone else's changelog is worth doing not because you copy the feature, but because it points a flashlight at the assumption you never checked.
The first real question isn't technical. A timezone could reasonably come from the browser or from the store, and picking wrong makes the whole feature feel broken. I went with the store. An operator servicing a Vancouver route from a hotel room in Toronto should not watch every chart shift three hours because they got on a plane. The store's day belongs to the store.
That decision is what makes the rest of the design fall out cleanly: the zone is resolved server-side, travels in the store DTO, and the client only ever formats with it. The frontend never re-derives policy, which means there is exactly one place a Vancouver store can be told it's in Vancouver.
This is the part I find most interesting, because three constraints that live entirely in the API ended up dictating frontend code.
Postgres 15, not 16. The clean way to do this in SQL is the three-argument date_trunc(field, source, zone), which landed in Postgres 16. This project runs 15. So the SQL does the round trip by hand: shift the timestamptz into local wall clock with AT TIME ZONE, truncate there, shift the result back. It works on both versions, so the fix doesn't quietly depend on someone bumping a Docker tag. The knock-on for the client is that the instants coming back are local period starts, not UTC midnights — which is why the bucket join key is now the raw instant rather than a sliced ISO string.
Migrations ran by hand, at the time. Nothing in CI, the Dockerfile or the start script ran them, so deploying code that selects a column which doesn't exist yet would 500 the entire stores endpoint. The entrypoint migrates on deploy now, which removes the forgetting but not the ordering: a deploy still brings code and schema at the same instant, so the code must tolerate the schema it replaces. That is why the column is nullable, why the API resolves store.timezone ?? timezoneForProvince(store.province), and on the client the field is optional in the Zod schema with the province as a fallback. A browser holding the new bundle against an API that hasn't deployed yet still renders correctly. That's not defensive padding — it's the only reason the two PRs can land in either order.
The same calendar math had to exist twice. The API buckets for the fleet rollup; the client buckets for per-store views over data it already has in cache. I deliberately did not extract a shared package for this. Two small, tested, independently-versioned copies of about eighty lines beat a shared dependency that couples a Next app's deploy to an Express service's, for a function whose inputs are two dates and a string. The compromise is that the two can drift; the mitigation is that the DST cases are pinned by tests on both sides, and drift there fails loudly.
The only question zone-aware bucketing actually asks is: given this instant and this zone, what is the local year, month, day and hour. Intl.DateTimeFormat.formatToParts answers exactly that, using tzdata the runtime already ships. Pulling in Luxon or date-fns-tz would send 20 to 60kB of a second copy of tzdata down the wire, on a release cadence I don't control, to do a job the platform already does.
The cost is real though, and it's the thing people get wrong with Intl: constructing a formatter is genuinely expensive, while calling one is cheap. So formatters are built once per zone and cached in a module-level Map. But the bigger win is structural rather than a cache: rather than asking "what local day is this sale in" once per sale, dayBoundaries resolves the eight local-midnight boundaries up front and then places every sale with plain integer comparisons. Over eighteen months of history that's eight zone resolutions instead of tens of thousands, and it's the difference between a chart that renders instantly and one that stutters when you flip the range toggle.
Getting DST right is the whole reason this needs care. A local day is not 86,400,000 milliseconds twice a year: in 2026 March 8 is 23 hours long and November 1 is 25. The instant lookup runs two passes, because the UTC offset you need depends on the instant you're trying to find. Three tests pin exactly that, plus one for Newfoundland, which sits at minus three thirty and breaks any code that assumes zone offsets are whole hours.
Per-store views have an obvious right answer. The fleet view does not. The fleet spans BC through Ontario, so a bucket labelled "Tue" cannot simultaneously be Vancouver's Tuesday and Toronto's — those are different, three-hour-offset spans of real time.
The tempting answer is to bucket each store in its own zone and add the results up. That's the one genuinely wrong option, and it's wrong in a way that hides: local days are offset spans, so the windows overlap and leave gaps, the buckets stop being a partition of time, and the bar heights become quietly meaningless. Nothing about the chart looks broken. It just isn't true.
So the fleet chart buckets in one zone — the viewer's, because "my Tuesday" is how someone reading a roll-up actually thinks — and the UI says so, in plain text, right under the range toggle. The label is not decoration or a disclaimer. It is the thing that makes the number honest, and it's text rather than a tooltip so a screen reader gets the same disclosure as a mouse does.
One more small inconsistency I chose on purpose: the existing granularity param falls back silently on garbage, but a bad tz returns a 400. A wrong granularity shows you the wrong range and you can see that immediately. A wrong zone shifts every boundary in the response by hours and looks completely normal. Failing loudly is worth breaking a convention for.
Operators get a day that starts when their day starts. The late-afternoon rush shows up on the afternoon it happened, the restock they did at 7pm is on that evening, and the 7-day trend is seven of their days rather than seven arbitrary 24-hour windows. On the fleet view they get something subtler but more valuable: a number they can trust, because it tells them what it's measuring.
Developers get one module that owns the conversion between an instant and a local wall clock, on each side, instead of Date.UTC scattered through four files. Every new time-bucketed feature takes a zone parameter and inherits correct DST behaviour for free. That matters immediately, because the next two things I want to build on this dashboard are a restock audit trail and scheduled promotions — and both of those are worthless if "when" is wrong.
Update — August 2, 2026
I went and looked at what the commercial smart-store platforms actually put in front of an operator, and the same section kept coming up that mine didn't have: products and pricing. Manage what you sell and how you price it, run a discount across the shelf, and a profit calculator to see what a promotion does to the numbers. My tabs could tell an operator what sold and what tax they owed, but nothing helped them decide what to charge. So this is a new Pricing tab, sitting between Sales and Tax where the revenue tabs cluster.
The obvious version of this persists a new price back to the store. I deliberately didn't. Committing a price is a real write with a real schema change flowing through the backend, and the question an operator actually asks first is "what would happen if I did this" — not "change it now." So the tab is a model: pick a discount per product, or one discount across the whole shelf, and watch the projected weekly revenue move. It's the same derive-not-store call I made for tax — the sales and the list prices are the source of truth, and the calculator is a pure function over them, so there's no second price ledger to drift and no round-trip to wait on. The price-update activity type is already in the model for the day a persisted write lands.
Everything is in operator-pricing.ts, pure and unit-tested with no component in sight. promoPrice(list, percent) applies and rounds the discount (and clamps a fat-fingered percent into 0–100), weeklyUnitsFor pulls the trailing-7-day demand for a product out of the sales list, and summarizePricing rolls the rows into the headline: projected weekly revenue at list versus with the promos, the delta between them, and how many products are discounted at what average. Tax-included price per row reuses the existing computeTax by the store's province, so the pre-tax and with-tax numbers can't disagree with the Tax tab.
The one honesty note I made sure to put in the UI: the projection assumes volume holds at the new price. A real discount usually lifts volume, but modelling elasticity from a demo's seeded sales would be inventing a number. So the tab measures the thing it can actually measure — the revenue you give up (or keep) per week if the same units move — and says so, rather than dressing up a guess as a forecast.
The version the commercial platforms actually sell is a profit calculator, not just revenue, and a discount only makes sense against what a product costs. My inventory carries a sale price but no cost of goods, and I didn't want to invent a backend field for it. So cost is derived from an assumed gross margin the operator plugs in — 30, 40, 50, 60% — the same "enter your numbers" move those calculators make. unitCost(list, margin) turns the margin into a cost, buildProfitTable layers projected weekly profit at list and promo onto each row, and summarizeProfit totals it and counts anything the discount has pushed below cost.
That below-cost guard is why the discounts go all the way to a 50% clearance cut. A gentle 10% off never threatens a healthy margin, but a clearance promotion can absolutely sell a product at a loss, and the calculator should say so — the row turns red and the header warns how many products are underwater at the current margin. It's the difference between "here's a discount" and "here's what the discount does to the bottom line."
The discount controls are real buttons with aria-pressed on the selected step and labels a screen reader can read ("Set Coca-Cola 355ml discount to 10%"), the per-product breakdown is a proper table with scoped headers and a caption, and the revenue impact carries a sign and a label so colour is never the only signal. Store-wide "apply to all" is one row of buttons at the top so setting a shelf-wide campaign is a single click, then you fine-tune individual products from there.
Update — July 31, 2026
Picking this back up today. The original dashboard answers one question well: which stores need attention right now. But an operator also runs each store as a small business, and the tabs didn't help with any of that. So this is a continuation, not a rewrite — three new capabilities layered onto the same in-memory demo data and the same pure-function-plus-schema patterns the rest of the feature already uses.
The planogram already drew the shelves, but a slot was just a box in a grid. If I'm standing in front of the fridge, "box three on the second shelf" means nothing. So every slot now carries an address — shelf letter plus 1-based position, so the fifth item on shelves of four is B1. One helper, slotLabelFor(index, shelfWidth), owns that math so the grid and the refill list can't disagree about where something lives.
On top of that is a "refill run": getRefillList pulls out every slot below the healthy fill line, tags it with its address, and sorts most-empty first. That's the actual job — not "here's the whole planogram," but "go to A2, then B1, then C4, in that order." I kept it to addressing and a refill list rather than drag-and-drop rearranging. Drag-and-drop is a lot of surface area for a demo, it's fiddly to test, and it doesn't answer the question the operator actually has, which is where things are and what needs topping up.
New Sales tab, backed by a seeded sales store and a GET /api/operator/stores/[storeId]/sales route, fetched by a useOperatorSales hook on the same 60-second polling tier as inventory — sales drain stock, so they move at roughly the same cadence. The display numbers are all pure functions over the sales list: summarizeSales for the headline totals, topSellingProducts for the per-product rollup ordered by revenue, and salesByDay for a last-7-days revenue trend. Keeping them pure means they test without a component in sight, and the tab is just a thin view over their output.
This is the piece with real domain logic. Operators are assumed to be in Canada for now, so a store gained a province field and the tax lib carries a table of GST/HST/PST rates for all thirteen provinces and territories. Three regimes: HST provinces charge one combined rate (Ontario 13%, the Maritimes 15%, Nova Scotia's reduced 14%), GST-only jurisdictions charge the flat 5% federal rate, and GST+PST provinces stack the 5% on a provincial rate — including Quebec, whose QST of 9.975% sits in the provincial slot.
computeTax(subtotal, province) rounds each component to the cent independently and then sums them, which is how a real invoice itemizes tax — you don't round the total, you round each line. And buildTaxHistory rolls the sales into per-month remittance rows, newest first, so there's a record to file against. On top of that, summarizeRemittance totals what's actually owed and splits it into the federal portion (GST/HST, off to the CRA) and the provincial portion (PST/QST), which is the number the operator really wants: how much do I owe, and to whom.
The decision I'm happiest with here: the tax is derived from the sales data, not stored in its own ledger. The sales are the source of truth; a second tax store would just be a copy that can fall out of sync. Recomputing from the sales every time is cheap at this scale and there's nothing to drift. The rates are the one thing that genuinely lives outside the sales — they're a small table, and if a province changes a rate that's a one-line edit in operator-tax.ts.
Same tradeoffs as the original still apply. The rate table is a point-in-time snapshot, not a live tax service, so a real deployment would want dated rate schedules and probably a proper accounting integration rather than a demo remittance table. But for showing the shape of the thing — the province regimes, the itemized breakdown, the monthly history — a pure lib over seeded sales is exactly enough.
Update — July 31, 2026 (later the same day)
The addresses and the refill run were a good start, but the planogram was still something you only looked at. Two things it should let you actually do: rearrange where products sit, and deal with a slot whose sensor has drifted. So I made it interactive.
The catch with an editable planogram is the 60-second inventory poll. If a rearrange only lived in component state, the next poll would wipe it out and the shelf would snap back. So the layout got its own persisted store — an ordered list of slots, each with a sensor flag — behind GET and PATCH /api/operator/stores/[storeId]/planogram. A move optimistically reorders the cached slots so the shelf shifts the instant you act, then the PATCH commits it and a rollback restores order if the request fails — the same optimistic pattern the restock and dismiss actions already use.
The reordering itself is a pure function, moveSlot(order, from, to), and the render-ready grid comes from assemblePlanogram, which joins the persisted slot order and sensor flags with the live inventory. Both are unit-tested with no component in sight, which is exactly why I keep the moving parts out of the UI.
Drag-and-drop is the obvious way to rearrange a grid, but drag-only isn't accessible — you can't tab and drop. So the primary control on each slot is a pair of arrow buttons with real labels ("Move Cola to the next slot"), fully keyboard-operable, and native HTML5 drag is layered on top as a mouse convenience that calls the same reorder path. The buttons are also what the tests drive, so the behavior I ship is the behavior that's covered.
A slot can read as a "mismatch" — the sensor thinks something other than the planned product is there. Before, that was just an amber badge with no way to resolve it. Now a mismatched slot shows a Re-sync button that clears the flag (optimistically, then persisted). Because the sensor state lives on the persisted slot rather than being recomputed from the item id on every render, a re-sync actually sticks.
Update — July 31, 2026 (later still)
The Sales tab showed a single "last 7 days" trend, which is fine for a glance but useless for spotting a monthly pattern or a year-over-year trend. And it was per-store only — there was no way to ask "how is the whole fleet doing." So two things: range views, and a fleet rollup.
The trend is now driven by salesByPeriod(sales, granularity, now), which builds a fixed set of windows — 7 days, 8 weeks, 12 months, or 5 years — ending at now, then drops each sale into its window. A Day/Week/Month/Year toggle on the Sales tab just changes the granularity argument; the re-bucketing is client-side over the sales already in cache, so switching ranges is instant and makes no request. Month and year use real calendar boundaries (UTC), day and week use fixed-width windows — same idea, and it takes an injectable now so every bucket boundary is testable without mocking the clock.
"Per whole fleet" is where the request count matters. I could have fetched every store's sales and summed them in the browser, but that's N requests that grow with the fleet — the exact fan-out I killed on the dashboard the first time around. So the fleet analytics aggregate server-side: GET /api/operator/sales-analytics?granularity=… runs aggregateFleetSales over every store and returns shared time buckets, a per-store revenue ranking, and the fleet total in one response. The dashboard's "Fleet sales" section reads it through a hook keyed by granularity, so each range caches on its own.
The whole point here is efficiency, so it's worth being explicit about the request budget. Fleet analytics is one request regardless of fleet size — the server does the fan-in, not the browser. The naive version (fetch every store's sales and sum in the client) is one request per store, so at 30 stores that's 30 requests versus 1. Switching the range on the per-store tab is zero requests: the sales are already in cache and salesByPeriod re-buckets them in memory. Each granularity caches under its own query key, so flipping back to a range you've already seen is instant and makes no call either. It's the same instinct as the rest of the dashboard: the fleet overview already collapsed a 2N+1 per-poll fan-out into a single fleet-summary request, the tiered polling only asks as often as each data type actually changes, and operator actions update optimistically so a click never blocks on a round-trip. Fewer calls, and the ones we make do more.
One demo-data note: the seed used to scatter sales across the last week, which made the month and year views basically empty. I widened it to spread about eighteen months of history per store, so every range actually has bars to show. It's still seeded mock data — the point is the shape of the analytics, not the numbers.
Update — July 31, 2026 (last one today)
The interactive planogram let you rearrange products, but there was a gap I glossed over: the shelf was a dense list of occupied slots, so "moving" a product could only reorder or swap things that were already placed. There was nowhere empty to put anything. A real shelf has empty spots. So the model changed.
A planogram box is now { itemId, sensorMatch } where a null itemId means the box is empty. Each shelf is seeded with the store's products plus a spare empty shelf, so there's room to move things around. The move itself is one pure function, moveToBox(boxes, from, to): drop into an empty box and the source is vacated; drop onto an occupied box and the two swap. Nulls make assemblePlanogram render an empty box as a labelled drop target instead of skipping it, so every position keeps its address whether it's full or not.
Worth being honest about the request pattern here too, since that's a theme across this whole feature. A move is one PATCH, and it's optimistic: the client computes the new box layout with moveToBox, writes it straight into the query cache so the shelf moves on the same frame, then sends it. The layout is persisted server-side, so it survives the 60-second poll instead of snapping back — but nothing about the interaction blocks on the network. It's the same rule the rest of the dashboard follows: read paths are pooled into as few requests as possible (one fleet-summary, one sales-analytics), write paths update optimistically and reconcile in the background, and the poll cadence matches how fast each kind of data actually changes. Fewer round-trips, and the UI never waits on one.
Update — August 1, 2026
Everything so far ran on an in-memory store — seeded factory data that resets on restart. Great for a demo, but it was never real. So I moved the operator data into portfolio_api (the same Node/Express/Postgres backend the rest of the site uses) — real tables for stores, inventory, alerts, activity, sales, and the planogram — and rewired the dashboard to read and write it.
Every /api/operator/* route is now a thin proxy over the live service, the same shape as the feature-flags console: operator-client.ts makes the validated HTTP calls and operator-bff.ts prefers the API but falls back to the in-memory seed when the backend is unreachable. So the demo still works, and looks identical, whether or not the API is running — and if you do run it, you get real persistence. The client validates every response against the same Zod schemas the UI already uses, so a drifting API surfaces as a clear error instead of quietly bad state.
This is where the earlier "fewer calls" instinct pays off for real. The fleet-summary and sales-analytics endpoints used to loop the in-memory data in JS; now they are grouped SQL on the server — one GROUP BY per axis (per store, per time bucket) instead of pulling every alert and sale row across the wire to sum them here. The browser still makes one request per view; the database does the fan-in. The backend even logs the aggregation time, so the win is something you can actually measure rather than just assert.
One small contract change fell out of it: a list read for a store that doesn't exist now returns an empty list, not a 404. The in-memory version 404'd because the store simply wasn't in the map; a real list endpoint has no reason to — "no rows" is a fine answer. The store-detail read still 404s, because asking for a store that isn't there is a real not-found.
Update — August 2, 2026
Going live surfaced a few things worth fixing, and one feature worth adding.
As soon as the dashboard read from the database, every store went "offline." A store's lastPing is sensor telemetry — a real device reports it continuously — but the seed writes it once, so it aged past the 10-minute offline threshold and stuck there, with nothing the operator could do. The in-memory demo had quietly recomputed a fresh ping on every read; the DB path lost that. The fix puts it back where the data lives: the backend synthesizes a recent ping per read from the store's status (online reads strong, degraded reads stale), so the freshness tiers still mean something. I audited the rest of the read path too — lastPing was the only value ever freshened on read, so it was the only place with the bug.
Static seed data has a subtler version of the same problem: the historical timestamps don't move, so now-relative windows (the 24-hour alert trend, the day/week sales ranges) slowly empty out. Rather than fake those on read, a cron job re-seeds the whole fleet on a schedule — the same pattern the feature-flags demo uses to restore itself. The CLI seed and the job now share one seedOperator() so they can't drift. And because a periodic reset would be confusing if it just happened, the dashboard now says so up front: your changes are saved for real, but reset periodically to keep the demo fresh.
Dismissing an alert used to feel like deleting it. But the backend keeps every alert — the acknowledged flag just hides resolved ones from the active list — so the history was already there, unused. The Alerts tab now has an overview (active vs resolved, a severity split, the most common categories, a 7-day trend) and an Active / Resolved toggle so an operator can look back at what was dismissed. It's all derived client-side with two pure helpers, summarizeAlerts and alertsByDay, from the alerts already fetched — no new request. The cross-store version (fleet-wide alert analytics from a grouped SQL query) is the natural next step if it's useful.