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); }