mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(mood): read an edited entry's linked day in the entry's own zone
The hook always sent the browser's zone while the route's comment said the detail view sent the entry's stored one. Nothing was red, because nothing asserted it: correcting an entry logged in another country looked its day up under whichever zone the correction was being made from, and the night shown beside it could be hours out with nothing on screen saying so. The edit dialog passes the entry's `tz` now. The capture sheet keeps the browser's and passes no third argument, which is correct rather than an omission: the entry does not exist yet and will be anchored to that zone. A legacy row with no zone falls back to the browser's rather than to Europe/Berlin — the server owns that legacy rule and a guess sent from the client would override the one place it is written down. The resolution is a pure helper so both branches can be asserted, and both call sites are pinned structurally, because the resolution being right is worth nothing if the dialog never hands it the entry's zone.
This commit is contained in:
1 parent
eac40e79d6
commit
ed5941d313
4 files changed
+184
-13
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<LinkedDayContext>(
|
||||
`/api/mood/linked-context?date=${encodeURIComponent(day)}&tz=${encodeURIComponent(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
|
||||
|
||||
Reference in new issue
Block a user