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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:02:03 +01:00
1 parent 02856e7f37
commit 3fcfe11694
7 files changed
+73 -53

No files matched your search

@@ -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,
@@ -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,
@@ -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 }) {
@@ -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<typeof import("@/lib/i18n/context")>()),
// 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 <span>{`${from}|${today}|${to}`}</span>;
}
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(<Probe />)).toContain(
"2026-04-03|2026-07-02|2026-12-29",
);
});
});
@@ -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();
+2 -11
View File
@@ -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<CycleTab>(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();
+20 -23
View File
@@ -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);
}