diff --git a/src/components/mood/__tests__/linked-day-timezone.test.ts b/src/components/mood/__tests__/linked-day-timezone.test.ts new file mode 100644 index 000000000..49a64e915 --- /dev/null +++ b/src/components/mood/__tests__/linked-day-timezone.test.ts @@ -0,0 +1,108 @@ +/** + * Which zone a linked-day read is made in, and which surface decides it. + * + * The finding this file answers was not a wrong number — it was a doc comment + * that said "the detail view passes the entry's stored tz" beside code that + * always sent the browser's. Nothing was red, because nothing asserted the + * claim. Two ends, so two kinds of assertion: + * + * * the resolution itself, on the pure helper, at both branches; + * * the two call sites, structurally, because the resolution being right is + * worth nothing if the edit dialog never hands it the entry's zone. + * + * Both matchers assert a non-zero match count. An empty match set fails rather + * than passing quietly, which is how a guard stays a guard after somebody + * renames the thing it greps for. + * + * Mutation check: drop the `editing?.tz` argument at the edit call site and + * the call-site row goes red; make the helper prefer its browser argument and + * the resolution rows go red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { linkedDayRequest } from "../use-linked-day-context"; + +const HERE = join(__dirname, ".."); +const LIST = readFileSync(join(HERE, "mood-list.tsx"), "utf8"); +const FORM = readFileSync(join(HERE, "mood-form.tsx"), "utf8"); + +/** The arguments of every `useLinkedDayContext(...)` call in a source file. */ +function callArguments(source: string): string[] { + const calls: string[] = []; + const needle = "useLinkedDayContext("; + let at = source.indexOf(needle); + while (at !== -1) { + // Only a CALL, never the import line that names the same identifier. + const open = at + needle.length - 1; + let depth = 0; + let end = open; + for (let i = open; i < source.length; i++) { + if (source[i] === "(") depth++; + else if (source[i] === ")") { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + calls.push(source.slice(open + 1, end)); + at = source.indexOf(needle, end); + } + return calls; +} + +describe("linked-day timezone", () => { + it("prefers a stored zone over the browser's", () => { + const { tz, url } = linkedDayRequest( + "2026-06-11", + "America/New_York", + "Europe/Berlin", + ); + expect(tz).toBe("America/New_York"); + expect(url).toContain("tz=America%2FNew_York"); + expect(url).toContain("date=2026-06-11"); + }); + + it("falls back to the browser's zone when the row has none", () => { + // A legacy row (`tz IS NULL`) and an entry that does not exist yet are the + // same case here. Not Europe/Berlin: the server owns the legacy rule and a + // guess sent from the client would override the one place it is written. + expect(linkedDayRequest("2026-06-11", null, "Europe/Berlin").tz).toBe( + "Europe/Berlin", + ); + expect(linkedDayRequest("2026-06-11", "", "Europe/Berlin").tz).toBe( + "Europe/Berlin", + ); + expect(linkedDayRequest("2026-06-11", undefined, "Asia/Tokyo").tz).toBe( + "Asia/Tokyo", + ); + }); + + it("has the edit dialog hand over the entry's own zone", () => { + const calls = callArguments(LIST); + expect( + calls.length, + "no useLinkedDayContext call found in mood-list.tsx — this matcher proves nothing", + ).toBe(1); + expect( + calls[0], + "the edit dialog reads the linked day under the browser's zone; an entry logged abroad would be looked up on the wrong day", + ).toContain("editing?.tz"); + }); + + it("leaves the capture sheet on the browser's zone", () => { + const calls = callArguments(FORM); + expect( + calls.length, + "no useLinkedDayContext call found in mood-form.tsx — this matcher proves nothing", + ).toBe(1); + // A new entry has no stored zone to pass, so the third argument is absent + // on purpose. Passing something here would be inventing a zone for a row + // that has not chosen one. + expect(calls[0]).not.toContain("tz"); + }); +}); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index d9ce502df..866c1606a 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -320,6 +320,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { // Keyed on the timestamp field, so moving the entry to yesterday shows // yesterday's night. Only fetched once a face is picked: an unopened sheet // must not cost four modules a query. + // No third argument, and deliberately: this entry does not exist yet, so it + // has no stored zone. It will be anchored to the browser's, which is the + // zone the hook falls back to — the figures shown here are the figures the + // saved row will sit beside. const linked = useLinkedDayContext(dayOfLocalInput(moodLoggedAt), moodPicked); // v1.17.0 — the "Note & details" badge counts the free-text tags entered diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 4b5d01eac..6ced5006f 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -130,6 +130,12 @@ interface MoodEntry { note: string | null; // v1.38 — the day context, null on entries that carry none. context: MoodContextWire | null; + /** + * The IANA zone this entry's `date` is anchored to, or null on a legacy row. + * Read by the linked-day block so an entry logged in another country is + * looked up under ITS day rather than under the corrector's. + */ + tz: string | null; source: string; moodLoggedAt: string; } @@ -505,6 +511,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const editLinked = useLinkedDayContext( dayOfLocalInput(editMoodLoggedAt), editing !== null, + // The entry's OWN zone, not the browser's. An entry logged abroad decided + // which day it belongs to when it was written; re-reading that day under + // whichever zone the correction is being made from would shift the night + // beside it by hours with nothing on screen saying so. + editing?.tz ?? null, ); const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0; diff --git a/src/components/mood/use-linked-day-context.ts b/src/components/mood/use-linked-day-context.ts index 524559c25..00cc3c71d 100644 --- a/src/components/mood/use-linked-day-context.ts +++ b/src/components/mood/use-linked-day-context.ts @@ -5,15 +5,25 @@ * * 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. - * The zone comes from the browser, which is the same zone the account's - * `displayTimezone` follows for anyone who has not deliberately set it - * otherwise; the server re-resolves the day either way, so a mismatch shows up - * as a different day's figures rather than as wrong figures for this one. + * + * **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. + * 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"; @@ -21,8 +31,11 @@ import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import type { LinkedDayContext } from "@/lib/mood/linked-context"; -/** The browser's own zone, which is what the capture sheet is showing. */ -function browserTimezone(): string { +/** + * 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; } @@ -34,14 +47,49 @@ export function dayOfLocalInput(value: string): string { return local.toISOString().slice(0, 10); } -export function useLinkedDayContext(day: string, enabled = true) { - const tz = 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( - `/api/mood/linked-context?date=${encodeURIComponent(day)}&tz=${encodeURIComponent(tz)}`, - ), + queryFn: () => apiGet(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