From ff3559c1695ffedc7189c0de7a1306f85c3b8d98 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 19 Oct 2026 14:56:53 +0200
Subject: [PATCH 1/4] fix(workouts): render workout dates in the profile
timezone
The workout list, the workout header and the dashboard tile formatted
instants through the shared formatter cache without a timeZone, so they
followed the browser's zone. The year comparison in the list used the
browser's calendar as well. All three now take the profile zone, and the
timezone guard also matches getDateTimeFormat(locale, options) calls that
leave the zone out.
---
.../timezone-explicit-formatter-guard.test.ts | 36 +++++++++++++++++--
.../dashboard/recent-workouts-tile.tsx | 8 +++--
.../insights/workout-detail/format.ts | 3 ++
.../insights/workout-detail/header.tsx | 8 ++++-
src/components/insights/workout-list.tsx | 20 +++++++++--
src/lib/format-locale.ts | 9 +++--
6 files changed, 72 insertions(+), 12 deletions(-)
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/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/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/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;
}
From 146fcac079b7825322681709aa999d3bc89c7ff8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 19 Oct 2026 14:57:00 +0200
Subject: [PATCH 2/4] chore: write NUL as an escape instead of a raw byte
Raw NUL bytes in source files make grep and ripgrep treat the file as
binary and skip it, so sweeps silently miss it. The affected string and
regex literals use the escape now; behaviour is unchanged.
---
.../intake-toast-single-decision.test.ts | Bin 8367 -> 8372 bytes
src/lib/__tests__/crypto-bytes.test.ts | Bin 4366 -> 4371 bytes
src/lib/__tests__/doctor-report-data.test.ts | Bin 18677 -> 18692 bytes
src/lib/documents/document-chat-prompt.ts | Bin 14900 -> 14915 bytes
src/lib/insights/__tests__/sanitize.test.ts | Bin 3130 -> 3150 bytes
src/lib/intl/formatter-cache.ts | Bin 1614 -> 1629 bytes
6 files changed, 0 insertions(+), 0 deletions(-)
diff --git a/src/__tests__/intake-toast-single-decision.test.ts b/src/__tests__/intake-toast-single-decision.test.ts
index a741baf8078513095aba2005bb8ab22478508aeb..39e73fbb1738dd4169647cd7cffd1370cd77e52c 100644
GIT binary patch
delta 19
ZcmZ4QxW#cp4KG_vsR0mduH`in1^`Fq1~>o!
delta 14
VcmdnuxZZI?4KE|Z<|bYvVE`%u1g8K1
diff --git a/src/lib/__tests__/crypto-bytes.test.ts b/src/lib/__tests__/crypto-bytes.test.ts
index 196b10e7127bc9d696e88289fcac6ffafcdd4874..68b40c922cad01ee94b0aafbba5f6886a95e96bd 100644
GIT binary patch
delta 19
ZcmeBEnyj>;m6a`~)Bp%Jx3P9`0{}nj1_%HE
delta 14
VcmbQN)Tgwem6efUb2n=THvlAo1a|-c
diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts
index 1741711776c4e02043611a3e72999514d1850d0a..1a40eb0d90f67e4f44f4e513b5380df3ed5d7bc5 100644
GIT binary patch
delta 47
xcmex5k+EeG;|5m+u6+IG5dQaC06s7VUH||9
diff --git a/src/lib/insights/__tests__/sanitize.test.ts b/src/lib/insights/__tests__/sanitize.test.ts
index 20514953963750ba88bdefc2c8ef478859c16a58..13da49c31ce11553726e72979d96c1de3cd5dea2 100644
GIT binary patch
delta 45
rcmdlbaZX~xTy~k5QUf4x0#oMBAj&Y!1w@&rxlaDbCcb$OdmRe^P}&ZU
delta 25
hcmX>nu}fmZTy_oyCw6CfmwMO9AKAn=Utq6e0RV4a2z3Ae
diff --git a/src/lib/intl/formatter-cache.ts b/src/lib/intl/formatter-cache.ts
index cc624f4584445c39ac68b00c497a9de95ffe3767..4a4275cc1576bf7c8eefbf4855cdb7fda0758ac8 100644
GIT binary patch
delta 45
ocmX@dbC+j>Jri3@sR0lyc3^@rH#@R;F+znWJF;j_-pV=?03|;RivR!s
delta 35
qcmcc1bB&JrgT~l7hCvWM3BX%?V6T85tQid$V`}smZfh4*>wKkqLnS
From 7b17ec819a3f5603c6aa18a20889f5b8eb866b04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 19 Oct 2026 14:57:00 +0200
Subject: [PATCH 3/4] chore: drop personal names from comments and test
fixtures
Comments referred to the maintainer by name, and a test fixture carried a
full real name. Comments describe the behaviour; fixtures use placeholders.
---
src/app/page-client.tsx | 2 +-
.../daily/__tests__/today-hero.test.tsx | 2 +-
src/components/daily/today-hero.tsx | 2 +-
.../document-coach-launch-wiring.test.ts | 2 +-
.../documents/__tests__/auto-stage-labs.test.ts | 2 +-
src/lib/documents/auto-stage-labs.ts | 2 +-
src/lib/jobs/__tests__/coach-nudge.test.ts | 16 ++++++++--------
7 files changed, 14 insertions(+), 14 deletions(-)
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/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/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/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", () => {
From 1606099005481efa71b13cc1974d24b691a7c8ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 19 Oct 2026 15:09:50 +0200
Subject: [PATCH 4/4] build(deps): keep the Next.js packages out of the weekly
groups
A red Next bump inside the production group held the twelve harmless
updates next to it red as well. The three Next packages open as single
pull requests, so each is judged on its own checks.
---
.github/dependabot.yml | 9 +++++++++
1 file changed, 9 insertions(+)
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