mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'fix/v1436-locks' into release/v1.43.6
This commit is contained in:
commit
378de45f88
20 files changed
+95
-26
No files matched your search
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ describe("<TodayHero>", () => {
|
||||
});
|
||||
|
||||
// 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(<TodayHero digest={digest()} />);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatDayLabel(workout.startedAt, locale)}
|
||||
{formatDayLabel(workout.startedAt, locale, timeZone)}
|
||||
</span>
|
||||
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
|
||||
{formatDurationMinutes(
|
||||
|
||||
@@ -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=<id>` (the v1.28.51 behaviour Marc corrected).
|
||||
* full-page nav to `/coach?doc=<id>` (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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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. */}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<DayLinkAt at={workout.startedAt} className="z-10">
|
||||
{formatDate(workout.startedAt, locale, timeFormat)}
|
||||
{formatDate(
|
||||
workout.startedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
)}
|
||||
</DayLinkAt>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user