Files
HealthLog/src/components/mood/use-linked-day-context.ts
T
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00

100 lines
4.1 KiB
TypeScript

"use client";
/**
* Read what the other modules already hold for one local day.
*
* A hook rather than a prop drilled from a page, because both mood surfaces
* need it and both know only the wall-clock value in their timestamp field.
*
* **Which zone that day is read in depends on which surface is asking, and the
* two are genuinely different questions.** The capture sheet is about to write
* a new entry, and that entry will be anchored to the zone the browser is in,
* so the browser's zone is the right one and the figures it shows are the
* figures the saved row will sit beside. The edit dialog is looking at an
* entry that already decided which day it belongs to and stored the zone it
* decided in: reading that entry's day under the corrector's browser zone
* would show a night from an entry logged in another country shifted by
* several hours, and nothing on the screen would say so. So the caller passes
* the entry's stored `tz` and the hook does not guess.
*
* The query key carries both the day and the zone. One key for both would let
* whichever answer arrived first serve the other — the same-key /
* different-shape cache poisoning the key factory exists to prevent, in its
* quieter same-shape / different-window form. Two surfaces asking about the
* same calendar date under two zones is exactly that case, and it is reachable
* from one screen: the list can hold an entry logged abroad while the sheet
* writes one logged here.
*/
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LinkedDayContext } from "@/lib/mood/linked-context";
import { userDayKey } from "@/lib/tz/format";
/**
* The browser's own zone — the right answer for an entry that does not exist
* yet, and the wrong one for an entry that already stored its own.
*/
export function browserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
export function dayOfLocalInput(value: string): string {
if (value.length >= 10) return value.slice(0, 10);
return userDayKey(new Date(), browserTimezone());
}
/**
* @param day the local day to read, `YYYY-MM-DD`
* @param enabled whether to fetch at all
* @param timezone the zone `day` is anchored to. Omit ONLY where the day has
* no zone of its own yet — a new entry the capture sheet is composing. An
* entry being edited passes its stored `tz`.
*/
/**
* The zone a linked-day read should use, and the URL that follows from it.
*
* Pure and exported so the decision can be tested without a renderer: the
* whole finding this answers was a doc comment claiming the edit dialog passed
* the entry's zone while the code always sent the browser's, and a claim like
* that is only worth as much as the assertion under it.
*
* A stored zone wins. Absent — a legacy row with `tz IS NULL`, or an entry
* that does not exist yet — falls back to the browser's, which is honest about
* being an assumption and is what the capture sheet means anyway. It does NOT
* fall back to Europe/Berlin: the server owns that legacy rule, and sending a
* guess would override the one place it is written down.
*/
export function linkedDayRequest(
day: string,
storedTimezone: string | null | undefined,
browserTz: string,
): { tz: string; url: string } {
const tz =
storedTimezone && storedTimezone.length > 0 ? storedTimezone : browserTz;
return {
tz,
url: `/api/mood/linked-context?date=${encodeURIComponent(day)}&tz=${encodeURIComponent(tz)}`,
};
}
export function useLinkedDayContext(
day: string,
enabled = true,
timezone?: string | null,
) {
const { tz, url } = linkedDayRequest(day, timezone, browserTimezone());
const query = useQuery({
queryKey: queryKeys.moodLinkedContext(day, tz),
queryFn: () => apiGet<LinkedDayContext>(url),
enabled,
// The figures come from other modules and change on their own schedule; a
// minute of staleness is cheaper than refetching four modules every time
// the sheet re-renders.
staleTime: 60_000,
});
return query.data ?? null;
}