Merge branch 'feat/v144-userneeds' into release/v1.44.0

# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/workout-canonical-identity-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:24:01 +02:00
commit aa899a9d54
86 files changed
+12949 -9

No files matched your search

@@ -0,0 +1,157 @@
/**
* `<ActivityCalendarCard>` draws what the server computed (SSR markup, like
* the other component suites). The arithmetic is pinned in
* `src/lib/activity/__tests__/calendar-math.test.ts`; this suite pins that
* the card shows each day's level, the workouts, the summary against the
* usual and the goals, and that a shared record gets no goal editor.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
buildActivityCalendar,
periodBounds,
type ActivityDayInput,
} from "@/lib/activity/calendar-math";
import { shiftDateKey } from "@/lib/tz/format";
import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals";
const state: { data: unknown; shared: boolean } = {
data: undefined,
shared: false,
};
vi.mock("@/hooks/use-activity-calendar", () => ({
useActivityCalendar: () => ({
data: state.data,
isError: false,
error: null,
refetch: vi.fn(),
}),
useSaveActivityGoals: () => ({
mutate: vi.fn(),
reset: vi.fn(),
isPending: false,
isError: false,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: state.shared }),
}));
import { ActivityCalendarCard } from "../activity-calendar-card";
import { parseGoalField } from "../activity-goals-sheet";
function calendar() {
const today = "2026-10-21";
const bounds = periodBounds("month", today);
const input: ActivityDayInput[] = [];
for (let i = 1; i <= 200; i++) {
input.push({
date: shiftDateKey(bounds.start, -i),
steps: 6000 + (i % 4) * 500,
exerciseMinutes: 30,
workoutMinutes: 0,
workoutCount: 0,
workoutsBySport: {},
load: null,
});
}
input.push({
date: "2026-10-05",
steps: 20000,
exerciseMinutes: 90,
workoutMinutes: 75,
workoutCount: 1,
workoutsBySport: { running: 1 },
load: null,
});
return {
...buildActivityCalendar({
bounds,
today,
input,
goals: {
...NO_ACTIVITY_GOALS,
workoutsPerWeek: 1,
stepsPerMonth: 100000,
},
}),
timezone: "Europe/Berlin",
loadType: null,
};
}
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ActivityCalendarCard />
</I18nProvider>,
);
}
describe("ActivityCalendarCard", () => {
it("shows a loading frame before the data arrives", () => {
state.data = undefined;
expect(render()).toContain('data-slot="activity-calendar-loading"');
});
it("draws every day of the grid with its level", () => {
state.data = calendar();
const html = render();
const days = html.match(/data-slot="activity-calendar-day"/g) ?? [];
expect(days.length).toBe(35);
expect(html).toMatch(/data-date="2026-10-05" data-level="high"/);
expect(html).toMatch(/data-date="2026-10-30" data-level="none"/);
// The accessible name carries the figures and the level in words.
expect(html).toContain("20,000 steps");
expect(html).toContain("Far above your usual");
});
it("summarises the period against the usual and lists the sports", () => {
state.data = calendar();
const html = render();
expect(html).toContain('data-metric="steps"');
expect(html).toContain("Your usual by this point:");
expect(html).toContain("Running: 1");
});
it("shows goal progress and a week tick column", () => {
state.data = calendar();
const html = render();
expect(html).toContain("Steps per month");
expect(html).toContain(
'data-slot="activity-calendar-week-goal" data-met="true"',
);
expect(html).toContain('data-slot="activity-calendar-set-goals"');
});
it("offers no goal editor inside somebody else's record", () => {
state.data = calendar();
state.shared = true;
const html = render();
state.shared = false;
expect(html).not.toContain('data-slot="activity-calendar-set-goals"');
});
});
describe("goal field parsing", () => {
it("blank clears, digits within bounds pass, anything else is refused", () => {
expect(parseGoalField("workoutsPerWeek", " ")).toEqual({
ok: true,
value: null,
});
expect(parseGoalField("workoutsPerWeek", "4")).toEqual({
ok: true,
value: 4,
});
expect(parseGoalField("workoutsPerWeek", "0")).toEqual({ ok: false });
expect(parseGoalField("stepsPerWeek", "10.000")).toEqual({ ok: false });
expect(parseGoalField("stepsPerWeek", "70000")).toEqual({
ok: true,
value: 70000,
});
expect(parseGoalField("stepsPerMonth", "99999999")).toEqual({ ok: false });
});
});
@@ -0,0 +1,521 @@
"use client";
import { useMemo, useState } from "react";
import {
CalendarDays,
Check,
ChevronLeft,
ChevronRight,
Target,
} from "lucide-react";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { InlineAction } from "@/components/ui/inline-action";
import { Progress } from "@/components/ui/progress";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ScorePill } from "@/components/ui/score-pill";
import { Segmented } from "@/components/ui/segmented";
import { Skeleton } from "@/components/ui/skeleton";
import { TagChip } from "@/components/ui/tag-chip";
import { ActivityGoalsSheet } from "@/components/activity/activity-goals-sheet";
import { useActivityCalendar } from "@/hooks/use-activity-calendar";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { cn } from "@/lib/utils";
import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar";
type Period = "week" | "month";
type RatedMetric = "steps" | "activeMinutes" | "load";
type CalendarDay = ActivityCalendarResponse["days"][number];
type DayLevel = CalendarDay["level"]["steps"];
/**
* One hue, rising intensity: the calendar shows more and less than usual,
* not good and bad, so no status colour takes part. The bar is a graphic
* (3:1 floor), the day number beside it stays foreground text.
*/
const LEVEL_BAR: Record<DayLevel, string> = {
none: "bg-transparent",
unrated: "bg-muted-foreground/60",
low: "bg-chart-2/20",
below: "bg-chart-2/40",
usual: "bg-chart-2/60",
above: "bg-chart-2/80",
high: "bg-chart-2",
};
const LEGEND_LEVELS: DayLevel[] = ["low", "below", "usual", "above", "high"];
/** 2024-01-01 is a Monday: seven UTC days from it name the week's columns. */
function weekdayLabels(locale: string): string[] {
const fmt = new Intl.DateTimeFormat(locale, {
weekday: "narrow",
timeZone: "UTC",
});
return Array.from({ length: 7 }, (_, i) =>
fmt.format(new Date(Date.UTC(2024, 0, 1 + i))),
);
}
function dateOfKey(key: string): Date {
return new Date(`${key}T12:00:00.000Z`);
}
/**
* The activity calendar (D1036): a week or a month of steps, active minutes,
* workouts and training load against the person's own usual, with the goals
* they set. Every figure comes from `/api/activity/calendar`; this component
* draws, it does not compute.
*/
export function ActivityCalendarCard() {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const capabilities = useRecordCapabilities();
const [period, setPeriod] = useState<Period>("month");
const [anchor, setAnchor] = useState<string | null>(null);
const [metricChoice, setMetricChoice] = useState<RatedMetric | null>(null);
const [goalsOpen, setGoalsOpen] = useState(false);
// Each opening mounts a fresh sheet, so its draft starts from the stored
// goals; keyed on the count rather than on `open`, so closing keeps the
// sheet mounted for its exit animation.
const [goalsOpenCount, setGoalsOpenCount] = useState(0);
const query = useActivityCalendar(period, anchor);
const data = query.data;
const weekdays = useMemo(() => weekdayLabels(locale), [locale]);
// A calendar day key names the same date everywhere: read it in UTC, where
// its noon anchor sits, never in the viewer's zone.
const dayNameFormat = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
timeZone: "UTC",
}),
[locale],
);
const ratedOptions = useMemo(() => {
if (!data) return [] as RatedMetric[];
return (["steps", "activeMinutes", "load"] as const).filter(
(m) => data.available[m],
);
}, [data]);
const metric: RatedMetric | null =
metricChoice && ratedOptions.includes(metricChoice)
? metricChoice
: (ratedOptions[0] ?? null);
const periodLabel = data
? period === "month"
? new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(dateOfKey(data.start))
: t("activityCalendar.weekRange", {
start: fmt.dateShortSmartCalendar(data.start),
end: fmt.dateShortSmartCalendar(data.end),
})
: "";
const formatMetric = (m: RatedMetric | "workouts", value: number) =>
m === "load" ? fmt.number(value, 1) : fmt.integer(Math.round(value));
const sportLabel = (sport: string) =>
translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport;
const weeklyGoalsSet =
data !== undefined &&
(data.goals.workoutsPerWeek !== null ||
data.goals.activeMinutesPerWeek !== null ||
data.goals.stepsPerWeek !== null);
const levelText = (level: DayLevel) => t(`activityCalendar.level.${level}`);
const dayLabel = (day: CalendarDay) => {
const parts = [dayNameFormat.format(dateOfKey(day.date))];
if (day.future) return parts.join(", ");
if (day.steps !== null)
parts.push(
t("activityCalendar.cell.steps", { value: fmt.integer(day.steps) }),
);
if (day.activeMinutes !== null)
parts.push(
t("activityCalendar.cell.activeMinutes", {
value: fmt.integer(day.activeMinutes),
}),
);
if (day.workoutCount > 0)
parts.push(
t("activityCalendar.cell.workouts", {
value: fmt.integer(day.workoutCount),
sports: Object.keys(day.workoutsBySport).map(sportLabel).join(", "),
}),
);
if (metric) parts.push(levelText(day.level[metric]));
return parts.join(", ");
};
const rows: CalendarDay[][] = [];
if (data) {
for (let i = 0; i < data.days.length; i += 7) {
rows.push(data.days.slice(i, i + 7));
}
}
return (
<Card
id="activity-calendar"
data-slot="activity-calendar"
className="scroll-mt-28"
>
<CardHeader>
<TileHeader
icon={CalendarDays}
title={t("activityCalendar.title")}
right={
<Segmented
size="sm"
label={t("activityCalendar.periodLabel")}
slot="activity-calendar-period"
value={period}
onChange={(next) => {
setPeriod(next);
setAnchor(data ? data.start : null);
}}
options={[
{ value: "week", label: t("activityCalendar.period.week") },
{ value: "month", label: t("activityCalendar.period.month") },
]}
/>
}
/>
</CardHeader>
<CardContent className="space-y-4">
{query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("activityCalendar.loadError")}
onRetry={() => query.refetch()}
/>
) : !data ? (
<div data-slot="activity-calendar-loading" className="space-y-2">
<Skeleton className="h-8 w-full rounded-lg" />
<Skeleton className="h-48 w-full rounded-lg" />
</div>
) : (
<>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="pointer-coarse:size-11"
aria-label={t("activityCalendar.previous")}
data-slot="activity-calendar-previous"
onClick={() => setAnchor(data.previous)}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Button>
<p
className="text-sm font-medium"
data-slot="activity-calendar-label"
aria-live="polite"
>
{periodLabel}
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="pointer-coarse:size-11"
aria-label={t("activityCalendar.next")}
data-slot="activity-calendar-next"
disabled={data.next === null}
onClick={() => data.next && setAnchor(data.next)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</Button>
</div>
{ratedOptions.length > 1 && metric ? (
<Segmented
size="sm"
stretch
label={t("activityCalendar.metricLabel")}
slot="activity-calendar-metric"
value={metric}
onChange={setMetricChoice}
options={ratedOptions.map((m) => ({
value: m,
label: t(`activityCalendar.metric.${m}`),
}))}
/>
) : null}
{!data.available.steps &&
!data.available.activeMinutes &&
!data.available.workouts &&
!data.available.load ? (
<EmptyState
variant="plain"
size="compact"
title={t("activityCalendar.empty")}
data-slot="activity-calendar-empty"
/>
) : (
<div role="table" aria-label={periodLabel} className="space-y-1">
<div role="row" className="flex gap-1">
{weekdays.map((label, i) => (
<span
key={i}
role="columnheader"
className="text-muted-foreground flex-1 text-center text-xs"
>
{label}
</span>
))}
{weeklyGoalsSet && period === "month" ? (
<span role="columnheader" className="w-5 shrink-0">
<span className="sr-only">
{t("activityCalendar.weekGoalsColumn")}
</span>
</span>
) : null}
</div>
{rows.map((week, rowIndex) => {
const weekRow = data.weeks[rowIndex];
const allMet =
weekRow && weekRow.goals.length > 0
? weekRow.goals.every((g) => g.met)
: null;
return (
<div role="row" key={week[0].date} className="flex gap-1">
{week.map((day) => {
const level = metric ? day.level[metric] : "none";
const value =
metric && !day.future ? day[metric] : null;
return (
<div
role="cell"
key={day.date}
data-slot="activity-calendar-day"
data-date={day.date}
data-level={level}
aria-label={dayLabel(day)}
className={cn(
"flex min-h-11 flex-1 flex-col items-center gap-1 rounded-lg border px-0.5 py-1",
!day.inPeriod && "opacity-50",
day.date === data.today && "border-primary",
)}
>
<span
aria-hidden="true"
className={cn(
"text-xs tabular-nums",
day.future
? "text-muted-foreground"
: "text-foreground",
)}
>
{Number(day.date.slice(8))}
</span>
<span
aria-hidden="true"
className={cn(
"h-1.5 w-full max-w-8 rounded-full",
LEVEL_BAR[level],
)}
/>
{period === "week" && value !== null ? (
<span
aria-hidden="true"
className="text-2xs text-foreground tabular-nums"
>
{formatMetric(metric as RatedMetric, value)}
</span>
) : null}
{day.workoutCount > 0 ? (
<span
aria-hidden="true"
className="flex items-center gap-0.5"
>
{Array.from({
length: Math.min(3, day.workoutCount),
}).map((_, i) => (
<span
key={i}
className="bg-chart-1 size-1.5 rounded-full"
/>
))}
</span>
) : null}
</div>
);
})}
{weeklyGoalsSet && period === "month" ? (
<span
role="cell"
className="flex w-5 shrink-0 items-center justify-center"
data-slot="activity-calendar-week-goal"
data-met={allMet === null ? "none" : String(allMet)}
>
{allMet ? (
<Check
className="text-success size-4"
aria-label={t("activityCalendar.weekGoalsMet")}
/>
) : allMet === false ? (
<span className="sr-only">
{t("activityCalendar.weekGoalsNotMet")}
</span>
) : null}
</span>
) : null}
</div>
);
})}
</div>
)}
{metric ? (
<div
className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"
data-slot="activity-calendar-legend"
>
<span>{t("activityCalendar.legend")}</span>
{LEGEND_LEVELS.map((level) => (
<span key={level} className="inline-flex items-center gap-1">
<span
aria-hidden="true"
className={cn("h-1.5 w-4 rounded-full", LEVEL_BAR[level])}
/>
{levelText(level)}
</span>
))}
{data.baseline[metric] === null ? (
<span data-slot="activity-calendar-no-baseline">
{t("activityCalendar.noBaseline")}
</span>
) : null}
</div>
) : null}
<ul className="space-y-2" data-slot="activity-calendar-summary">
{(["steps", "activeMinutes", "workouts", "load"] as const)
.filter((m) => data.available[m])
.map((m) => {
const s = data.summary[m];
const delta =
s.value !== null && s.usualToDate
? (s.value - s.usualToDate) / s.usualToDate
: null;
return (
<li
key={m}
data-slot="activity-calendar-summary-row"
data-metric={m}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm">
{t(`activityCalendar.summary.${m}`, {
value:
s.value === null ? "–" : formatMetric(m, s.value),
})}
</p>
<p className="text-muted-foreground text-xs">
{s.usualToDate === null
? t("activityCalendar.summary.noUsual")
: t("activityCalendar.summary.usual", {
value: formatMetric(m, s.usualToDate),
})}
</p>
</div>
{delta !== null ? (
<ScorePill tone="neutral" numeric className="shrink-0">
{`${delta > 0 ? "+" : ""}${fmt.percent(delta)}`}
</ScorePill>
) : null}
</li>
);
})}
</ul>
{data.sports.length > 0 ? (
<div
className="flex flex-wrap gap-2"
data-slot="activity-calendar-sports"
>
{data.sports.map((s) => (
<TagChip key={s.sportType}>
{t("activityCalendar.sportCount", {
sport: sportLabel(s.sportType),
count: fmt.integer(s.count),
})}
</TagChip>
))}
</div>
) : null}
<div className="space-y-2" data-slot="activity-calendar-goals">
{data.goalProgress.map((g) => (
<div key={g.key} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="inline-flex items-center gap-1.5">
<Target
className="text-muted-foreground size-4"
aria-hidden="true"
/>
{t(`activityCalendar.goal.${g.key}`)}
</span>
<span className="tabular-nums">
{t("activityCalendar.goalOf", {
value: fmt.integer(Math.round(g.value)),
target: fmt.integer(g.target),
})}
</span>
</div>
<Progress
value={Math.min(100, (g.value / g.target) * 100)}
aria-label={t(`activityCalendar.goal.${g.key}`)}
/>
</div>
))}
{!capabilities.inSharedRecord ? (
<InlineAction
type="button"
tone="primary"
size="xs"
data-slot="activity-calendar-set-goals"
onClick={() => {
setGoalsOpenCount((n) => n + 1);
setGoalsOpen(true);
}}
>
{data.goalProgress.length > 0 || weeklyGoalsSet
? t("activityCalendar.editGoals")
: t("activityCalendar.setGoals")}
</InlineAction>
) : null}
</div>
</>
)}
</CardContent>
{data && !capabilities.inSharedRecord ? (
<ActivityGoalsSheet
key={goalsOpenCount}
open={goalsOpen}
onOpenChange={setGoalsOpen}
goals={data.goals}
/>
) : null}
</Card>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useSaveActivityGoals } from "@/hooks/use-activity-calendar";
import {
ACTIVITY_GOAL_BOUNDS,
ACTIVITY_GOAL_KEYS,
type ActivityGoalKey,
} from "@/lib/activity/goal-bounds";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
type Goals = Record<ActivityGoalKey, number | null>;
type Draft = Record<ActivityGoalKey, string>;
function toDraft(goals: Goals): Draft {
return Object.fromEntries(
ACTIVITY_GOAL_KEYS.map((k) => [
k,
goals[k] === null ? "" : String(goals[k]),
]),
) as Draft;
}
/**
* Parse one field. Blank clears the goal; anything else must be a whole
* number inside the bounds the server enforces. Digits only, so a grouping
* separator typed by habit ("10.000") is refused rather than misread.
*/
export function parseGoalField(
key: ActivityGoalKey,
raw: string,
): { ok: true; value: number | null } | { ok: false } {
const trimmed = raw.trim();
if (trimmed === "") return { ok: true, value: null };
if (!/^\d+$/.test(trimmed)) return { ok: false };
const value = Number(trimmed);
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
if (value < min || value > max) return { ok: false };
return { ok: true, value };
}
/**
* Set or clear the activity goals. Every goal is optional; an empty field is
* no goal. There is no suggested number: the goal is the person's own.
*
* The draft starts from `goals` when the sheet mounts; the caller remounts it
* on every open (a `key`), so a reopened sheet never shows a stale draft.
*/
export function ActivityGoalsSheet({
open,
onOpenChange,
goals,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
goals: Goals;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const save = useSaveActivityGoals();
const [draft, setDraft] = useState<Draft>(() => toDraft(goals));
const [invalid, setInvalid] = useState<ActivityGoalKey[]>([]);
const submit = () => {
const bad: ActivityGoalKey[] = [];
const patch: Partial<Goals> = {};
for (const key of ACTIVITY_GOAL_KEYS) {
const parsed = parseGoalField(key, draft[key]);
if (!parsed.ok) bad.push(key);
else patch[key] = parsed.value;
}
setInvalid(bad);
if (bad.length > 0) return;
save.mutate(patch, { onSuccess: () => onOpenChange(false) });
};
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("activityCalendar.goalsSheet.title")}
description={t("activityCalendar.goalsSheet.description")}
footer={
<Button
type="button"
size="sm"
className="min-h-11"
data-slot="activity-goals-save"
disabled={save.isPending}
onClick={submit}
>
{t("common.save")}
</Button>
}
>
<form
className="grid gap-4"
data-slot="activity-goals-form"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{ACTIVITY_GOAL_KEYS.map((key) => {
const id = `activity-goal-${key}`;
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
const isInvalid = invalid.includes(key);
return (
<div key={key} className="grid gap-1.5">
<Label htmlFor={id}>{t(`activityCalendar.goal.${key}`)}</Label>
<Input
id={id}
inputMode="numeric"
autoComplete="off"
value={draft[key]}
aria-invalid={isInvalid || undefined}
aria-describedby={isInvalid ? `${id}-error` : undefined}
placeholder={t("activityCalendar.goalsSheet.none")}
onChange={(e) =>
setDraft((d) => ({ ...d, [key]: e.target.value }))
}
/>
{isInvalid ? (
<p
id={`${id}-error`}
role="alert"
className="text-destructive text-xs"
>
{t("activityCalendar.goalsSheet.invalid", {
min: fmt.integer(min),
max: fmt.integer(max),
})}
</p>
) : null}
</div>
);
})}
{save.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("activityCalendar.goalsSheet.saveError")}
</p>
) : null}
</form>
</ResponsiveSheet>
);
}
@@ -1,5 +1,6 @@
"use client";
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
import Link from "next/link";
import type { ComponentProps, ComponentType, ReactNode } from "react";
@@ -523,6 +524,9 @@ export function HealthKitMetricPage({
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{/* v1.44 (#1204) — where the values come from, for a metric the
source priority decides for. Nothing for any other metric. */}
<SourceProvenanceLink type={measurementType} />
{/* v1.17.0 — page-specific extra block (blood glucose: the clinical
panel). Mounted on the data-bearing branch only. */}
{afterChart}
@@ -0,0 +1,176 @@
/**
* `<SourceProvenanceView>` draws the server's answer (SSR markup): the day's
* sources with the used one marked and the reason in words, the person's
* ladder, and the side-by-side table with the used value in bold. The link
* from a metric page appears only for a metric the ladder decides for.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const state: { day: unknown; compare: unknown } = {
day: undefined,
compare: undefined,
};
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
vi.mock("@/hooks/use-source-provenance", () => ({
useDayProvenance: () => ({
data: state.day,
isError: false,
error: null,
refetch: vi.fn(),
}),
useSourceComparison: () => ({
data: state.compare,
isError: false,
error: null,
refetch: vi.fn(),
}),
}));
import { SourceProvenanceView } from "../source-provenance-view";
import { SourceProvenanceLink } from "../source-provenance-link";
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const DAY = {
type: "SLEEP_DURATION",
date: "2026-10-01",
aggregation: "sum",
ranked: true,
metricKey: "sleep",
ladder: ["WHOOP", "OURA"],
picked: "WHOOP",
pickReason: "highest_on_ladder",
sources: [
{
source: "WHOOP",
rank: 0,
readings: 1,
value: 420,
min: 420,
max: 420,
firstAt: "",
lastAt: "",
unit: "min",
devices: ["band"],
pass: null,
},
{
source: "OURA",
rank: 1,
readings: 3,
value: 391,
min: 30,
max: 301,
firstAt: "",
lastAt: "",
unit: "min",
devices: ["ring"],
pass: "lower_on_ladder",
},
],
};
const COMPARE = {
type: "SLEEP_DURATION",
from: "2026-09-02",
to: "2026-10-01",
aggregation: "sum",
ranked: true,
ladder: ["WHOOP", "OURA"],
unit: "min",
sources: [
{ source: "OURA", days: 30 },
{ source: "WHOOP", days: 20 },
],
a: "OURA",
b: "WHOOP",
days: [{ date: "2026-10-01", a: 391, b: 420, picked: "WHOOP" }],
summary: {
daysA: 30,
daysB: 20,
daysBoth: 20,
meanA: 395,
meanB: 440,
meanDifference: 45,
meanAbsoluteDifference: 45,
pickedA: 10,
pickedB: 20,
},
};
describe("SourceProvenanceView", () => {
it("names the used source, why, and why the other was not used", () => {
state.day = DAY;
state.compare = COMPARE;
const html = render(
<SourceProvenanceView
initialType="SLEEP_DURATION"
initialDate="2026-10-01"
/>,
);
expect(html).toContain('data-source="WHOOP" data-picked="true"');
expect(html).toContain('data-source="OURA" data-picked="false"');
expect(html).toContain("stands highest in your source priority");
expect(html).toContain("Lower in your source priority.");
expect(html).toContain("Your order for this metric: WHOOP, Oura");
expect(html).toContain('href="/settings/sources"');
});
it("lines up two sources and marks the used value", () => {
state.day = DAY;
state.compare = COMPARE;
const html = render(
<SourceProvenanceView
initialType="SLEEP_DURATION"
initialDate="2026-10-01"
/>,
);
expect(html).toContain('data-slot="provenance-compare-row"');
expect(html).toMatch(/font-semibold tabular-nums">420/);
expect(html).toContain("10 / 20");
});
it("says when there is nothing to compare", () => {
state.day = { ...DAY, sources: [], picked: null, pickReason: null };
state.compare = {
...COMPARE,
sources: [{ source: "OURA", days: 3 }],
b: null,
};
const html = render(
<SourceProvenanceView initialType="SLEEP_DURATION" initialDate={null} />,
);
expect(html).toContain('data-slot="provenance-day-empty"');
expect(html).toContain('data-slot="provenance-compare-empty"');
});
});
describe("SourceProvenanceLink", () => {
it("links ranked metrics and stays away from the rest", () => {
expect(render(<SourceProvenanceLink type="ACTIVITY_STEPS" />)).toContain(
"/insights/sources?type=ACTIVITY_STEPS",
);
expect(render(<SourceProvenanceLink type="BLOOD_GLUCOSE" />)).toBe("");
});
});
@@ -0,0 +1,27 @@
"use client";
import Link from "next/link";
import { InlineAction } from "@/components/ui/inline-action";
import { useTranslations } from "@/lib/i18n/context";
import { isProvenanceType } from "@/lib/provenance/ranked-types";
/**
* "Where these values come from": a small link from a metric's page to the
* provenance view, offered for the metrics the source priority decides for.
* Renders nothing for any other type.
*/
export function SourceProvenanceLink({ type }: { type: string }) {
const { t } = useTranslations();
if (!isProvenanceType(type)) return null;
return (
<InlineAction asChild tone="primary" size="xs">
<Link
href={`/insights/sources?type=${type}`}
data-slot="source-provenance-link"
>
{t("sourceProvenance.metricLink")}
</Link>
</InlineAction>
);
}
@@ -0,0 +1,416 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { GitCompareArrows, Layers } from "lucide-react";
import { TileHeader } from "@/components/insights/tile-header";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { DateField } from "@/components/ui/date-field";
import { EmptyState } from "@/components/ui/empty-state";
import { InlineAction } from "@/components/ui/inline-action";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Segmented } from "@/components/ui/segmented";
import { Skeleton } from "@/components/ui/skeleton";
import { TagChip } from "@/components/ui/tag-chip";
import {
useDayProvenance,
useSourceComparison,
} from "@/hooks/use-source-provenance";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
PROVENANCE_TYPES,
type ProvenanceType,
} from "@/lib/provenance/ranked-types";
import { shiftDateKey } from "@/lib/tz/format";
type RangeDays = "30" | "90" | "365";
/**
* Today as a UTC day key: the source ladder picks per UTC day, so the page
* names its days the way the pick cuts them.
*/
function utcToday(): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the ladder pick is per UTC day, and this page explains that pick
return new Date().toISOString().slice(0, 10);
}
/**
* The provenance view (#1204, #1108, #576): for one metric, which sources
* delivered values on a day, which one the source priority picked and why,
* and two sources side by side over a range. Read-only; the settings that
* decide the pick stay in Settings > Source priority.
*/
export function SourceProvenanceView({
initialType,
initialDate,
}: {
initialType: ProvenanceType;
initialDate: string | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [type, setType] = useState<ProvenanceType>(initialType);
const [date, setDate] = useState<string>(initialDate ?? utcToday());
const [range, setRange] = useState<RangeDays>("30");
const [a, setA] = useState<string | undefined>(undefined);
const [b, setB] = useState<string | undefined>(undefined);
const to = utcToday();
const from = shiftDateKey(to, -(Number(range) - 1));
const day = useDayProvenance(type, date);
const compare = useSourceComparison({ type, from, to, a, b });
const sourceLabel = (source: string) => {
const key =
MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[
source as keyof typeof MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS
];
return key ? t(key) : source;
};
const typeLabel = (value: ProvenanceType) =>
t(MEASUREMENT_TYPE_LABEL_KEYS[value]);
const sortedTypes = [...PROVENANCE_TYPES].sort((x, y) =>
typeLabel(x).localeCompare(typeLabel(y)),
);
const formatValue = (value: number | null, aggregation: "sum" | "mean") =>
value === null
? "–"
: aggregation === "sum"
? fmt.integer(Math.round(value))
: fmt.number(value, 1);
return (
<div className="space-y-6" data-slot="source-provenance">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="provenance-type">
{t("sourceProvenance.metric")}
</Label>
<NativeSelect
id="provenance-type"
data-slot="provenance-type"
value={type}
onChange={(e) => {
setType(e.target.value as ProvenanceType);
setA(undefined);
setB(undefined);
}}
>
{sortedTypes.map((value) => (
<option key={value} value={value}>
{typeLabel(value)}
</option>
))}
</NativeSelect>
</div>
<div className="grid gap-1.5">
<Label htmlFor="provenance-date">{t("sourceProvenance.day")}</Label>
<DateField
id="provenance-date"
value={date}
max={to}
onChange={(value) => value && setDate(value)}
/>
</div>
</div>
<Card data-slot="provenance-day">
<CardHeader>
<TileHeader icon={Layers} title={t("sourceProvenance.dayTitle")} />
</CardHeader>
<CardContent className="space-y-3">
{day.isError ? (
<QueryErrorRow
inset
error={day.error}
message={t("sourceProvenance.loadError")}
onRetry={() => day.refetch()}
/>
) : !day.data ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : day.data.sources.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("sourceProvenance.dayEmpty")}
data-slot="provenance-day-empty"
/>
) : (
<>
<p className="text-sm" data-slot="provenance-verdict">
{day.data.pickReason === "not_ranked"
? t("sourceProvenance.reason.not_ranked")
: t(
`sourceProvenance.reason.${day.data.pickReason ?? "only_source"}`,
{
source: sourceLabel(day.data.picked ?? ""),
},
)}
</p>
<ul className="divide-border divide-y rounded-lg border">
{day.data.sources.map((row) => (
<li
key={row.source}
data-slot="provenance-source-row"
data-source={row.source}
data-picked={
row.source === day.data?.picked ? "true" : "false"
}
className="flex items-start justify-between gap-3 p-3"
>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="flex flex-wrap items-center gap-2 text-sm font-medium">
{sourceLabel(row.source)}
{row.source === day.data?.picked ? (
<TagChip>{t("sourceProvenance.used")}</TagChip>
) : null}
</p>
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.readings", {
count: fmt.integer(row.readings),
devices: row.devices.join(", "),
})}
</p>
{row.pass ? (
<p className="text-muted-foreground text-xs">
{t(`sourceProvenance.pass.${row.pass}`)}
</p>
) : null}
</div>
<p className="shrink-0 text-sm tabular-nums">
{formatValue(row.value, day.data?.aggregation ?? "mean")}{" "}
<span className="text-muted-foreground text-xs">
{row.unit}
</span>
</p>
</li>
))}
</ul>
{day.data.ranked ? (
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.ladder", {
ladder: day.data.ladder.map(sourceLabel).join(", "),
})}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.utcNote")}
</p>
</>
)}
<InlineAction asChild tone="primary" size="xs">
<Link href="/settings/sources" data-slot="provenance-settings-link">
{t("sourceProvenance.openSettings")}
</Link>
</InlineAction>
</CardContent>
</Card>
<Card data-slot="provenance-compare">
<CardHeader>
<TileHeader
icon={GitCompareArrows}
title={t("sourceProvenance.compareTitle")}
right={
<Segmented
size="sm"
label={t("sourceProvenance.range")}
slot="provenance-range"
value={range}
onChange={setRange}
options={[
{
value: "30",
label: t("sourceProvenance.rangeDays", { days: 30 }),
},
{
value: "90",
label: t("sourceProvenance.rangeDays", { days: 90 }),
},
{
value: "365",
label: t("sourceProvenance.rangeDays", { days: 365 }),
},
]}
/>
}
/>
</CardHeader>
<CardContent className="space-y-4">
{compare.isError ? (
<QueryErrorRow
inset
error={compare.error}
message={t("sourceProvenance.loadError")}
onRetry={() => compare.refetch()}
/>
) : !compare.data ? (
<Skeleton className="h-32 w-full rounded-lg" />
) : compare.data.sources.length < 2 ? (
<EmptyState
variant="plain"
size="compact"
title={t("sourceProvenance.compareNeedsTwo")}
data-slot="provenance-compare-empty"
/>
) : (
<>
<div className="grid grid-cols-2 gap-4">
{(["a", "b"] as const).map((side) => (
<div key={side} className="grid gap-1.5">
<Label htmlFor={`provenance-${side}`}>
{t(`sourceProvenance.side.${side}`)}
</Label>
<NativeSelect
id={`provenance-${side}`}
data-slot={`provenance-${side}`}
value={
(side === "a" ? compare.data?.a : compare.data?.b) ?? ""
}
onChange={(e) =>
side === "a"
? setA(e.target.value)
: setB(e.target.value)
}
>
{compare.data?.sources.map((s) => (
<option key={s.source} value={s.source}>
{sourceLabel(s.source)}
</option>
))}
</NativeSelect>
</div>
))}
</div>
<dl
className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm"
data-slot="provenance-compare-summary"
>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.daysBoth")}
</dt>
<dd className="tabular-nums">
{fmt.integer(compare.data.summary.daysBoth)}
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.meanOf", {
source: sourceLabel(compare.data.a ?? ""),
})}
</dt>
<dd className="tabular-nums">
{formatValue(
compare.data.summary.meanA,
compare.data.aggregation,
)}
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.meanOf", {
source: sourceLabel(compare.data.b ?? ""),
})}
</dt>
<dd className="tabular-nums">
{formatValue(
compare.data.summary.meanB,
compare.data.aggregation,
)}
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.meanAbsDiff")}
</dt>
<dd className="tabular-nums">
{formatValue(
compare.data.summary.meanAbsoluteDifference,
"mean",
)}{" "}
<span className="text-muted-foreground text-xs">
{compare.data.unit}
</span>
</dd>
<dt className="text-muted-foreground text-xs">
{t("sourceProvenance.pickedDays")}
</dt>
<dd className="tabular-nums">
{t("sourceProvenance.pickedSplit", {
a: fmt.integer(compare.data.summary.pickedA),
b: fmt.integer(compare.data.summary.pickedB),
})}
</dd>
</dl>
<div className="max-h-80 overflow-y-auto overscroll-contain rounded-lg border">
<table
className="w-full text-sm"
data-slot="provenance-compare-table"
>
<thead className="bg-muted text-muted-foreground sticky top-0 text-xs">
<tr>
<th scope="col" className="p-2 text-left font-medium">
{t("sourceProvenance.day")}
</th>
<th scope="col" className="p-2 text-right font-medium">
{sourceLabel(compare.data.a ?? "")}
</th>
<th scope="col" className="p-2 text-right font-medium">
{sourceLabel(compare.data.b ?? "")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{[...compare.data.days].reverse().map((d) => (
<tr key={d.date} data-slot="provenance-compare-row">
<th scope="row" className="p-2 text-left font-normal">
<InlineAction
type="button"
tone="primary"
size="sm"
data-slot="provenance-compare-day"
onClick={() => setDate(d.date)}
>
{fmt.dateShortSmartCalendar(d.date)}
</InlineAction>
</th>
{(["a", "b"] as const).map((side) => {
const source = compare.data?.[side];
const used = source !== null && d.picked === source;
return (
<td
key={side}
className={
used
? "p-2 text-right font-semibold tabular-nums"
: "p-2 text-right tabular-nums"
}
>
{formatValue(
d[side],
compare.data?.aggregation ?? "mean",
)}
{used ? (
<span className="sr-only">
{" "}
{t("sourceProvenance.used")}
</span>
) : null}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.compareNote")}
</p>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -14,6 +14,9 @@
* (file/paste, client-side parseability guard).
* 3. CSV import — `csv-import-card.tsx`
* (file/paste, dry-run preview).
* 4. Lab results and medications from a spreadsheet —
* `record-csv-import-card.tsx` (v1.44, #1215; column mapping, check,
* import).
*
* Both controls surface rate-limit (429) and error states cleanly and
* announce progress to assistive tech via `aria-live` regions.
@@ -28,6 +31,7 @@ import { HealthConnectImportCard } from "./health-connect-import-card";
import { JsonImportCard } from "./json-import-card";
import { CsvImportCard } from "./csv-import-card";
import { DoseHistoryImportCard } from "./dose-history-import-card";
import { RecordCsvImportCard } from "./record-csv-import-card";
export {
EXAMPLE_IMPORT,
@@ -55,6 +59,7 @@ export function ImportPanel() {
<JsonImportCard />
<CsvImportCard />
<DoseHistoryImportCard />
<RecordCsvImportCard />
</div>
</section>
);
@@ -0,0 +1,562 @@
"use client";
import { useId, useMemo, useState } from "react";
import { Download, FileSpreadsheet, FlaskConical, Pill } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { apiFetch, ApiError } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
RECORD_IMPORT_TEMPLATES,
fieldsFor,
guessFormat,
guessMapping,
readHead,
type FieldKey,
type FileHead,
type RecordImportKind,
} from "@/lib/import/csv-mapping";
import type { RecordImportResult } from "@/lib/validations/record-import";
import { ImportCardShell } from "./import-card-shell";
/** The head of the file the mapping screen reads: enough for six rows. */
const HEAD_BYTES = 64 * 1024;
const MAX_FILE_BYTES = 16 * 1024 * 1024;
type Delimiter = "comma" | "semicolon" | "tab";
function delimiterName(d: string): Delimiter {
return d === ";" ? "semicolon" : d === "\t" ? "tab" : "comma";
}
function downloadTemplate(kind: RecordImportKind) {
const blob = new Blob([RECORD_IMPORT_TEMPLATES[kind]], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-${kind}-template.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/**
* Settings > Export & Import: lab results and medications from a spreadsheet
* (#1215). The card opens a sheet that reads the head of the file, suggests
* the column for each field, lets the person correct every choice, checks
* the whole file on the server (nothing written) and then imports it.
*/
export function RecordCsvImportCard() {
const { t } = useTranslations();
const [open, setOpen] = useState<RecordImportKind | null>(null);
const [session, setSession] = useState(0);
return (
<ImportCardShell
testId="import-card-records-csv"
anchor="import-records-csv"
icon={FileSpreadsheet}
title={t("recordImport.title")}
description={t("recordImport.description")}
>
<p className="text-sm">{t("recordImport.detail")}</p>
<SettingsCardActions align="start">
{(["labs", "medications"] as const).map((kind) => (
<Button
key={kind}
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot={`record-import-open-${kind}`}
onClick={() => {
setSession((n) => n + 1);
setOpen(kind);
}}
>
{kind === "labs" ? (
<FlaskConical className="size-4" aria-hidden="true" />
) : (
<Pill className="size-4" aria-hidden="true" />
)}
{t(`recordImport.kind.${kind}`)}
</Button>
))}
</SettingsCardActions>
{open ? (
<RecordCsvImportSheet
key={session}
kind={open}
onOpenChange={(next) => !next && setOpen(null)}
/>
) : null}
</ImportCardShell>
);
}
function RecordCsvImportSheet({
kind,
onOpenChange,
}: {
kind: RecordImportKind;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const fileId = useId();
const [file, setFile] = useState<File | null>(null);
const [head, setHead] = useState<FileHead | null>(null);
const [mapping, setMapping] = useState<Partial<Record<FieldKey, number>>>({});
const [delimiter, setDelimiter] = useState<Delimiter>("comma");
const [decimal, setDecimal] = useState<"dot" | "comma">("dot");
const [dateOrder, setDateOrder] = useState<"iso" | "dmy" | "mdy">("iso");
const [header, setHeader] = useState(true);
const [reminders, setReminders] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [check, setCheck] = useState<RecordImportResult | null>(null);
const [done, setDone] = useState<RecordImportResult | null>(null);
const fields = fieldsFor(kind);
const columns = useMemo(() => {
if (!head) return [] as string[];
const width = Math.max(...head.rows.map((r) => r.length), 0);
return Array.from({ length: width }, (_, i) =>
header && head.rows[0]?.[i]?.trim()
? head.rows[0][i].trim()
: t("recordImport.column", { n: i + 1 }),
);
}, [head, header, t]);
const missing = fields.filter(
(f) => f.required && mapping[f.key] === undefined,
);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const picked = e.target.files?.[0] ?? null;
e.target.value = "";
setCheck(null);
setDone(null);
setError(null);
if (!picked) return;
if (picked.size > MAX_FILE_BYTES) {
setError(t("recordImport.error.tooLarge"));
return;
}
const text = await picked.slice(0, HEAD_BYTES).text();
const parsedHead = readHead(text);
const guessed = guessMapping(parsedHead.rows[0] ?? [], kind);
const format = guessFormat(
parsedHead,
guessed[kind === "labs" ? "date" : "startsOn"],
);
setFile(picked);
setHead(parsedHead);
setMapping(guessed);
setDelimiter(delimiterName(parsedHead.delimiter));
setDecimal(format.decimal);
setDateOrder(format.dateOrder);
}
async function send(dryRun: boolean) {
if (!file) return;
setBusy(true);
setError(null);
const params = new URLSearchParams({
dryRun: dryRun ? "1" : "0",
header: header ? "1" : "0",
delimiter,
decimal,
dateOrder,
});
if (kind === "medications") params.set("reminders", reminders ? "1" : "0");
for (const f of fields) {
const index = mapping[f.key];
if (index !== undefined) params.set(f.key, String(index));
}
try {
const result = await apiFetch<RecordImportResult>(
`/api/import/${kind}-csv?${params}`,
{
method: "POST",
headers: { "Content-Type": "text/csv" },
body: file,
// A large file is read and written row by row; give it time.
signal: AbortSignal.timeout(5 * 60_000),
},
);
if (dryRun) setCheck(result);
else setDone(result);
} catch (err) {
const status = err instanceof ApiError ? err.status : 0;
setError(
status === 429
? t("recordImport.error.rateLimited")
: status === 413
? t("recordImport.error.tooLarge")
: status === 422
? t("recordImport.error.invalid")
: t("recordImport.error.failed"),
);
} finally {
setBusy(false);
}
}
const result = done ?? check;
return (
<ResponsiveSheet
open
onOpenChange={onOpenChange}
title={t(`recordImport.sheetTitle.${kind}`)}
description={t(`recordImport.sheetDescription.${kind}`)}
footer={
done ? (
<Button
type="button"
size="sm"
className="min-h-11"
onClick={() => onOpenChange(false)}
>
{t("recordImport.close")}
</Button>
) : (
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
disabled={!file || busy || missing.length > 0}
data-slot="record-import-check"
onClick={() => send(true)}
>
{t("recordImport.check")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11"
disabled={!check || busy || check.inserted === 0}
data-slot="record-import-run"
onClick={() => send(false)}
>
{check
? t("recordImport.run", { count: fmt.integer(check.inserted) })
: t("recordImport.runIdle")}
</Button>
</div>
)
}
>
<div
className="space-y-4"
data-slot="record-import-sheet"
data-kind={kind}
>
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor={fileId} className="sr-only">
{t("recordImport.file")}
</Label>
<input
id={fileId}
type="file"
accept=".csv,text/csv,text/plain"
data-slot="record-import-file"
className="text-sm"
onChange={onFile}
/>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="record-import-template"
onClick={() => downloadTemplate(kind)}
>
<Download className="size-4" aria-hidden="true" />
{t("recordImport.template")}
</Button>
</div>
{head ? (
<>
<fieldset
className="grid gap-4 sm:grid-cols-2"
data-slot="record-import-mapping"
>
<legend className="mb-2 text-sm font-medium">
{t("recordImport.mapping")}
</legend>
{fields.map((f) => (
<div key={f.key} className="grid gap-1.5">
<Label htmlFor={`map-${f.key}`}>
{t(`recordImport.field.${f.key}`)}
{f.required ? ` ${t("recordImport.required")}` : ""}
</Label>
<NativeSelect
id={`map-${f.key}`}
data-slot={`record-import-map-${f.key}`}
value={
mapping[f.key] === undefined ? "" : String(mapping[f.key])
}
onChange={(e) => {
setCheck(null);
const v = e.target.value;
setMapping((m) => {
const next = { ...m };
// One column feeds one field: taking it here frees it elsewhere.
for (const k of Object.keys(next) as FieldKey[]) {
if (v !== "" && next[k] === Number(v)) delete next[k];
}
if (v === "") delete next[f.key];
else next[f.key] = Number(v);
return next;
});
}}
>
<option value="">{t("recordImport.notInFile")}</option>
{columns.map((name, i) => (
<option key={i} value={String(i)}>
{name}
</option>
))}
</NativeSelect>
</div>
))}
</fieldset>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-1.5">
<Label htmlFor="record-import-delimiter">
{t("recordImport.delimiter")}
</Label>
<NativeSelect
id="record-import-delimiter"
value={delimiter}
onChange={(e) => {
setCheck(null);
setDelimiter(e.target.value as Delimiter);
}}
>
{(["comma", "semicolon", "tab"] as const).map((d) => (
<option key={d} value={d}>
{t(`recordImport.delimiterOption.${d}`)}
</option>
))}
</NativeSelect>
</div>
<div className="grid gap-1.5">
<Label htmlFor="record-import-decimal">
{t("recordImport.decimal")}
</Label>
<NativeSelect
id="record-import-decimal"
value={decimal}
onChange={(e) => {
setCheck(null);
setDecimal(e.target.value as "dot" | "comma");
}}
>
<option value="dot">
{t("recordImport.decimalOption.dot")}
</option>
<option value="comma">
{t("recordImport.decimalOption.comma")}
</option>
</NativeSelect>
</div>
<div className="grid gap-1.5">
<Label htmlFor="record-import-dates">
{t("recordImport.dateOrder")}
</Label>
<NativeSelect
id="record-import-dates"
value={dateOrder}
onChange={(e) => {
setCheck(null);
setDateOrder(e.target.value as "iso" | "dmy" | "mdy");
}}
>
{(["iso", "dmy", "mdy"] as const).map((o) => (
<option key={o} value={o}>
{t(`recordImport.dateOrderOption.${o}`)}
</option>
))}
</NativeSelect>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="record-import-header">
{t("recordImport.header")}
</Label>
<Switch
id="record-import-header"
checked={header}
onCheckedChange={(v) => {
setCheck(null);
setHeader(v);
}}
/>
</div>
{kind === "medications" ? (
<div className="flex items-center justify-between gap-3">
<Label htmlFor="record-import-reminders">
{t("recordImport.reminders")}
</Label>
<Switch
id="record-import-reminders"
checked={reminders}
onCheckedChange={(v) => {
setCheck(null);
setReminders(v);
}}
/>
</div>
) : null}
<p className="text-muted-foreground text-xs">
{t("recordImport.timezoneNote")}
</p>
<div
className="max-h-48 overflow-auto overscroll-contain rounded-lg border"
data-slot="record-import-head"
>
<table className="w-full text-xs">
<tbody className="divide-border divide-y">
{head.rows.map((row, r) => (
<tr
key={r}
className={
r === 0 && header ? "bg-muted font-medium" : undefined
}
>
{row.map((c, i) => (
<td
key={i}
className="max-w-40 truncate p-2 whitespace-nowrap"
>
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
{missing.length > 0 && head ? (
<p
className="text-muted-foreground text-xs"
data-slot="record-import-missing"
>
{t("recordImport.missing", {
fields: missing
.map((f) => t(`recordImport.field.${f.key}`))
.join(", "),
})}
</p>
) : null}
{error ? (
<p
role="alert"
className="text-destructive text-sm"
data-slot="record-import-error"
>
{error}
</p>
) : null}
{result ? (
<div
className="space-y-3"
data-slot={done ? "record-import-done" : "record-import-checked"}
aria-live="polite"
>
<p className="text-sm font-medium">
{done
? t("recordImport.summaryDone", {
inserted: fmt.integer(result.inserted),
duplicates: fmt.integer(result.duplicates),
failed: fmt.integer(result.failed),
})
: t("recordImport.summaryCheck", {
rows: fmt.integer(result.rows),
inserted: fmt.integer(result.inserted),
duplicates: fmt.integer(result.duplicates),
failed: fmt.integer(result.failed),
})}
</p>
{result.markers &&
(result.markers.created.length > 0 ||
result.markers.matched.length > 0) ? (
<div className="text-xs" data-slot="record-import-markers">
{result.markers.matched.length > 0 ? (
<p>
<span className="text-muted-foreground">
{t("recordImport.markersMatched")}{" "}
</span>
{result.markers.matched
.map((m) =>
m.name === m.marker
? m.name
: `${m.name} (${m.marker})`,
)
.join(", ")}
</p>
) : null}
{result.markers.created.length > 0 ? (
<p>
<span className="text-muted-foreground">
{t("recordImport.markersCreated")}{" "}
</span>
{result.markers.created.join(", ")}
</p>
) : null}
</div>
) : null}
{result.errors.length > 0 ? (
<ul
className="max-h-48 divide-y overflow-y-auto overscroll-contain rounded-lg border text-xs"
data-slot="record-import-errors"
>
{result.errors.map((e) => (
<li
key={`${e.line}-${e.reason}`}
className="flex gap-3 p-2"
data-slot="record-import-error-row"
>
<span className="text-muted-foreground shrink-0 tabular-nums">
{t("recordImport.line", { line: fmt.integer(e.line) })}
</span>
<span>
{t(`recordImport.reason.${e.reason}`)}
{e.field
? ` (${t(`recordImport.field.${e.field}`)})`
: ""}
</span>
</li>
))}
</ul>
) : null}
{result.errorsTruncated ? (
<p className="text-muted-foreground text-xs">
{t("recordImport.errorsTruncated")}
</p>
) : null}
</div>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -679,6 +679,19 @@ export function SourcesSection() {
{t("settings.sections.sources.integrationsHintLink")}
</Link>
</p>
{/* v1.44 (#1204) — the read-only view of what this ladder decides:
which source delivered what on a day, and two sources side by
side. Above the action row, like the hint before it. */}
<p className="text-muted-foreground text-xs">
{t("sourceProvenance.settingsHint")}{" "}
<Link
href="/insights/sources"
className="text-primary underline underline-offset-2"
data-slot="sources-provenance-cross-link"
>
{t("sourceProvenance.settingsHintLink")}
</Link>
</p>
{/* One action row, last in the card (design standards §12). The
reset used to ride the header's status slot, which carries