mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user