mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge tickets part 1 into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user