diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b22647ae1..95944b652 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,16 +13,25 @@ updates: # One weekly pull request per dependency kind instead of a trickle of # single bumps: patch and minor updates travel together and pass the # same checks once. Majors stay single so each gets its own review. + # + # The Next.js line travels alone: while a 16.4 patch is still being + # re-measured, a red Next bump inside the group holds every harmless + # update in it red as well. production-weekly: dependency-type: "production" update-types: - "patch" - "minor" + exclude-patterns: + - "next" development-weekly: dependency-type: "development" update-types: - "patch" - "minor" + exclude-patterns: + - "eslint-config-next" + - "@next/bundle-analyzer" ignore: # Next 16.4.0 breaks URL state after history traversal: with a sheet # opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes diff --git a/src/__tests__/intake-toast-single-decision.test.ts b/src/__tests__/intake-toast-single-decision.test.ts index a741baf80..39e73fbb1 100644 Binary files a/src/__tests__/intake-toast-single-decision.test.ts and b/src/__tests__/intake-toast-single-decision.test.ts differ diff --git a/src/__tests__/timezone-explicit-formatter-guard.test.ts b/src/__tests__/timezone-explicit-formatter-guard.test.ts index 9eaf1456e..ee2bff254 100644 --- a/src/__tests__/timezone-explicit-formatter-guard.test.ts +++ b/src/__tests__/timezone-explicit-formatter-guard.test.ts @@ -51,6 +51,11 @@ const ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ const FORMATTER_PATTERNS = [ /new Intl\.DateTimeFormat\s*\(/g, /\.toLocale(?:Date|Time)String\s*\(/g, + // The shared cache's own entry point: it forwards its options straight to + // `Intl.DateTimeFormat`, so a call that omits the zone is a bare formatter + // exactly like the constructor form (the workout surfaces did this until + // v1.43.6). + /\bgetDateTimeFormat\s*\(/g, ]; /** The argument list starting at the `(` at or after `from`. */ @@ -68,12 +73,30 @@ function argumentList(source: string, from: number): string { return source.slice(open); } +/** Number of top-level, comma-separated arguments in `(…)`. */ +function topLevelArgCount(args: string): number { + let depth = 0; + let count = 0; + let sawContent = false; + for (const ch of args.slice(1, -1)) { + if ("([{".includes(ch)) depth += 1; + else if (")]}".includes(ch)) depth -= 1; + else if (ch === "," && depth === 0) { + count += 1; + sawContent = false; + continue; + } + if (!/\s/.test(ch)) sawContent = true; + } + return sawContent ? count + 1 : count; +} + function bareFormatterSites(): Array<{ file: string; line: number }> { const listed = execFileSync( "grep", [ "-rlE", - "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(", + "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(|getDateTimeFormat\\(", "src", "--include=*.ts", "--include=*.tsx", @@ -105,6 +128,15 @@ function bareFormatterSites(): Array<{ file: string; line: number }> { // of them and the honest response would be to allowlist them — which // is how a guard stops guarding. if (/\btimeZone\s*(?::|,|\}|$)/m.test(args)) continue; + // `getDateTimeFormat(locale, tz, options)` takes the zone as its own + // argument; only the two-argument `(locale, options)` form can leave + // it out. + if (m[0].startsWith("getDateTimeFormat") && topLevelArgCount(args) >= 3) + continue; + // A mention in a comment renders nothing. + const lineStart = source.lastIndexOf("\n", m.index) + 1; + if (/^\s*(?:\/\/|\*|\/\*)/.test(source.slice(lineStart, m.index))) + continue; hits.push({ file, line: source.slice(0, m.index).split("\n").length, @@ -125,7 +157,7 @@ describe("date formatters name the calendar they render in", () => { "grep", [ "-rlE", - "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(", + "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(|getDateTimeFormat\\(", "src", "--include=*.ts", "--include=*.tsx", diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 1e32be413..9fae5214a 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -295,7 +295,7 @@ export default function DashboardPageClient({ const digestQuery = useDailyDigest(isAuthenticated); // v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web - // hero (Marc, live-use: uneven, wasted tile space). The snapshot still + // hero (live use: uneven, wasted tile space). The snapshot still // resolves `scoreRings` server-side for the iOS selection contract; the web // hero just no longer renders them, so no client-side ring ordering here. diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index a334795ed..7e58ad751 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -318,7 +318,7 @@ describe("", () => { }); // v1.29.1 — the v1.29.0 selected-score-ring cluster was removed from the web - // hero (Marc, live-use: uneven, wasted tile space). Only the main + // hero (live use: uneven, wasted tile space). Only the main // health-score ring paints now; the cluster's data-slots are gone. it("renders no score-ring cluster — only the health-score ring", () => { const html = render(); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index d2325fd0c..936232eef 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -14,7 +14,7 @@ * - the day's read: the health `ScoreRing` (`flat`, md) with its * server-computed band and an honest provisional/final face — a null * score paints the ring's own provisional state, never a zero; - * - v1.29.1 (Marc, live-use): the v1.29.0 selected-score-ring cluster is + * - v1.29.1 (live use): the v1.29.0 selected-score-ring cluster is * removed from the web hero — it read as uneven and wasted tile space. * The health `ScoreRing` is the hero's only ring now. The score-ring * SELECTION contract stays server-side (`selectedScoreRings` on the diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index 60c094a15..ec0f87625 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { Activity } from "lucide-react"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { useTranslations } from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -32,9 +32,10 @@ import { iconForSport } from "@/lib/workouts/sport-icons"; * round-trip per session. */ -function formatDayLabel(iso: string, locale: string): string { +function formatDayLabel(iso: string, locale: string, timeZone: string): string { const d = new Date(iso); return getDateTimeFormat(locale, { + timeZone, weekday: "short", day: "2-digit", month: "short", @@ -61,6 +62,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { export function RecentWorkoutsTile() { const { t, locale } = useTranslations(); + const timeZone = useDisplayTimezone(); const { data, isError, refetch } = useWorkouts({ limit: 3 }); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the @@ -145,7 +147,7 @@ export function RecentWorkoutsTile() { > {renderRow(workout, sportName)} - {formatDayLabel(workout.startedAt, locale)} + {formatDayLabel(workout.startedAt, locale, timeZone)} {formatDurationMinutes( diff --git a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts index f0b59d9ce..626fc38a0 100644 --- a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts +++ b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; /** * v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL fenced coach * conversation in the shared SIDE DRAWER, scoped to the document, instead of a - * full-page nav to `/coach?doc=` (the v1.28.51 behaviour Marc corrected). + * full-page nav to `/coach?doc=` (the v1.28.51 behaviour, since corrected). * * The wiring is source-structural (the send-path fence itself is covered by the * coach-send-target unit tests — untouched here). These guards pin: diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index a3027ea5d..443ab5cd9 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -126,16 +126,19 @@ export function formatDateRange( endedAt: string, locale: string, timeFormat: TimeFormatPreference, + timeZone: string, ): string { const start = new Date(startedAt); const end = new Date(endedAt); const dateFmt = getDateTimeFormat(locale, { + timeZone, weekday: "long", day: "2-digit", month: "long", year: "numeric", }); const timeFmt = getDateTimeFormat(locale, { + timeZone, hour: "2-digit", minute: "2-digit", ...hourCycleOptions(timeFormat), diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index 1e4200f0b..b82c6e218 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -1,6 +1,10 @@ "use client"; -import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useTimeFormatPreference, + useTranslations, +} from "@/lib/i18n/context"; import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; @@ -32,6 +36,7 @@ function renderSportIconBadge(sportType: string) { export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const timeZone = useDisplayTimezone(); const { preference } = useUnitDisplay(); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; const sportLabel = t(sportLabelKey); @@ -68,6 +73,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { workout.endedAt, locale, timeFormat, + timeZone, )}

diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 913a19846..3ca14f2fc 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -7,7 +7,11 @@ import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useTimeFormatPreference, + useTranslations, +} from "@/lib/i18n/context"; import { hourCycleOptions, type TimeFormatPreference, @@ -40,12 +44,16 @@ function formatDate( iso: string, locale: string, timeFormat: TimeFormatPreference, + timeZone: string, ): string { const d = new Date(iso); // Show the year whenever the workout is not from the current year, so a // "Do., 16. Dez." from a previous year is never mistaken for this year's. - const showYear = d.getFullYear() !== new Date().getFullYear(); + const yearOf = (value: Date) => + getDateTimeFormat(locale, { timeZone, year: "numeric" }).format(value); + const showYear = yearOf(d) !== yearOf(new Date()); return getDateTimeFormat(locale, { + timeZone, weekday: "short", day: "2-digit", month: "short", @@ -75,6 +83,7 @@ export function WorkoutList({ }: WorkoutListProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const timeZone = useDisplayTimezone(); const { preference } = useUnitDisplay(); return ( @@ -141,7 +150,12 @@ export function WorkoutList({ stays readable beside the stats column. */} - {formatDate(workout.startedAt, locale, timeFormat)} + {formatDate( + workout.startedAt, + locale, + timeFormat, + timeZone, + )} diff --git a/src/lib/__tests__/crypto-bytes.test.ts b/src/lib/__tests__/crypto-bytes.test.ts index 196b10e71..68b40c922 100644 Binary files a/src/lib/__tests__/crypto-bytes.test.ts and b/src/lib/__tests__/crypto-bytes.test.ts differ diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 174171177..1a40eb0d9 100644 Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts index ebcde0792..90a9943f0 100644 --- a/src/lib/documents/__tests__/auto-stage-labs.test.ts +++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /** - * S8 — auto-stage lab facts after the auto-index job. Pins Marc's sign-off + * S8 — auto-stage lab facts after the auto-index job. Pins the sign-off * (auto-STAGE, never auto-COMMIT), the double module gate, the reuse of the * auto-index consent gate, the "looks like a lab" heuristic, idempotency (a * non-STORED or already-staged document is left alone), and the guarded diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index fc694a56e..a30714219 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -5,7 +5,7 @@ * document that LOOKS LIKE a lab result is run through the SAME inbound * extraction the manual "Extract" button uses — but the facts land PENDING for * the existing human-review screen. Nothing is committed here: the user still - * taps one confirm before a value becomes a `LabResult`. Marc's sign-off is + * taps one confirm before a value becomes a `LabResult`. The sign-off is * explicit — auto-STAGE, never auto-COMMIT. * * Gates (ALL must hold, otherwise the manual extract button is left untouched): diff --git a/src/lib/documents/document-chat-prompt.ts b/src/lib/documents/document-chat-prompt.ts index 5805ffc77..4833650c3 100644 Binary files a/src/lib/documents/document-chat-prompt.ts and b/src/lib/documents/document-chat-prompt.ts differ diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 5186efdf0..68548ea40 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -124,9 +124,12 @@ function asDate(value: DateInput): Date { * lookup is cheap. */ function isCurrentYearInTz(date: Date, tz: string, localeTag: string): boolean { - const yearOnly = { timeZone: tz, year: "numeric" as const }; - const target = getDateTimeFormat(localeTag, yearOnly).format(date); - const now = getDateTimeFormat(localeTag, yearOnly).format(new Date()); + const yearIn = (value: Date) => + getDateTimeFormat(localeTag, { timeZone: tz, year: "numeric" }).format( + value, + ); + const target = yearIn(date); + const now = yearIn(new Date()); return target === now; } diff --git a/src/lib/insights/__tests__/sanitize.test.ts b/src/lib/insights/__tests__/sanitize.test.ts index 205149539..13da49c31 100644 Binary files a/src/lib/insights/__tests__/sanitize.test.ts and b/src/lib/insights/__tests__/sanitize.test.ts differ diff --git a/src/lib/intl/formatter-cache.ts b/src/lib/intl/formatter-cache.ts index cc624f458..4a4275cc1 100644 Binary files a/src/lib/intl/formatter-cache.ts and b/src/lib/intl/formatter-cache.ts differ diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index 8543a4340..074263377 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -279,19 +279,19 @@ describe("buildCoachNudgePayload", () => { it("opens the title with a localized morning greeting and the name", () => { const { title, body } = buildCoachNudgePayload("bp", "de", { - name: "Marc", + name: "Alex", openerSeed: 0, }); - expect(title).toBe("Guten Morgen, Marc"); + expect(title).toBe("Guten Morgen, Alex"); expect(body.length).toBeGreaterThan(0); }); it("rotates to the casual opener on an odd seed", () => { const { title } = buildCoachNudgePayload("bp", "de", { - name: "Marc", + name: "Alex", openerSeed: 1, }); - expect(title).toBe("Hey Marc"); + expect(title).toBe("Hey Alex"); }); it("drops the name from the greeting when none is given", () => { @@ -361,11 +361,11 @@ describe("resolveGreetingName", () => { it("prefers the display name's first token", () => { expect( resolveGreetingName({ - displayName: "Marc B.", - fullName: "Marc Bombeck", - username: "marc", + displayName: "Alex B.", + fullName: "Alex Example", + username: "alex", }), - ).toBe("Marc"); + ).toBe("Alex"); }); it("falls back to the full name's given token", () => {