There’s a page on the site called /masturbation that publicly shows each day’s count and an annual heatmap, plus an admin backend /dash/masturbation where the administrator can backfill, edit, and clear entries. The data is private and the page is ordinary: a year-scoped query RPC, a calendar, a date input and a count input.
On launch day there was a bug that silently ate data.
0 means both “nothing that day” and “zero out that day”
The heatmap loads by year, and the editor’s selectedCount comes from the current year’s query result. On the 2025 page, the admin changes the date to 2024-03-12—
- The date in the input changes, but the page still holds 2025 data;
selectedCountcan’t find that day in the 2025 array and falls back to 0;- The count input shows 0;
- Click save, and 0 is written.
If 2024-03-12 originally had 7 recorded, that one “nothing was changed” save wipes it out. Here 0 is both the display default for “no record” and the write value for “clear the record” — the two look exactly alike.
Let the date carry its own year
The first instinct might be to add a hint: “switch the year first for cross-year dates.” But the more reliable fix is to make the year part of the selection state:
- Selecting a date that doesn’t belong to the current year immediately
navigate('?year=2024&date=2024-03-12'); - The loader loads that year’s data based on the
yearparam, and brings the initial selected value back in via thedateparam; - The route component uses
year:initialDateas thekey, so the whole editor remounts when crossing years; - Until the new data arrives, the save button is disabled.
The key guard is this line:
const unavailable =
busy || navigation.state !== 'idle' || Number(selected.slice(0, 4)) !== yearAs long as the selected year differs from the current page’s year, saving isn’t allowed. That way, even if navigation is slow and the user is fast, the 0 from “last year hasn’t been read back yet” never gets written.
After a successful save, instead of refetching the whole year, update the local days with the returned value: if the count is greater than 0, replace or insert; if it’s 0, remove it from the array. The old useQuery + invalidateQueries are gone too, saving a round trip and removing the need to maintain the retry branch for when the query.isError refresh fails.
The heatmap itself isn’t that mysterious
The calendar lays out cells using UTC dates: from January 1 of the year back to that week’s Sunday, then day by day forward past January 1 of the next year and filled out to a full week. The page has 365 or 366 buttons, future dates are disabled, and counts only take rows from <= today. Month labels compute the column number from which week the 1st of each month lands in; the annual total, days recorded, and single-day maximum are all derived from the counts.
today uses Intl.DateTimeFormat to pin Asia/Shanghai, so it doesn’t read the server process timezone. Both /masturbation and the admin send Cache-Control: no-store, so an anonymous user clicking save gets a 401.
Links between the public page and the admin, the previous year’s ←, and the next year’s → are all there; the year is only allowed from 1970 through the current year, illegal years get a 400 and no fake records are generated. The initial table is just empty — no sample data is stuffed in.
Phone width
The same round also fixed overflow in the admin at 390×844: outer flex-wrap, date input min-w-0 max-w-full, stat cards changed from a fixed three columns to repeat(auto-fit, minmax(min(100%, 100px), 1fr)), year heading whitespace-nowrap. The e2e asserts documentElement.scrollWidth <= innerWidth.
The heatmap itself allows horizontal scrolling on narrow screens, but only inside the chart — it doesn’t stretch the whole page.
How to nail down the regression
The new e2e first saves 2024-03-12 as 7, then opens the 2025 admin, fills in this cross-year date, asserts that the count input becomes 7 within 1.5 seconds, clicks save, and finally checks the year endpoint that it’s still 7. In finally it writes the original value back, so other cases aren’t polluted.
The other case only does the phone-width check. Neither relies on “the user remembering to switch the year first.”
Wrapping up
The implicit primary key of a time-series form is (year, day). Choosing across years isn’t changing a string — it’s switching the data source; treating “loading” as a disable condition is more reliable than any hint.
Fields like 0, which are both a value and a default, are most dangerous when they get submitted as the “current value” before the data has arrived. Read it back first, then allow writes.
