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", () => {