From 3fcfe1169467dbfbd7849aac74d80603808bd513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:02:03 +0100 Subject: [PATCH] fix(cycle): date today in the profile zone The cycle page, its ring and the summary card took "today" and their read window from the device clock, while the server keys every cycle day, today included, in the profile zone. With a phone in another zone the highlighted day and the day "log today" writes to sat one apart from the rest of the app around midnight. One `useCycleWindow()` now dates today in the display zone and derives the shared window from it; it replaces `localYmd` and its two copies. --- .../__tests__/cycle-view-error-state.test.tsx | 6 ++- .../cycle-view-sheet-context.test.tsx | 2 +- .../use-cycle-ring-has-dial.test.tsx | 12 +++--- .../cycle/__tests__/use-cycle-window.test.tsx | 33 ++++++++++++++ .../cycle/cycle-insight-summary-card.tsx | 17 +++----- src/components/cycle/cycle-view.tsx | 13 +----- src/components/cycle/use-cycle.ts | 43 +++++++++---------- 7 files changed, 73 insertions(+), 53 deletions(-) create mode 100644 src/components/cycle/__tests__/use-cycle-window.test.tsx diff --git a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx index cedad4149..63602a290 100644 --- a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx @@ -42,7 +42,11 @@ const idleQuery = { const mutationStub = { mutate: vi.fn(), isPending: false, isError: false }; vi.mock("../use-cycle", () => ({ - localYmd: () => "2026-07-01", + useCycleWindow: () => ({ + today: "2026-07-01", + from: "2026-04-02", + to: "2026-12-28", + }), useCycleCalendar: () => errorQuery, useCycleHistory: () => idleQuery, useCycleInsights: () => idleQuery, diff --git a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx index f727d776e..89c58407a 100644 --- a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx @@ -95,7 +95,7 @@ const idleQuery = { const mutationStub = { mutate: vi.fn(), isPending: false, isError: false }; vi.mock("../use-cycle", () => ({ - localYmd: () => TODAY, + useCycleWindow: () => ({ today: TODAY, from: TODAY, to: TODAY }), useCycleCalendar: () => calendarQuery, useCycleHistory: () => idleQuery, useCycleInsights: () => idleQuery, diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx index 4b872315e..0ff861570 100644 --- a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -16,14 +16,14 @@ import { describe, expect, it } from "vitest"; import { ApiError } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; -import { localYmd, useCycleRingDial } from "../use-cycle"; +import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { useCycleRingDial } from "../use-cycle"; + +/** The ring's window, around today in the display zone (Berlin when unset). */ function window(): [string, string] { - const from = new Date(); - from.setDate(from.getDate() - 90); - const to = new Date(); - to.setDate(to.getDate() + 180); - return [localYmd(from), localYmd(to)]; + const today = userDayKey(new Date(), DEFAULT_TIMEZONE); + return [shiftDateKey(today, -90), shiftDateKey(today, 180)]; } function Probe({ enabled }: { enabled: boolean }) { diff --git a/src/components/cycle/__tests__/use-cycle-window.test.tsx b/src/components/cycle/__tests__/use-cycle-window.test.tsx new file mode 100644 index 000000000..225a5ea4e --- /dev/null +++ b/src/components/cycle/__tests__/use-cycle-window.test.tsx @@ -0,0 +1,33 @@ +/** + * The cycle surfaces take "today" from the record's display zone, the zone + * the server keys cycle days in. They used to read the device clock, so with + * a phone in another zone than the profile the highlighted day and the day a + * log lands on sat one apart from the server's around midnight. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/lib/i18n/context", async (importOriginal) => ({ + ...(await importOriginal()), + // UTC+14: already the next calendar day for most of the world's afternoon. + useDisplayTimezone: () => "Pacific/Kiritimati", +})); + +import { useCycleWindow } from "../use-cycle"; + +function Probe() { + const { today, from, to } = useCycleWindow(); + return {`${from}|${today}|${to}`}; +} + +afterEach(() => vi.useRealTimers()); + +describe("useCycleWindow", () => { + it("dates today in the display zone, not on the device clock", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z")); + expect(renderToStaticMarkup()).toContain( + "2026-04-03|2026-07-02|2026-12-29", + ); + }); +}); diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index ee1b2e0c1..8d620d7f4 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -1,6 +1,5 @@ "use client"; -import { useMemo } from "react"; import Link from "next/link"; import { ArrowRight, Droplets } from "lucide-react"; @@ -8,7 +7,11 @@ import { useTranslations } from "@/lib/i18n/context"; import { SectionHeading } from "@/components/ui/section-heading"; import { CyclePhaseHeadline } from "./cycle-phase-crosstab"; import { PHASE_HUE } from "./phase-tokens"; -import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle"; +import { + useCycleCalendar, + useCycleInsights, + useCycleWindow, +} from "./use-cycle"; import { QueryErrorRow } from "@/components/ui/query-error-row"; /** @@ -37,20 +40,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; * a broken or empty-framed state on the overview. */ -/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */ -function shiftToday(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return localYmd(d); -} - export function CycleInsightSummaryCard() { const { t } = useTranslations(); // Mirror the cycle-view window so the cache key is shared (no extra request // when the user later opens the cycle page in the same session). - const from = useMemo(() => shiftToday(-90), []); - const to = useMemo(() => shiftToday(180), []); + const { from, to } = useCycleWindow(); const calendar = useCycleCalendar(from, to); const insights = useCycleInsights(); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 586fe4b14..b748a1a8b 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -27,7 +27,7 @@ import { CycleSettingsTab } from "./cycle-settings-tab"; import { PHASE_HUE } from "./phase-tokens"; import { monthOf, monthWindowOutside } from "./calendar-window"; import { - localYmd, + useCycleWindow, useCycleCalendar, useCycleHistory, useCycleInsights, @@ -50,13 +50,6 @@ import { ListCapNotice } from "@/components/ui/list-cap-notice"; * re-triggers the moment. */ -/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */ -function shiftToday(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return localYmd(d); -} - /** The four cycle tabs, in render order. The deep-link value is validated * against this set so a junk `?tab=` falls back to the calendar tab. */ // The BBT chart is the only recharts consumer on /cycle — load it through @@ -114,9 +107,7 @@ export function CycleView() { // a later param change does not force-switch a tab the user is reading). const [tab, setTab] = useState(initialTab); - const today = useMemo(() => shiftToday(0), []); - const from = useMemo(() => shiftToday(-90), []); - const to = useMemo(() => shiftToday(180), []); + const { today, from, to } = useCycleWindow(); const calendar = useCycleCalendar(from, to); const history = useCycleHistory(); diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index cbe9afe92..0707607ca 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -21,7 +21,8 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useCalendarDate } from "@/hooks/use-calendar-date"; @@ -72,16 +73,23 @@ export interface CycleInsightsResponse { } /** - * The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys - * are tz-anchored on the server; the client must derive the key from the - * user's wall-clock day, never from `toISOString()` (which is UTC and rolls - * the date over near midnight). + * Today, and the window the cycle page, its ring and its summary card read + * (90 days back, 180 ahead), in the record's display zone. The server keys + * cycle days, "today" included, in the profile zone; the device clock put + * the highlighted day and the day a log lands on one apart from it when a + * phone sat in another zone near midnight. One window, so the three readers + * share one cache entry. */ -export function localYmd(d: Date = new Date()): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; +export function useCycleWindow(): { today: string; from: string; to: string } { + const timeZone = useDisplayTimezone(); + return useMemo(() => { + const today = userDayKey(new Date(), timeZone); + return { + today, + from: shiftDateKey(today, -90), + to: shiftDateKey(today, 180), + }; + }, [timeZone]); } /** @@ -115,20 +123,9 @@ export function useCycleCalendar(from: string, to: string, enabled = true) { }); } -/** YYYY-MM-DD for `days` from today in the local zone. */ -function localYmdFromToday(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return localYmd(d); -} - -/** - * The calendar window the cycle ring reads: the cycle page's own window, so - * the ring, the page and the summary card share one cache entry. - */ +/** The calendar window the cycle ring reads: the cycle page's own. */ export function useCycleRingCalendar(enabled = true) { - const from = useMemo(() => localYmdFromToday(-90), []); - const to = useMemo(() => localYmdFromToday(180), []); + const { from, to } = useCycleWindow(); return useCycleCalendar(from, to, enabled); }