Merge tickets part 1 into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 11:35:07 +02:00
commit 861ba21086
48 files changed
+2654 -202

No files matched your search

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
* #1110 — since iOS 27 one Apple Watch writes both HRV measures, SDNN
* (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). The page used to chart
* RMSSD only when there was no SDNN at all, so a watch writing both showed
* SDNN and hid RMSSD. With both present each now gets its own chart, titled
* with its measure; they are never drawn as one line.
*
* The chart is `next/dynamic`; the stub echoes the props that matter.
*/
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = (props: {
types?: string[];
title?: string;
onVisibleStats?: unknown;
}) => (
<div
data-slot="chart-stub"
data-types={(props.types ?? []).join(",")}
data-title={props.title ?? ""}
data-reports-stats={props.onVisibleStats ? "yes" : "no"}
/>
);
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("next/navigation", () => ({ usePathname: () => "/insights/hrv" }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
import InsightsHrvPage from "@/app/insights/hrv/page";
function render() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<InsightsHrvPage />
</I18nProvider>
</QueryClientProvider>,
);
}
function charts(html: string): Array<{ types: string; title: string }> {
return [...html.matchAll(/data-types="([^"]*)" data-title="([^"]*)"/g)].map(
(m) => ({ types: m[1]!, title: m[2]! }),
);
}
beforeEach(() => {
analyticsMock.mockReset();
});
describe("/insights/hrv — SDNN and RMSSD kept apart (#1110)", () => {
it("charts each measure on its own when both have readings", () => {
analyticsMock.mockReturnValue({
data: {
summaries: {
HEART_RATE_VARIABILITY: { count: 30 },
HRV_RMSSD: { count: 28 },
},
},
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(2);
expect(found[0]).toMatchObject({ types: "HEART_RATE_VARIABILITY" });
expect(found[0]!.title).toMatch(/SDNN$/);
expect(found[1]).toMatchObject({ types: "HRV_RMSSD" });
expect(found[1]!.title).toMatch(/RMSSD$/);
});
it("lets only the primary chart write the stat strip's window", () => {
// Two charts writing the one stats slot replaced each other's window on
// every render: React's update-depth error, and the chart failed to load.
analyticsMock.mockReturnValue({
data: {
summaries: {
HEART_RATE_VARIABILITY: { count: 30 },
HRV_RMSSD: { count: 28 },
},
},
isEmpty: false,
});
const flags = [...render().matchAll(/data-reports-stats="(yes|no)"/g)].map(
(m) => m[1],
);
expect(flags).toEqual(["yes", "no"]);
});
it("keeps one unlabelled SDNN chart when there is no RMSSD", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 30 } } },
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(1);
expect(found[0]!.types).toBe("HEART_RATE_VARIABILITY");
expect(found[0]!.title).not.toMatch(/SDNN|RMSSD/);
});
it("charts RMSSD alone, labelled, for a ring or strap user", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HRV_RMSSD: { count: 28 } } },
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(1);
expect(found[0]!.types).toBe("HRV_RMSSD");
expect(found[0]!.title).toMatch(/RMSSD$/);
});
});
@@ -73,6 +73,16 @@ export interface HealthKitMetricPageProps {
* `fallbackMeasurementType` is set.
*/
fallbackMeasureLabel?: string;
/**
* v1.42 (#1110) — short label naming the PRIMARY measure (e.g. "SDNN").
* Since iOS 27 one account can hold both HRV measures from the same
* watch. They are different statistics on different scales, so when
* both have rows the page charts each on its own, each titled with its
* measure, instead of hiding the second behind the first.
*/
primaryMeasureLabel?: string;
/** Line colour of the fallback measure's own chart; defaults to `color`. */
fallbackColor?: `var(--${string})`;
/** The InsightMetric key used by `useInsightsAnalytics()`. */
insightMetric: InsightMetric;
/** The chart-overlay slot id. */
@@ -215,6 +225,8 @@ export function HealthKitMetricPage({
measurementType,
fallbackMeasurementType,
fallbackMeasureLabel,
primaryMeasureLabel,
fallbackColor,
insightMetric,
chartKey,
i18nPrefix,
@@ -269,6 +281,11 @@ export function HealthKitMetricPage({
const effectiveType = usingFallback
? (fallbackMeasurementType as string)
: measurementType;
// v1.42 (#1110) — both measures present: the primary keeps the strip, the
// coach read and the value list, and the fallback measure gets a chart of
// its own beneath it. Never one line, never one hidden.
const bothMeasures =
!!fallbackMeasurementType && primaryCount > 0 && fallbackCount > 0;
// v1.32.26 — resolve the display unit + scale from the user's preference for
// a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved,
@@ -421,7 +438,11 @@ export function HealthKitMetricPage({
summary={summary}
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
fractionDigits={resolvedFractionDigits}
seriesLabel={title}
seriesLabel={
bothMeasures && primaryMeasureLabel
? `${title} · ${primaryMeasureLabel}`
: title
}
icon={statIcon}
windowStats={statsByType?.[effectiveType] ?? null}
medianLabel={statMedianLabel}
@@ -455,7 +476,9 @@ export function HealthKitMetricPage({
title={
usingFallback && fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
: bothMeasures && primaryMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${primaryMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[color]}
@@ -469,6 +492,29 @@ export function HealthKitMetricPage({
onVisibleStats={onVisibleStats}
showDataTable
/>
{bothMeasures ? (
<HealthChartDynamic
chartKey={chartKey}
types={[fallbackMeasurementType as string]}
title={
fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[fallbackColor ?? color]}
unit={resolvedUnit}
yAxisUnit={resolvedYAxisUnit}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={resolvedScale}
valueOffset={resolvedOffset}
// No `onVisibleStats`: the strip describes the primary measure, and
// two charts writing the one stats slot would replace each other's
// window on every render without end.
showDataTable
/>
) : null}
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
@@ -38,7 +38,7 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
import { AppleHealthCard } from "../apple-health-card";
import { AppleHealthCard, ArrivalDetail } from "../apple-health-card";
function render(locale: Locale = "en") {
return renderToStaticMarkup(
@@ -404,3 +404,77 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
}
});
});
describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
const now = new Date("2026-10-08T12:00:00.000Z");
const hoursAgo = (h: number) =>
new Date(now.getTime() - h * 60 * 60 * 1000).toISOString();
const base = {
type: "RESPIRATORY_RATE",
lastSeenAt: hoursAgo(5),
stale: false,
};
const t = (key: string, params?: Record<string, string | number>) => {
const value = key
.split(".")
.reduce<unknown>(
(node, part) => (node as Record<string, unknown>)?.[part],
en,
);
if (typeof value !== "string") return key;
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
String(params?.[name] ?? `{${name}}`),
);
};
const html = (entry: Parameters<typeof ArrivalDetail>[0]["entry"]) =>
renderToStaticMarkup(<ArrivalDetail entry={entry} t={t} now={now} />);
it("names when a type last arrived and that only Sync all carried it", () => {
const out = html({
...base,
lastReceivedAt: hoursAgo(2),
lastTrigger: "manual",
lastNewSampleAt: hoursAgo(2),
});
expect(out).toContain('data-trigger="manual"');
expect(out).toContain("Last received 2 h ago");
expect(out).toContain("from Sync all");
// The last arrival brought something new, so no second date is needed.
expect(out).not.toContain("Last new value");
});
it("adds the last new value when the latest arrival held duplicates only", () => {
const out = html({
...base,
lastReceivedAt: hoursAgo(1),
lastTrigger: "background",
lastNewSampleAt: hoursAgo(72),
});
expect(out).toContain("in the background");
expect(out).toContain("Last new value 3 d ago");
});
it("says a type has not arrived through live sync yet", () => {
const out = html({
...base,
lastReceivedAt: null,
lastTrigger: null,
lastNewSampleAt: null,
});
expect(out).toContain('data-state="never"');
expect(out).toContain("Not received yet");
});
it("renders nothing when the server sent no arrival facts", () => {
expect(html(base)).toBe("");
});
it("titles the list by data type", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
syncHealth: { verdict: "fresh", since: null },
metricFreshness: [{ ...base }],
};
expect(render()).toContain("By data type");
});
});
@@ -25,10 +25,8 @@ import {
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
} from "@/lib/integrations/sync-verdict";
import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync";
import type { SyncHealth } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
@@ -43,7 +41,8 @@ interface HealthKitStatus {
/** When data last arrived without the app being opened. */
lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
/** Per type: newest sample, plus when it last arrived and how (#1173). */
metricFreshness?: HealthKitFreshnessEntry[];
/** Server-side backfill progress (#778); null when the read failed. */
syncProgress?: {
recordsAccepted: number;
@@ -135,6 +134,65 @@ function DeliveryDiagnostic({
);
}
/**
* The second line of a type's row: when a live sync last carried the type,
* under which trigger, and when one last brought a new value. The newest
* sample's time on the right of the row says when the reading was TAKEN; this
* says when the server RECEIVED it, which is what tells a type that only
* arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing
* renders when the server sent no arrival facts (the workouts row, or a failed
* ledger read), so absence is never painted as "not received".
*/
export function ArrivalDetail({
entry,
t,
now,
}: {
entry: HealthKitFreshnessEntry;
t: (key: string, params?: Record<string, string | number>) => string;
now?: Date;
}) {
if (entry.lastReceivedAt === undefined) return null;
if (entry.lastReceivedAt === null) {
return (
<span data-testid="apple-health-arrival" data-state="never">
{t("settings.appleHealth.freshness.never")}
</span>
);
}
const reference = (now ?? new Date()).getTime();
const relative = (iso: string) =>
formatRelative(Math.max(0, reference - new Date(iso).getTime()), t);
const trigger = entry.lastTrigger ?? null;
const parts = [
t("settings.appleHealth.freshness.lastReceived", {
relative: relative(entry.lastReceivedAt),
}),
t(
trigger
? TRIGGER_KEYS[trigger]
: "settings.appleHealth.delivery.trigger.unknown",
),
];
// Only worth a mention when the last arrival brought duplicates alone.
if (entry.lastNewSampleAt && entry.lastNewSampleAt !== entry.lastReceivedAt) {
parts.push(
t("settings.appleHealth.freshness.lastNewValue", {
relative: relative(entry.lastNewSampleAt),
}),
);
}
return (
<span
data-testid="apple-health-arrival"
data-state="received"
data-trigger={trigger ?? "unknown"}
>
{parts.join(" · ")}
</span>
);
}
/**
* First-run backfill progress (#778). During the initial HealthKit backfill
* the card said nothing about how much had arrived or how far back it
@@ -269,6 +327,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
<div className="space-y-2">
@@ -20,7 +20,7 @@
* "expected but missing" row is invented.
*/
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { AlertTriangle, ChevronDown } from "lucide-react";
import { formatRelative } from "@/components/settings/integration-status-pill";
@@ -42,16 +42,27 @@ function humanise(type: string): string {
return words.charAt(0).toUpperCase() + words.slice(1);
}
export function MetricFreshnessDisclosure({
export function MetricFreshnessDisclosure<
Entry extends MetricFreshnessEntry = MetricFreshnessEntry,
>({
entries,
idPrefix,
now,
title,
detail,
}: {
entries: MetricFreshnessEntry[] | undefined;
entries: Entry[] | undefined;
/** Namespaces the disclosure's ids so several cards can coexist. */
idPrefix: string;
/** Override "now" for deterministic testing. */
now?: Date;
/** Replaces the shared "received data" heading. */
title?: string;
/**
* A second line under a row, for a card that knows more per type than the
* newest sample (the Apple Health card's arrival facts, #1173).
*/
detail?: (entry: Entry) => ReactNode;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -101,7 +112,7 @@ export function MetricFreshnessDisclosure({
)}
/>
<span className="text-sm font-medium">
{t("settings.integrationFreshness.title")}
{title ?? t("settings.integrationFreshness.title")}
</span>
<span className="text-muted-foreground min-w-0 truncate text-xs">
{t("settings.integrationFreshness.summary", {
@@ -118,35 +129,54 @@ export function MetricFreshnessDisclosure({
</button>
{open && (
<ul id={panelId} className="space-y-1">
{sorted.map((entry) => (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"flex items-center justify-between gap-3 text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
<span className="truncate">{label(entry.type)}</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
<ul id={panelId} className={detail ? "space-y-2.5" : "space-y-1"}>
{sorted.map((entry) => {
const extra = detail?.(entry);
return (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
{relative(entry.lastSeenAt)}
</time>
</li>
))}
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
{/* With a detail line under it, the type name is the
row's content and the line its meta. */}
<span
className={cn(
"truncate",
extra && !entry.stale && "text-foreground",
)}
>
{label(entry.type)}
</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
>
{relative(entry.lastSeenAt)}
</time>
</div>
{extra ? (
<div data-slot="metric-freshness-detail" className="mt-0.5">
{extra}
</div>
) : null}
</li>
);
})}
</ul>
)}
</div>
@@ -47,7 +47,10 @@ import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
import {
manualWorkoutOriginalFromRow,
type ManualWorkoutEntry,
} from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
@@ -251,3 +254,39 @@ describe("delete — a hand-entered workout in one's own record only", () => {
});
});
});
describe("<ManualWorkoutForm> — editing a stored workout (#1162)", () => {
const original = manualWorkoutOriginalFromRow(
{
sportType: "cycling",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T07:30:00.000Z",
durationSec: 5400,
distanceM: 32000,
activeEnergyKcal: 640,
minHr: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
storedAvgHr: 131,
storedMaxHr: null,
},
{ timezone: "Europe/Berlin", unitPreference: "metric" },
);
it("opens on the stored values", () => {
const html = render(
<ManualWorkoutForm edit={{ externalId: ENTRY.externalId, original }} />,
);
expect(html).toContain('value="2026-09-15T08:00"');
expect(html).toContain('value="1"');
expect(html).toContain('value="30"');
expect(html).toContain('value="32"');
expect(html).toContain('value="640"');
});
it("opens empty when it is not editing", () => {
const html = render(<ManualWorkoutForm />);
expect(html).not.toContain('value="640"');
});
});
@@ -0,0 +1,83 @@
"use client";
import { useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { cn } from "@/lib/utils";
import {
canEditWorkout,
manualWorkoutOriginalFromRow,
type StoredManualWorkout,
} from "@/lib/workouts/manual-entry";
/**
* The detail page's edit control (#1162): a ghost icon beside delete that
* opens the "Log workout" sheet on the stored values. Renders nothing for a
* workout it may not edit (a synced one, or somebody else's record).
*
* Saving goes through the same `POST /api/workouts/batch` the form always
* used, re-posting the row's own `manual:` id, which the route applies as an
* overwrite of that one row. No separate edit route exists.
*/
export function EditWorkoutButton({
workout,
}: {
workout: StoredManualWorkout & {
source: string;
externalId: string | null;
};
}) {
const { t } = useTranslations();
const { user } = useAuth();
const { preference } = useUnitDisplay();
const capabilities = useRecordCapabilities();
const [open, setOpen] = useState(false);
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const original = useMemo(
() =>
manualWorkoutOriginalFromRow(workout, {
timezone,
unitPreference: preference,
}),
[workout, timezone, preference],
);
if (!canEditWorkout(workout, capabilities) || !workout.externalId) {
return null;
}
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-edit"
aria-label={t("insights.workouts.manual.editAction")}
title={t("insights.workouts.manual.editTitle")}
onClick={() => setOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
{/* The sheet mounts its form only while open, so each opening starts
from the values the page shows at that moment. */}
<ManualWorkoutSheet
open={open}
onOpenChange={setOpen}
edit={{ externalId: workout.externalId, original }}
/>
</>
);
}
@@ -1,9 +1,9 @@
"use client";
import { useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import { Check, Loader2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
@@ -33,6 +33,7 @@ import {
type ManualWorkoutDraft,
type ManualWorkoutEntry,
type ManualWorkoutField,
type ManualWorkoutOriginal,
} from "@/lib/workouts/manual-entry";
import {
workoutSportTypeEnum,
@@ -49,10 +50,23 @@ import {
* `onSuccess` / `onCancel` close the sheet.
*/
/**
* Edit mode (#1162): the stored workout's own `manual:` id and the values it
* opens with. Saving re-posts that id, which the batch route applies as an
* overwrite of that one row.
*/
export interface ManualWorkoutEdit {
externalId: string;
original: ManualWorkoutOriginal;
}
interface ManualWorkoutFormProps {
onSuccess?: () => void;
onCancel?: () => void;
footerSlot?: HTMLElement | null;
edit?: ManualWorkoutEdit;
/** Whether the fields differ from what the form opened with. */
onDirtyChange?: (dirty: boolean) => void;
}
interface BatchResult {
@@ -104,6 +118,8 @@ export function ManualWorkoutForm({
onSuccess,
onCancel,
footerSlot,
edit,
onDirtyChange,
}: ManualWorkoutFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -114,10 +130,20 @@ export function ManualWorkoutForm({
// Minted once per opened form and sent with every submit, so a second tap
// or a retried request lands as a duplicate rather than a second row, and
// a submit after an edit updates the row the first submit stored.
const [externalId] = useState(newManualWorkoutExternalId);
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(),
// An edit sends the stored row's own id instead.
const [externalId] = useState(
() => edit?.externalId ?? newManualWorkoutExternalId(),
);
const [initialDraft] = useState<ManualWorkoutDraft>(
() => edit?.original.draft ?? emptyManualWorkoutDraft(),
);
const [draft, setDraft] = useState<ManualWorkoutDraft>(initialDraft);
const dirty = (
Object.keys(initialDraft) as Array<keyof ManualWorkoutDraft>
).some((key) => draft[key] !== initialDraft[key]);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>
>({});
@@ -158,6 +184,7 @@ export function ManualWorkoutForm({
unitPreference: preference,
externalId,
now: new Date(),
original: edit?.original,
});
if (!built.ok) {
setErrors(built.errors);
@@ -174,6 +201,8 @@ export function ManualWorkoutForm({
"empty",
t("insights.workouts.manual.alreadySaved"),
);
} else if (outcome === "updated") {
toastWrittenOutcome("success", t("insights.workouts.manual.updated"));
} else {
toastWrittenOutcome("success", t("common.saved"));
}
@@ -216,6 +245,8 @@ export function ManualWorkoutForm({
>
{saving ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : edit ? (
<Check className="size-4" />
) : (
<Plus className="size-4" />
)}
@@ -278,16 +309,20 @@ export function ManualWorkoutForm({
max={wallClockNow(new Date(), timezone)}
aria-invalid={!!errors.start || undefined}
aria-describedby={[
`${ids.start}-hint`,
edit ? undefined : `${ids.start}-hint`,
describedBy("start", `${ids.start}-error`),
]
.filter(Boolean)
.join(" ")}
data-testid="manual-workout-start"
/>
<p id={`${ids.start}-hint`} className="text-muted-foreground text-xs">
{t("insights.workouts.manual.startHint")}
</p>
{/* "Leave empty if you just finished" is about logging; an edit
opens with the stored start in place. */}
{edit ? null : (
<p id={`${ids.start}-hint`} className="text-muted-foreground text-xs">
{t("insights.workouts.manual.startHint")}
</p>
)}
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
@@ -14,7 +14,10 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import {
ManualWorkoutForm,
type ManualWorkoutEdit,
} from "@/components/workouts/manual-workout-form";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -23,21 +26,31 @@ import { useTranslations } from "@/lib/i18n/context";
* `ManualWorkoutForm` inside their own sheets, which already carry the same
* confirm-before-discard guard; this one carries it too, so a half-filled
* form is never dropped by a stray swipe.
*
* With `edit` it opens on a stored workout (#1162). Its fields start filled,
* so "has input" says nothing there; the guard asks the form whether anything
* differs from what it opened with.
*/
export function ManualWorkoutSheet({
open,
onOpenChange,
edit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
edit?: ManualWorkoutEdit;
}) {
const { t } = useTranslations();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
const editDirty = useRef(false);
const handleDirtyChange = useCallback((dirty: boolean) => {
editDirty.current = dirty;
}, []);
function handleOpenChange(next: boolean) {
if (next) return onOpenChange(true);
if (sheetBodyHasUnsavedInput()) {
if (edit ? editDirty.current : sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
@@ -49,7 +62,11 @@ export function ManualWorkoutSheet({
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
title={t(
edit
? "insights.workouts.manual.editTitle"
: "insights.workouts.manual.sheetTitle",
)}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
@@ -58,6 +75,8 @@ export function ManualWorkoutSheet({
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
edit={edit}
onDirtyChange={edit ? handleDirtyChange : undefined}
/>
)}
</ResponsiveSheet>