mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -96,6 +96,9 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
// as stored, schedule and switch included.
|
||||
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
|
||||
"lib/export/restore-backup.ts": "restore writes the stored rows back",
|
||||
// v1.44 — the CSV import writes new medications and reads only their names
|
||||
// and doses to skip duplicates; it derives nothing due.
|
||||
"lib/import/medications-csv.ts": "imports new rows, derives nothing due",
|
||||
// v1.43 — the units a taken dose consumed, resolved per slot exactly as
|
||||
// the inventory does. It reads only doses already recorded as taken.
|
||||
"lib/medications/ingredients/recompute.ts":
|
||||
|
||||
@@ -1077,6 +1077,18 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
|
||||
},
|
||||
"app/api/measurements/provenance/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "Which source delivered a metric on one UTC day and which one the record's source priority picks, with the reason. Read-only aggregates of the resolved user's readings; no AI, no note text.",
|
||||
},
|
||||
"app/api/measurements/provenance/compare/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "Two sources of one metric side by side over at most a year of UTC days, with the source the ladder picked each day. Read-only aggregates of the resolved user's readings; no AI, no note text.",
|
||||
},
|
||||
"app/api/activity/calendar/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The activity calendar: day totals of steps, active minutes and load, workouts by sport, and the record owner's goals, computed without AI. Read-only, scoped to the resolved user, module-gated on the record's own `workouts` switch like the workout list beside it.",
|
||||
},
|
||||
"app/api/cycle/cycles/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
|
||||
@@ -2178,8 +2190,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
*
|
||||
* v1.44 -- the module list layout and the preview-feature switch are the
|
||||
* person's own presentation and join the actor list. 268 -> 270.
|
||||
*
|
||||
* v1.44 -- the activity calendar read, the activity goals and the source
|
||||
* provenance reads join the list. 270 -> 273.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 270;
|
||||
const FROZEN_ENTRY_COUNT = 273;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
// the two status grids used to spell the tint at every call site and now
|
||||
// name a tone instead, so the marker lives here once.
|
||||
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
|
||||
// v1.44: the activity calendar's per-week tick for "every weekly goal met",
|
||||
// a state of a row the person reads, not the outcome of a write.
|
||||
"src/components/activity/activity-calendar-card.tsx": { "text-success": 1 },
|
||||
// v1.42: the readiness inventory's "carries" status icon, one arm of the
|
||||
// carries / thin / empty tone table, beside its spoken status label.
|
||||
"src/components/timeline/readiness-inventory.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -46,6 +46,9 @@ const IDENTITY_OWNER: Record<string, string> = {
|
||||
// The doctor report's per-sport block (v1.44): sums sessions, so a session
|
||||
// two sources reported must count once, as it does on the list.
|
||||
"lib/doctor-report/workouts.ts": "lib/doctor-report/workouts.ts",
|
||||
// The activity calendar counts workouts per day; a session two devices
|
||||
// recorded must count once there, as it does in the list.
|
||||
"lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts",
|
||||
// The only indirection in the set. The Coach block is handed rows it does not
|
||||
// read itself; the snapshot builder fetches them in parallel with every other
|
||||
// block's data. Recording the owner here rather than assuming the call site
|
||||
|
||||
@@ -53,6 +53,9 @@ const LIMITING_HELPERS: Record<string, string[]> = {
|
||||
"lib/withings/webhook-handler.ts",
|
||||
"lib/whoop/webhook-handler.ts",
|
||||
],
|
||||
// v1.44 — the lab and medication CSV imports: a preview bucket for a dry
|
||||
// run, the shared `import:` bucket for the import itself.
|
||||
handleRecordImport: ["lib/import/record-import-route.ts"],
|
||||
};
|
||||
|
||||
/** Mutating routes with no limit, each with the reason it needs none. */
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* `/api/activity/calendar` and `/api/activity/goals` (D1036).
|
||||
*
|
||||
* The goals: a PATCH replaces only what it names, `null` clears, an unknown
|
||||
* key or a goal out of bounds is a 422 and writes nothing, and clearing the
|
||||
* last goal returns the column to NULL rather than storing an empty object.
|
||||
* The calendar: the module gate answers before any read, a future or malformed
|
||||
* day is refused, and the reader is asked for exactly the window the period
|
||||
* needs and never past today.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
readActivityDays: vi.fn(),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "user-1" },
|
||||
actor: { id: "user-1" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } },
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: mocks.requireModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/activity/calendar-read", () => ({
|
||||
readActivityDays: mocks.readActivityDays,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { GET as calendarGet } from "../calendar/route";
|
||||
import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route";
|
||||
|
||||
type Handler = (req: NextRequest) => Promise<Response>;
|
||||
|
||||
function patch(body: unknown) {
|
||||
return (goalsPatch as unknown as Handler)(
|
||||
new NextRequest("http://localhost/api/activity/goals", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function calendar(query: string) {
|
||||
return (calendarGet as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/activity/calendar${query}`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findUnique.mockResolvedValue({
|
||||
activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 },
|
||||
});
|
||||
mocks.update.mockResolvedValue({});
|
||||
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null });
|
||||
});
|
||||
|
||||
describe("activity goals", () => {
|
||||
it("reads every goal, unset ones as null", async () => {
|
||||
const res = await (goalsGet as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: 50000,
|
||||
stepsPerMonth: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("changes what the body names and keeps the rest", async () => {
|
||||
const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mocks.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
activityGoalsJson: {
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: null,
|
||||
stepsPerMonth: 250000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.auditLog).toHaveBeenCalledWith(
|
||||
"activity.goals.update",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores NULL once the last goal is cleared", async () => {
|
||||
await patch({ workoutsPerWeek: null, stepsPerWeek: null });
|
||||
expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe(
|
||||
Prisma.DbNull,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ caloriesPerWeek: 3000 }],
|
||||
[{ workoutsPerWeek: 0 }],
|
||||
[{ stepsPerWeek: 2.5 }],
|
||||
[{ stepsPerMonth: "200000" }],
|
||||
])("refuses %j without writing", async (body) => {
|
||||
const res = await patch(body);
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity calendar", () => {
|
||||
it("answers the module gate before reading anything", async () => {
|
||||
mocks.requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
});
|
||||
const res = await calendar("?period=month");
|
||||
expect(res.status).toBe(403);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])(
|
||||
"refuses %s",
|
||||
async (q) => {
|
||||
const res = await calendar(q);
|
||||
expect(res.status).toBe(422);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a day after today", async () => {
|
||||
const res = await calendar("?date=2999-01-01");
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the baseline window before the period and nothing past the grid", async () => {
|
||||
const res = await calendar("?period=week&date=2025-03-12");
|
||||
expect(res.status).toBe(200);
|
||||
const args = mocks.readActivityDays.mock.calls[0][0];
|
||||
// Week of Monday 2025-03-10; 84 days before it.
|
||||
expect(args.fromKey).toBe("2024-12-16");
|
||||
expect(args.toKey).toBe("2025-03-16");
|
||||
expect(args.tz).toBe("Europe/Berlin");
|
||||
const body = await res.json();
|
||||
expect(body.data.start).toBe("2025-03-10");
|
||||
expect(body.data.goals.workoutsPerWeek).toBe(3);
|
||||
expect(body.data.days).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* `GET /api/activity/calendar` — a week or a month of activity against the
|
||||
* person's own normal, with their weekly and monthly goals (D1036).
|
||||
*
|
||||
* Computed server-side, without AI, from the stored readings: steps (the
|
||||
* canonical-source day totals the steps chart draws), exercise minutes,
|
||||
* workouts after the cross-source collapse, and a training-load score when a
|
||||
* device or HealthLog provides one. What "usual" means is in
|
||||
* `src/lib/activity/calendar-math.ts`; it is always the person's own history,
|
||||
* never a population norm.
|
||||
*
|
||||
* Owned by the Workouts module, like the page that shows it: a switched-off
|
||||
* module answers 403 here as `/api/workouts` does.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
buildActivityCalendar,
|
||||
periodBounds,
|
||||
} from "@/lib/activity/calendar-math";
|
||||
import { readActivityDays } from "@/lib/activity/calendar-read";
|
||||
import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar";
|
||||
import { parseActivityGoals } from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** How far back the calendar pages: ten years, the series route's ceiling. */
|
||||
const MAX_DAYS_BACK = 3650 - 200;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "activity.calendar.read" } });
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = activityCalendarQuerySchema.safeParse({
|
||||
period: params.get("period") ?? undefined,
|
||||
date: params.get("date") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const now = new Date();
|
||||
const today = userDayKey(now, tz);
|
||||
const anchor = parsed.data.date ?? today;
|
||||
if (anchor > today) {
|
||||
return apiError("The calendar cannot show a day after today", 422, {
|
||||
errorCode: "activity.calendar.future_date",
|
||||
});
|
||||
}
|
||||
if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) {
|
||||
return apiError("The calendar reaches back ten years", 422, {
|
||||
errorCode: "activity.calendar.too_old",
|
||||
});
|
||||
}
|
||||
|
||||
const bounds = periodBounds(parsed.data.period, anchor);
|
||||
const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today;
|
||||
const [{ days, loadType }, goalRow] = await Promise.all([
|
||||
readActivityDays({
|
||||
userId: user.id,
|
||||
tz,
|
||||
fromKey: bounds.readStart,
|
||||
toKey: lastRead,
|
||||
now,
|
||||
includeWorkouts: true,
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { activityGoalsJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const calendar = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input: days,
|
||||
goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null),
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
activity_calendar_period: bounds.period,
|
||||
activity_calendar_days_read: days.length,
|
||||
activity_calendar_load_type: loadType ?? "none",
|
||||
},
|
||||
});
|
||||
return apiSuccess({ ...calendar, timezone: tz, loadType });
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* GET / PATCH the caller's activity goals (D1036).
|
||||
*
|
||||
* Every goal is optional and set by the person; there is no default goal.
|
||||
* PATCH replaces the goals the body names (`null` clears one) and keeps the
|
||||
* rest; an unknown key or an out-of-bounds goal is refused with the
|
||||
* multi-issue 422. The goals live on their own `User` column
|
||||
* (`activity_goals_json`) and the activity calendar reads them.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
ACTIVITY_GOAL_KEYS,
|
||||
activityGoalsSchema,
|
||||
hasNoActivityGoals,
|
||||
mergeActivityGoals,
|
||||
parseActivityGoals,
|
||||
type ActivityGoals,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readGoals(userId: string): Promise<ActivityGoals> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activityGoalsJson: true },
|
||||
});
|
||||
return parseActivityGoals(row?.activityGoalsJson ?? null);
|
||||
}
|
||||
|
||||
function setKeys(goals: ActivityGoals): string[] {
|
||||
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// Owner-only, like the write: a delegate sees the goals where they matter,
|
||||
// inside the calendar, which carries them.
|
||||
const { user } = await requireAuth();
|
||||
const goals = await readGoals(user.id);
|
||||
annotate({
|
||||
action: { name: "activity.goals.read" },
|
||||
meta: { set: setKeys(goals) },
|
||||
});
|
||||
return apiSuccess(goals);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = activityGoalsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "activity.goals.validation-failed" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
activityGoalsJson: hasNoActivityGoals(next)
|
||||
? Prisma.DbNull
|
||||
: toJson(next),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("activity.goals.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { set: setKeys(next) },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "activity.goals.update" },
|
||||
meta: { set: setKeys(next) },
|
||||
});
|
||||
return apiSuccess(next);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The request side of `/api/import/labs-csv` and `/api/import/medications-csv`
|
||||
* (#1215): module gate first, a separate bucket for dry runs, the content
|
||||
* type, the byte cap while streaming, the mapping refused before anything is
|
||||
* read, and an audit row only for a real import.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
importLabsCsv: vi.fn(),
|
||||
importMedicationsCsv: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: mocks.requireModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
vi.mock("@/lib/import/labs-csv", () => ({
|
||||
importLabsCsv: mocks.importLabsCsv,
|
||||
}));
|
||||
vi.mock("@/lib/import/medications-csv", () => ({
|
||||
importMedicationsCsv: mocks.importMedicationsCsv,
|
||||
}));
|
||||
|
||||
import { CsvTooLargeError } from "@/lib/import/csv-stream";
|
||||
import { POST as labsPost } from "../labs-csv/route";
|
||||
import { POST as medsPost } from "../medications-csv/route";
|
||||
|
||||
type Handler = (req: NextRequest) => Promise<Response>;
|
||||
const RESULT = {
|
||||
kind: "labs",
|
||||
dryRun: false,
|
||||
rows: 2,
|
||||
valid: 2,
|
||||
inserted: 2,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
errorsTruncated: false,
|
||||
preview: [],
|
||||
};
|
||||
|
||||
function post(
|
||||
handler: unknown,
|
||||
query: string,
|
||||
body = "a,b\n1,2\n",
|
||||
type = "text/csv",
|
||||
) {
|
||||
return (handler as Handler)(
|
||||
new NextRequest(`http://localhost/api/import/x?${query}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": type },
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
mocks.importLabsCsv.mockResolvedValue(RESULT);
|
||||
mocks.importMedicationsCsv.mockResolvedValue({
|
||||
...RESULT,
|
||||
kind: "medications",
|
||||
});
|
||||
});
|
||||
|
||||
const LABS = "analyte=0&value=1&unit=2&date=3";
|
||||
const MEDS = "name=0&dose=1×=2";
|
||||
|
||||
describe("record CSV import routes", () => {
|
||||
it("answers the module gate before reading the file", async () => {
|
||||
mocks.requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
});
|
||||
expect((await post(labsPost, LABS)).status).toBe(403);
|
||||
expect(mocks.requireModuleEnabled).toHaveBeenCalledWith("user-1", "labs");
|
||||
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a mapping without the required columns, or with a shared column", async () => {
|
||||
expect((await post(labsPost, "analyte=0&value=1")).status).toBe(422);
|
||||
expect((await post(labsPost, "analyte=0&value=0&date=1")).status).toBe(422);
|
||||
expect((await post(medsPost, "name=0")).status).toBe(422);
|
||||
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("spends the preview bucket on a dry run and the import bucket otherwise", async () => {
|
||||
await post(labsPost, `${LABS}&dryRun=1`);
|
||||
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
|
||||
"import-preview:user-1",
|
||||
30,
|
||||
3_600_000,
|
||||
);
|
||||
expect(mocks.auditLog).not.toHaveBeenCalled();
|
||||
await post(medsPost, MEDS);
|
||||
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
|
||||
"import:user-1",
|
||||
5,
|
||||
3_600_000,
|
||||
);
|
||||
expect(mocks.auditLog).toHaveBeenCalledWith(
|
||||
"import.medications.csv",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 429 when the bucket is spent", async () => {
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: false });
|
||||
expect((await post(labsPost, LABS)).status).toBe(429);
|
||||
});
|
||||
|
||||
it("refuses a body that is not CSV", async () => {
|
||||
expect((await post(labsPost, LABS, "{}", "application/json")).status).toBe(
|
||||
415,
|
||||
);
|
||||
});
|
||||
|
||||
it("turns the streaming cap into a 413", async () => {
|
||||
mocks.importLabsCsv.mockRejectedValue(new CsvTooLargeError(1));
|
||||
expect((await post(labsPost, LABS)).status).toBe(413);
|
||||
});
|
||||
|
||||
it("calls a file without data rows empty", async () => {
|
||||
mocks.importLabsCsv.mockResolvedValue({ ...RESULT, rows: 0 });
|
||||
expect((await post(labsPost, LABS)).status).toBe(422);
|
||||
});
|
||||
|
||||
it("hands the parsed mapping and the account zone to the importer", async () => {
|
||||
const res = await post(
|
||||
labsPost,
|
||||
`${LABS}&decimal=comma&dateOrder=dmy&delimiter=semicolon`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const args = mocks.importLabsCsv.mock.calls[0][0];
|
||||
expect(args.tz).toBe("Europe/Berlin");
|
||||
expect(args.query).toMatchObject({
|
||||
analyte: 0,
|
||||
value: 1,
|
||||
unit: 2,
|
||||
date: 3,
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
delimiter: "semicolon",
|
||||
dryRun: false,
|
||||
header: true,
|
||||
});
|
||||
expect(args.stream).toBeInstanceOf(ReadableStream);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `POST /api/import/labs-csv` — lab results from a spreadsheet (#1215).
|
||||
*
|
||||
* Body: the CSV file, streamed. Query: the column mapping and the file's
|
||||
* conventions (`labsCsvQuerySchema`). `dryRun=1` checks every row and reports
|
||||
* what would happen without writing; the real import writes in chunks, skips
|
||||
* readings the record already holds and reports every refused row by line.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { importLabsCsv } from "@/lib/import/labs-csv";
|
||||
import { handleRecordImport } from "@/lib/import/record-import-route";
|
||||
import { labsCsvQuerySchema } from "@/lib/validations/record-import";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) =>
|
||||
handleRecordImport(request, {
|
||||
kind: "labs",
|
||||
module: "labs",
|
||||
schema: labsCsvQuerySchema,
|
||||
run: (args) => importLabsCsv(args),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `POST /api/import/medications-csv` — medications from a spreadsheet (#1215).
|
||||
*
|
||||
* Body: the CSV file, streamed. Query: the column mapping and the file's
|
||||
* conventions (`medicationsCsvQuerySchema`). Maps only name, dose, daily
|
||||
* times, as-needed and a start and end date; reminders stay off unless the
|
||||
* query turns them on. `dryRun=1` checks without writing.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { importMedicationsCsv } from "@/lib/import/medications-csv";
|
||||
import { handleRecordImport } from "@/lib/import/record-import-route";
|
||||
import { medicationsCsvQuerySchema } from "@/lib/validations/record-import";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) =>
|
||||
handleRecordImport(request, {
|
||||
kind: "medications",
|
||||
module: "medications",
|
||||
schema: medicationsCsvQuerySchema,
|
||||
run: (args) => importMedicationsCsv(args),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* `GET /api/measurements/provenance/compare` — two sources of one metric side
|
||||
* by side over up to a year of UTC days, with the source the ladder picked
|
||||
* each day (#1204). Read-only; one aggregate query, at most one row per day
|
||||
* and source.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readSourceComparison } from "@/lib/provenance/source-provenance";
|
||||
import { sourceComparisonQuerySchema } from "@/lib/validations/source-provenance";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "measurement.provenance.compare" } });
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = sourceComparisonQuerySchema.safeParse({
|
||||
type: params.get("type") ?? undefined,
|
||||
from: params.get("from") ?? undefined,
|
||||
to: params.get("to") ?? undefined,
|
||||
a: params.get("a") ?? undefined,
|
||||
b: params.get("b") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const result = await readSourceComparison({
|
||||
userId: user.id,
|
||||
...parsed.data,
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
provenance_type: result.type,
|
||||
provenance_days: result.days.length,
|
||||
provenance_sources: result.sources.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* `GET /api/measurements/provenance` — which sources delivered a metric on a
|
||||
* day, which one the source-priority ladder picked, and why (#1204, #1108,
|
||||
* #576). Read-only; the explanation comes from the same rank SQL the charts
|
||||
* and summaries pick with (`src/lib/provenance/source-provenance.ts`).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readDayProvenance } from "@/lib/provenance/source-provenance";
|
||||
import { dayProvenanceQuerySchema } from "@/lib/validations/source-provenance";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "measurement.provenance.day" } });
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = dayProvenanceQuerySchema.safeParse({
|
||||
type: params.get("type") ?? undefined,
|
||||
date: params.get("date") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const result = await readDayProvenance({
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
date: parsed.data.date,
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
provenance_type: result.type,
|
||||
provenance_sources: result.sources.length,
|
||||
provenance_pick_reason: result.pickReason ?? "none",
|
||||
},
|
||||
});
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
|
||||
import Link from "next/link";
|
||||
import { ArrowDownRight, ArrowUpRight, HeartPulse } from "lucide-react";
|
||||
|
||||
@@ -226,6 +227,8 @@ export default function InsightsBlutdruckPage() {
|
||||
slug="blood-pressure"
|
||||
icon={<HeartPulse className="h-5 w-5" />}
|
||||
/>
|
||||
{/* v1.44 (#1204) — where these values come from. */}
|
||||
<SourceProvenanceLink type="BLOOD_PRESSURE_SYS" />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
|
||||
import Link from "next/link";
|
||||
import { Gauge, Heart } from "lucide-react";
|
||||
|
||||
@@ -211,6 +212,8 @@ export default function InsightsPulsPage() {
|
||||
cta={t("insights.vo2Max.cardioLinkCta")}
|
||||
/>
|
||||
) : null}
|
||||
{/* v1.44 (#1204) — where these values come from. */}
|
||||
<SourceProvenanceLink type="PULSE" />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
@@ -153,6 +154,8 @@ export default function InsightsSchlafPage() {
|
||||
<SleepQualitySection enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="sleep" />
|
||||
{/* v1.44 (#1204) — where these values come from. */}
|
||||
<SourceProvenanceLink type="SLEEP_DURATION" />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { SourceProvenanceView } from "@/components/provenance/source-provenance-view";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isProvenanceType } from "@/lib/provenance/ranked-types";
|
||||
|
||||
/**
|
||||
* v1.44 — `/insights/sources` (#1204, #1108, #576).
|
||||
*
|
||||
* Where a metric's values come from: per day, every source that delivered
|
||||
* one and the one the source priority picked, with the reason; and two
|
||||
* sources side by side over a range. Reached from a metric's page and from
|
||||
* Settings > Source priority; `?type=` and `?date=` open it on a metric and a
|
||||
* day. Read-only: the order that decides the pick is edited in Settings.
|
||||
*/
|
||||
function SourcesPageBody() {
|
||||
const params = useSearchParams();
|
||||
const type = params.get("type");
|
||||
const date = params.get("date");
|
||||
return (
|
||||
<SourceProvenanceView
|
||||
initialType={type && isProvenanceType(type) ? type : "ACTIVITY_STEPS"}
|
||||
initialDate={date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InsightsSourcesPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("sourceProvenance.title")}
|
||||
description={t("sourceProvenance.description")}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SourcesPageBody />
|
||||
</Suspense>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Footprints } from "lucide-react";
|
||||
import { CalendarDays, Footprints } from "lucide-react";
|
||||
|
||||
import { CrossLinkCard } from "@/components/insights/cross-link-card";
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.12 — `/insights/steps`.
|
||||
@@ -15,8 +18,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* user having step data. Reuses the generic HealthKitMetricPage scaffold;
|
||||
* empty-state carries no manual-entry CTA — the series arrives from Apple
|
||||
* Health or Withings sync.
|
||||
*
|
||||
* v1.44 — links across to the activity calendar on the workouts page, which
|
||||
* shows the same day totals as a week or month against the person's usual.
|
||||
* The link follows the Workouts module: switched off, it is not offered.
|
||||
*/
|
||||
export default function InsightsStepsPage() {
|
||||
const { t } = useTranslations();
|
||||
const calendarVisible = useSurfaceVisible("insights-page:workouts");
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="ACTIVITY_STEPS"
|
||||
@@ -32,6 +41,18 @@ export default function InsightsStepsPage() {
|
||||
emptyStateCtaType={null}
|
||||
captureType="ACTIVITY_STEPS"
|
||||
coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
|
||||
afterChart={
|
||||
calendarVisible ? (
|
||||
<CrossLinkCard
|
||||
href="/insights/workouts#activity-calendar"
|
||||
slot="steps-activity-calendar-link"
|
||||
icon={CalendarDays}
|
||||
title={t("activityCalendar.crossLink.title")}
|
||||
body={t("activityCalendar.crossLink.body")}
|
||||
cta={t("activityCalendar.crossLink.cta")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Scale } from "lucide-react";
|
||||
@@ -222,6 +223,8 @@ export default function InsightsGewichtPage() {
|
||||
slug="weight"
|
||||
icon={<Scale className="h-5 w-5" />}
|
||||
/>
|
||||
{/* v1.44 (#1204) — where these values come from. */}
|
||||
<SourceProvenanceLink type="WEIGHT" />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { canLogWorkout } from "@/lib/workouts/manual-entry";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ActivityCalendarCard } from "@/components/activity/activity-calendar-card";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/workouts`.
|
||||
@@ -107,6 +108,11 @@ export default function InsightsWorkoutsPageClient() {
|
||||
{showLog ? (
|
||||
<ManualWorkoutSheet open={logOpen} onOpenChange={setLogOpen} />
|
||||
) : null}
|
||||
{/* The activity calendar (D1036): steps, active minutes and workouts
|
||||
against the person's own usual. It stands above the list and draws
|
||||
from steps alone too, so an account without workouts still sees
|
||||
its routine. */}
|
||||
<ActivityCalendarCard />
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar";
|
||||
import type {
|
||||
ActivityGoals,
|
||||
ActivityGoalsInput,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
/**
|
||||
* `GET /api/activity/calendar` for one period. `date` null = the period that
|
||||
* contains today. The server computes everything, the client only draws it.
|
||||
*/
|
||||
export function useActivityCalendar(
|
||||
period: "week" | "month",
|
||||
date: string | null,
|
||||
opts?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.activityCalendar(period, date),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ period });
|
||||
if (date) params.set("date", date);
|
||||
return apiGet<ActivityCalendarResponse>(
|
||||
`/api/activity/calendar?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
enabled: opts?.enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** `PATCH /api/activity/goals`; refreshes every calendar cell on success. */
|
||||
export function useSaveActivityGoals() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch: ActivityGoalsInput) =>
|
||||
apiPatch<ActivityGoals>("/api/activity/goals", patch),
|
||||
onSuccess: (goals) => {
|
||||
queryClient.setQueryData(queryKeys.activityGoals(), goals);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.activityCalendarAll(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DayProvenanceResponse,
|
||||
SourceComparisonResponse,
|
||||
} from "@/lib/validations/source-provenance";
|
||||
|
||||
/** `GET /api/measurements/provenance` for one metric and UTC day. */
|
||||
export function useDayProvenance(type: string, date: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.provenanceDay(type, date),
|
||||
queryFn: () =>
|
||||
apiGet<DayProvenanceResponse>(
|
||||
`/api/measurements/provenance?${new URLSearchParams({ type, date })}`,
|
||||
),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** `GET /api/measurements/provenance/compare` over a range of UTC days. */
|
||||
export function useSourceComparison(opts: {
|
||||
type: string;
|
||||
from: string;
|
||||
to: string;
|
||||
a?: string;
|
||||
b?: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.provenanceCompare(opts),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
type: opts.type,
|
||||
from: opts.from,
|
||||
to: opts.to,
|
||||
});
|
||||
if (opts.a) params.set("a", opts.a);
|
||||
if (opts.b) params.set("b", opts.b);
|
||||
return apiGet<SourceComparisonResponse>(
|
||||
`/api/measurements/provenance/compare?${params}`,
|
||||
);
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals";
|
||||
|
||||
import {
|
||||
BASELINE_DAYS,
|
||||
MIN_BASELINE_DAYS,
|
||||
activeMinutesOf,
|
||||
baselineStats,
|
||||
buildActivityCalendar,
|
||||
comparePeriod,
|
||||
dayLevel,
|
||||
daysInMonth,
|
||||
goalProgress,
|
||||
periodBounds,
|
||||
quantileSorted,
|
||||
shiftMonth,
|
||||
weekStartOf,
|
||||
type ActivityDayInput,
|
||||
} from "../calendar-math";
|
||||
|
||||
function day(
|
||||
date: string,
|
||||
over: Partial<ActivityDayInput> = {},
|
||||
): ActivityDayInput {
|
||||
return {
|
||||
date,
|
||||
steps: null,
|
||||
exerciseMinutes: null,
|
||||
workoutMinutes: 0,
|
||||
workoutCount: 0,
|
||||
workoutsBySport: {},
|
||||
load: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("quantiles", () => {
|
||||
it("interpolates linearly between ranks (PERCENTILE.INC)", () => {
|
||||
const s = [1, 2, 3, 4, 5];
|
||||
expect(quantileSorted(s, 0)).toBe(1);
|
||||
expect(quantileSorted(s, 0.5)).toBe(3);
|
||||
expect(quantileSorted(s, 0.25)).toBe(2);
|
||||
expect(quantileSorted([10, 20], 0.5)).toBe(15);
|
||||
expect(quantileSorted([10, 20, 30, 40], 0.9)).toBeCloseTo(37);
|
||||
});
|
||||
|
||||
it("refuses a baseline below the day floor", () => {
|
||||
expect(baselineStats(Array(MIN_BASELINE_DAYS - 1).fill(5000))).toBeNull();
|
||||
const stats = baselineStats(
|
||||
Array.from({ length: 20 }, (_, i) => (i + 1) * 1000),
|
||||
);
|
||||
expect(stats?.days).toBe(20);
|
||||
expect(stats?.median).toBe(10500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("day level against the person's own spread", () => {
|
||||
const stats = baselineStats(Array.from({ length: 100 }, (_, i) => i))!;
|
||||
it.each([
|
||||
[null, "none"],
|
||||
[5, "low"],
|
||||
[20, "below"],
|
||||
[50, "usual"],
|
||||
[80, "above"],
|
||||
[95, "high"],
|
||||
] as const)("%s → %s", (value, level) => {
|
||||
expect(dayLevel(value, stats)).toBe(level);
|
||||
});
|
||||
|
||||
it("is unrated without a baseline, not guessed", () => {
|
||||
expect(dayLevel(9000, null)).toBe("unrated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("active minutes never double-count a workout", () => {
|
||||
it("takes the larger of exercise and workout minutes", () => {
|
||||
expect(
|
||||
activeMinutesOf(
|
||||
day("2026-05-01", { exerciseMinutes: 40, workoutMinutes: 30 }),
|
||||
true,
|
||||
),
|
||||
).toBe(40);
|
||||
expect(
|
||||
activeMinutesOf(
|
||||
day("2026-05-01", { exerciseMinutes: 10, workoutMinutes: 45 }),
|
||||
true,
|
||||
),
|
||||
).toBe(45);
|
||||
});
|
||||
it("is 0 on a quiet day once the account has activity data, otherwise unknown", () => {
|
||||
expect(activeMinutesOf(day("2026-05-01"), true)).toBe(0);
|
||||
expect(activeMinutesOf(day("2026-05-01"), false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("period bounds", () => {
|
||||
it("weeks run Monday to Sunday", () => {
|
||||
// 2026-10-21 is a Wednesday.
|
||||
expect(weekStartOf("2026-10-21")).toBe("2026-10-19");
|
||||
expect(weekStartOf("2026-10-19")).toBe("2026-10-19");
|
||||
expect(weekStartOf("2026-10-25")).toBe("2026-10-19");
|
||||
const b = periodBounds("week", "2026-10-21");
|
||||
expect([b.start, b.end]).toEqual(["2026-10-19", "2026-10-25"]);
|
||||
expect(b.readStart).toBe(shiftDateKey("2026-10-19", -BASELINE_DAYS));
|
||||
});
|
||||
|
||||
it("a month grid covers whole weeks and reads six months back", () => {
|
||||
const b = periodBounds("month", "2026-02-14");
|
||||
expect([b.start, b.end]).toEqual(["2026-02-01", "2026-02-28"]);
|
||||
expect(b.gridStart).toBe("2026-01-26");
|
||||
expect(b.gridEnd).toBe("2026-03-01");
|
||||
expect(b.readStart).toBe("2025-08-01");
|
||||
});
|
||||
|
||||
it("month arithmetic handles length and year turns", () => {
|
||||
expect(daysInMonth("2024-02-01")).toBe(29);
|
||||
expect(daysInMonth("2026-02-01")).toBe(28);
|
||||
expect(shiftMonth("2026-01-01", -1)).toBe("2025-12-01");
|
||||
expect(shiftMonth("2026-12-01", 1)).toBe("2027-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("period against the usual period", () => {
|
||||
it("compares a finished week with the median of the twelve before", () => {
|
||||
const values = new Map<string, number>();
|
||||
// Twelve earlier weeks at 7 000 a day, this week at 10 000 a day.
|
||||
const bounds = periodBounds("week", "2026-10-05");
|
||||
for (let i = 1; i <= 84; i++)
|
||||
values.set(shiftDateKey(bounds.start, -i), 7000);
|
||||
for (let i = 0; i < 7; i++)
|
||||
values.set(shiftDateKey(bounds.start, i), 10000);
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21",
|
||||
valueOf: (d) => values.get(d) ?? null,
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.value).toBe(70000);
|
||||
expect(result.usual).toBe(49000);
|
||||
expect(result.usualToDate).toBe(49000);
|
||||
expect(result.referencePeriods).toBe(12);
|
||||
});
|
||||
|
||||
it("pro-rates the usual for a period still running", () => {
|
||||
const bounds = periodBounds("week", "2026-10-21");
|
||||
const values = new Map<string, number>();
|
||||
for (let i = 1; i <= 84; i++)
|
||||
values.set(shiftDateKey(bounds.start, -i), 1000);
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21", // Wednesday: three days elapsed
|
||||
valueOf: (d) =>
|
||||
values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.value).toBe(6000);
|
||||
expect(result.usual).toBe(7000);
|
||||
expect(result.usualToDate).toBe(3000);
|
||||
});
|
||||
|
||||
it("compares months per day, so February meets March fairly", () => {
|
||||
const bounds = periodBounds("month", "2026-02-10");
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21",
|
||||
valueOf: (d) => (d < "2026-03-01" ? 100 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.value).toBe(2800);
|
||||
// Six earlier months at 100 a day → 28 days × 100.
|
||||
expect(result.usual).toBe(2800);
|
||||
});
|
||||
|
||||
it("gives no usual with fewer than three earlier periods of data", () => {
|
||||
const bounds = periodBounds("week", "2026-10-05");
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21",
|
||||
valueOf: (d) => (d >= shiftDateKey(bounds.start, -14) ? 500 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.referencePeriods).toBe(2);
|
||||
expect(result.usual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goals", () => {
|
||||
it("reports only goals that are set, for the period they belong to", () => {
|
||||
const goals = {
|
||||
...NO_ACTIVITY_GOALS,
|
||||
workoutsPerWeek: 3,
|
||||
stepsPerMonth: 200000,
|
||||
};
|
||||
expect(goalProgress("week", goals, { workouts: 4 })).toEqual([
|
||||
{ key: "workoutsPerWeek", target: 3, value: 4, met: true },
|
||||
]);
|
||||
expect(goalProgress("month", goals, { steps: 150000 })).toEqual([
|
||||
{ key: "stepsPerMonth", target: 200000, value: 150000, met: false },
|
||||
]);
|
||||
expect(goalProgress("week", NO_ACTIVITY_GOALS, { workouts: 9 })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the assembled calendar", () => {
|
||||
const today = "2026-10-21";
|
||||
const bounds = periodBounds("month", today);
|
||||
// 84 baseline days of 5 000–9 000 steps, a run every Monday.
|
||||
const input: ActivityDayInput[] = [];
|
||||
for (let i = 1; i <= 200; i++) {
|
||||
const date = shiftDateKey(bounds.start, -i);
|
||||
input.push(
|
||||
day(date, {
|
||||
steps: 5000 + (i % 5) * 1000,
|
||||
workoutCount: i % 7 === 0 ? 1 : 0,
|
||||
workoutMinutes: i % 7 === 0 ? 30 : 0,
|
||||
workoutsBySport: i % 7 === 0 ? { running: 1 } : {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
input.push(
|
||||
day("2026-10-02", {
|
||||
steps: 15000,
|
||||
workoutCount: 2,
|
||||
workoutMinutes: 90,
|
||||
workoutsBySport: { running: 1, cycling: 1 },
|
||||
}),
|
||||
);
|
||||
input.push(day("2026-10-03", { steps: 2000 }));
|
||||
const calendar = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input,
|
||||
goals: { ...NO_ACTIVITY_GOALS, workoutsPerWeek: 2 },
|
||||
});
|
||||
|
||||
it("draws whole weeks and marks the days after today as future", () => {
|
||||
expect(calendar.days[0].date).toBe(calendar.gridStart);
|
||||
expect(calendar.days.length % 7).toBe(0);
|
||||
const future = calendar.days.find((d) => d.date === "2026-10-22");
|
||||
expect(future?.future).toBe(true);
|
||||
expect(future?.level.steps).toBe("none");
|
||||
});
|
||||
|
||||
it("rates a big day high and a short one low against the person's own spread", () => {
|
||||
expect(calendar.baseline.steps?.days).toBe(BASELINE_DAYS);
|
||||
expect(
|
||||
calendar.days.find((d) => d.date === "2026-10-02")?.level.steps,
|
||||
).toBe("high");
|
||||
expect(
|
||||
calendar.days.find((d) => d.date === "2026-10-03")?.level.steps,
|
||||
).toBe("low");
|
||||
expect(
|
||||
calendar.days.find((d) => d.date === "2026-10-04")?.level.steps,
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("counts workouts by sport in the period", () => {
|
||||
expect(calendar.sports).toEqual([
|
||||
{ sportType: "cycling", count: 1 },
|
||||
{ sportType: "running", count: 1 },
|
||||
]);
|
||||
expect(calendar.summary.workouts.value).toBe(2);
|
||||
});
|
||||
|
||||
it("marks weekly goals per calendar row, and none for a week not yet begun", () => {
|
||||
const week = calendar.weeks.find((w) => w.start === "2026-09-28");
|
||||
expect(week?.goals).toEqual([
|
||||
{ key: "workoutsPerWeek", target: 2, value: 2, met: true },
|
||||
]);
|
||||
const last = calendar.weeks[calendar.weeks.length - 1];
|
||||
expect(last.start > today).toBe(true);
|
||||
expect(last.goals).toEqual([]);
|
||||
expect(calendar.next).toBeNull();
|
||||
expect(calendar.previous).toBe("2026-09-01");
|
||||
});
|
||||
|
||||
it("offers only the signals the account has", () => {
|
||||
expect(calendar.available).toEqual({
|
||||
steps: true,
|
||||
activeMinutes: true,
|
||||
workouts: true,
|
||||
load: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Activity calendar arithmetic (D1036). Pure: no database, no clock, no zone.
|
||||
*
|
||||
* The calendar compares a week or a month of activity with the person's own
|
||||
* normal, never with a population norm. Two comparisons, each with its own
|
||||
* honest reference:
|
||||
*
|
||||
* - A DAY is placed against the spread of the person's own days in the 84
|
||||
* days before the period starts (`BASELINE_DAYS`): below the 10th
|
||||
* percentile is `low`, below the 25th `below`, above the 75th `above`,
|
||||
* above the 90th `high`, everything between `usual`. With fewer than
|
||||
* `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`:
|
||||
* there is not enough history to say what usual is, and the calendar says
|
||||
* so instead of guessing.
|
||||
* - A PERIOD total is placed against the median total of the same kind of
|
||||
* period before it: the twelve weeks before a week, the six months before
|
||||
* a month. A month is compared per day (months differ in length), so the
|
||||
* usual month is the median daily rate of the earlier months times this
|
||||
* month's length. A period still running is compared with the usual rate
|
||||
* times the days elapsed so far, not with a full period it cannot have
|
||||
* reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data
|
||||
* are needed; fewer gives no usual.
|
||||
*
|
||||
* What a day's value is:
|
||||
* - steps: the day's step total (the same canonical-source sum the steps
|
||||
* chart draws); a day without a step reading has no value, it is not 0,
|
||||
* because a watch left on the charger is not a day without steps.
|
||||
* - active minutes: the larger of the day's exercise minutes and the day's
|
||||
* workout minutes. Exercise minutes already contain the workouts a watch
|
||||
* recorded, so adding the two would count a workout twice; taking the
|
||||
* larger never does, and still counts a workout logged by hand on a day
|
||||
* the watch recorded nothing. Once the account has any activity data in
|
||||
* the window, a day without either is 0.
|
||||
* - workouts: the number of workouts that started that day, after the
|
||||
* cross-source duplicate collapse the workout list applies. Shown as
|
||||
* counts, never rated: most days have none, so percentiles of a mostly-zero
|
||||
* series say nothing.
|
||||
* - load: the day's training-load score when a device or HealthLog computes
|
||||
* one; no value otherwise.
|
||||
*/
|
||||
import {
|
||||
daysBetweenDateKeys,
|
||||
shiftDateKey,
|
||||
weekdayOfDateKey,
|
||||
} from "@/lib/tz/format";
|
||||
|
||||
import type {
|
||||
ActivityGoalKey,
|
||||
ActivityGoals,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
/** Days before the period whose spread defines a usual day. */
|
||||
export const BASELINE_DAYS = 84;
|
||||
/** Days with a value needed before a day is rated against the baseline. */
|
||||
export const MIN_BASELINE_DAYS = 14;
|
||||
/** Earlier periods compared against: twelve weeks, six months. */
|
||||
export const REFERENCE_WEEKS = 12;
|
||||
export const REFERENCE_MONTHS = 6;
|
||||
/** Earlier periods with data needed before a usual period total is given. */
|
||||
export const MIN_REFERENCE_PERIODS = 3;
|
||||
|
||||
export type ActivityPeriod = "week" | "month";
|
||||
export type RatedMetric = "steps" | "activeMinutes" | "load";
|
||||
export type DayLevel =
|
||||
"none" | "unrated" | "low" | "below" | "usual" | "above" | "high";
|
||||
|
||||
/** One local day of raw signals, as the reader assembles it. */
|
||||
export interface ActivityDayInput {
|
||||
/** Local calendar day, `YYYY-MM-DD`. */
|
||||
date: string;
|
||||
steps: number | null;
|
||||
exerciseMinutes: number | null;
|
||||
workoutMinutes: number;
|
||||
workoutCount: number;
|
||||
workoutsBySport: Record<string, number>;
|
||||
load: number | null;
|
||||
}
|
||||
|
||||
export interface BaselineStats {
|
||||
median: number;
|
||||
p10: number;
|
||||
p25: number;
|
||||
p75: number;
|
||||
p90: number;
|
||||
/** Days with a value the stats were computed from. */
|
||||
days: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-interpolation quantile (the R-7 / spreadsheet `PERCENTILE.INC`
|
||||
* definition) over an ascending-sorted array. `q` in [0, 1].
|
||||
*/
|
||||
export function quantileSorted(sorted: readonly number[], q: number): number {
|
||||
if (sorted.length === 0) return Number.NaN;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const pos = (sorted.length - 1) * q;
|
||||
const lo = Math.floor(pos);
|
||||
const hi = Math.ceil(pos);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
||||
}
|
||||
|
||||
export function median(values: readonly number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return quantileSorted(sorted, 0.5);
|
||||
}
|
||||
|
||||
export function baselineStats(values: readonly number[]): BaselineStats | null {
|
||||
if (values.length < MIN_BASELINE_DAYS) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return {
|
||||
median: quantileSorted(sorted, 0.5),
|
||||
p10: quantileSorted(sorted, 0.1),
|
||||
p25: quantileSorted(sorted, 0.25),
|
||||
p75: quantileSorted(sorted, 0.75),
|
||||
p90: quantileSorted(sorted, 0.9),
|
||||
days: sorted.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where one day's value sits in the person's own spread. */
|
||||
export function dayLevel(
|
||||
value: number | null,
|
||||
baseline: BaselineStats | null,
|
||||
): DayLevel {
|
||||
if (value === null) return "none";
|
||||
if (!baseline) return "unrated";
|
||||
if (value < baseline.p10) return "low";
|
||||
if (value < baseline.p25) return "below";
|
||||
if (value > baseline.p90) return "high";
|
||||
if (value > baseline.p75) return "above";
|
||||
return "usual";
|
||||
}
|
||||
|
||||
/** The active-minutes rule from the header: the larger, never the sum. */
|
||||
export function activeMinutesOf(
|
||||
day: ActivityDayInput,
|
||||
hasActivityData: boolean,
|
||||
): number | null {
|
||||
if (day.exerciseMinutes !== null) {
|
||||
return Math.max(day.exerciseMinutes, day.workoutMinutes);
|
||||
}
|
||||
if (day.workoutMinutes > 0) return day.workoutMinutes;
|
||||
return hasActivityData ? 0 : null;
|
||||
}
|
||||
|
||||
export function metricValue(
|
||||
day: ActivityDayInput,
|
||||
metric: RatedMetric,
|
||||
hasActivityData: boolean,
|
||||
): number | null {
|
||||
switch (metric) {
|
||||
case "steps":
|
||||
return day.steps;
|
||||
case "activeMinutes":
|
||||
return activeMinutesOf(day, hasActivityData);
|
||||
case "load":
|
||||
return day.load;
|
||||
}
|
||||
}
|
||||
|
||||
/** Monday on or before `date` (the calendar's weeks run Monday to Sunday). */
|
||||
export function weekStartOf(date: string): string {
|
||||
const weekday = weekdayOfDateKey(date); // 0 = Sunday
|
||||
return shiftDateKey(date, -((weekday + 6) % 7));
|
||||
}
|
||||
|
||||
export function monthStartOf(date: string): string {
|
||||
return `${date.slice(0, 7)}-01`;
|
||||
}
|
||||
|
||||
export function daysInMonth(monthStart: string): number {
|
||||
const [y, m] = monthStart.split("-").map(Number);
|
||||
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
/** First day of the month `delta` months from `monthStart`. */
|
||||
export function shiftMonth(monthStart: string, delta: number): string {
|
||||
const [y, m] = monthStart.split("-").map(Number);
|
||||
const index = y * 12 + (m - 1) + delta;
|
||||
const year = Math.floor(index / 12);
|
||||
const month = (index % 12) + 1;
|
||||
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`;
|
||||
}
|
||||
|
||||
export interface PeriodBounds {
|
||||
period: ActivityPeriod;
|
||||
/** First and last day of the period, inclusive. */
|
||||
start: string;
|
||||
end: string;
|
||||
/** The grid the calendar draws: whole weeks around the period. */
|
||||
gridStart: string;
|
||||
gridEnd: string;
|
||||
/** The earliest day any comparison reads. */
|
||||
readStart: string;
|
||||
}
|
||||
|
||||
export function periodBounds(
|
||||
period: ActivityPeriod,
|
||||
anchor: string,
|
||||
): PeriodBounds {
|
||||
if (period === "week") {
|
||||
const start = weekStartOf(anchor);
|
||||
const end = shiftDateKey(start, 6);
|
||||
return {
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
gridStart: start,
|
||||
gridEnd: end,
|
||||
readStart: shiftDateKey(
|
||||
start,
|
||||
-Math.max(BASELINE_DAYS, REFERENCE_WEEKS * 7),
|
||||
),
|
||||
};
|
||||
}
|
||||
const start = monthStartOf(anchor);
|
||||
const end = shiftDateKey(start, daysInMonth(start) - 1);
|
||||
const gridStart = weekStartOf(start);
|
||||
const gridEnd = shiftDateKey(weekStartOf(end), 6);
|
||||
// Date keys order lexically, so the earliest is the smallest string.
|
||||
const readStart = [
|
||||
shiftMonth(start, -REFERENCE_MONTHS),
|
||||
shiftDateKey(start, -BASELINE_DAYS),
|
||||
gridStart,
|
||||
].sort()[0];
|
||||
return { period, start, end, gridStart, gridEnd, readStart };
|
||||
}
|
||||
|
||||
/** Every day key from `from` to `to`, inclusive. */
|
||||
export function dayRange(from: string, to: string): string[] {
|
||||
const n = daysBetweenDateKeys(from, to);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i <= n; i++) out.push(shiftDateKey(from, i));
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
/** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */
|
||||
value: number | null;
|
||||
/** Days of the period that carry a value. */
|
||||
daysWithData: number;
|
||||
/** The usual full period, or null when there is not enough history. */
|
||||
usual: number | null;
|
||||
/** The usual figure for the days elapsed so far (equals `usual` for a finished period). */
|
||||
usualToDate: number | null;
|
||||
/** Earlier periods the usual was computed from. */
|
||||
referencePeriods: number;
|
||||
}
|
||||
|
||||
export type SummaryMetric = RatedMetric | "workouts";
|
||||
|
||||
function aggregate(
|
||||
values: ReadonlyArray<number | null>,
|
||||
mode: "sum" | "mean",
|
||||
): { value: number | null; n: number } {
|
||||
const present = values.filter((v): v is number => v !== null);
|
||||
if (present.length === 0) return { value: null, n: 0 };
|
||||
const total = present.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
value: mode === "sum" ? total : total / present.length,
|
||||
n: present.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The earlier periods a period is compared with, as inclusive day ranges. */
|
||||
export function referencePeriods(
|
||||
bounds: Pick<PeriodBounds, "period" | "start">,
|
||||
): Array<{ start: string; end: string; length: number }> {
|
||||
const out: Array<{ start: string; end: string; length: number }> = [];
|
||||
if (bounds.period === "week") {
|
||||
for (let i = 1; i <= REFERENCE_WEEKS; i++) {
|
||||
const start = shiftDateKey(bounds.start, -7 * i);
|
||||
out.push({ start, end: shiftDateKey(start, 6), length: 7 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (let i = 1; i <= REFERENCE_MONTHS; i++) {
|
||||
const start = shiftMonth(bounds.start, -i);
|
||||
const length = daysInMonth(start);
|
||||
out.push({ start, end: shiftDateKey(start, length - 1), length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function comparePeriod(input: {
|
||||
bounds: Pick<PeriodBounds, "period" | "start" | "end">;
|
||||
/** The last day that has happened (today in the person's zone). */
|
||||
today: string;
|
||||
valueOf: (date: string) => number | null;
|
||||
mode: "sum" | "mean";
|
||||
}): PeriodComparison {
|
||||
const { bounds, today, valueOf, mode } = input;
|
||||
const lastCounted = bounds.end < today ? bounds.end : today;
|
||||
const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1;
|
||||
const elapsed =
|
||||
lastCounted < bounds.start
|
||||
? 0
|
||||
: daysBetweenDateKeys(bounds.start, lastCounted) + 1;
|
||||
const current =
|
||||
elapsed === 0
|
||||
? { value: null, n: 0 }
|
||||
: aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode);
|
||||
|
||||
// Each earlier period becomes a per-day rate (sum) or a mean (mean), so a
|
||||
// 28-day February and a 31-day March compare on the same footing.
|
||||
const rates: number[] = [];
|
||||
for (const ref of referencePeriods(bounds)) {
|
||||
const agg = aggregate(dayRange(ref.start, ref.end).map(valueOf), mode);
|
||||
if (agg.value === null) continue;
|
||||
rates.push(mode === "sum" ? agg.value / ref.length : agg.value);
|
||||
}
|
||||
const usualRate =
|
||||
rates.length >= MIN_REFERENCE_PERIODS ? median(rates) : null;
|
||||
const usual =
|
||||
usualRate === null ? null : mode === "sum" ? usualRate * length : usualRate;
|
||||
const usualToDate =
|
||||
usualRate === null
|
||||
? null
|
||||
: mode === "sum"
|
||||
? usualRate * elapsed
|
||||
: usualRate;
|
||||
return {
|
||||
value: current.value,
|
||||
daysWithData: current.n,
|
||||
usual,
|
||||
usualToDate: elapsed === 0 ? null : usualToDate,
|
||||
referencePeriods: rates.length,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GoalProgress {
|
||||
key: ActivityGoalKey;
|
||||
target: number;
|
||||
value: number;
|
||||
met: boolean;
|
||||
}
|
||||
|
||||
/** Which goals apply to which period, and which figure each one reads. */
|
||||
const GOALS_BY_PERIOD: Record<
|
||||
ActivityPeriod,
|
||||
Array<{ key: ActivityGoalKey; metric: SummaryMetric }>
|
||||
> = {
|
||||
week: [
|
||||
{ key: "workoutsPerWeek", metric: "workouts" },
|
||||
{ key: "activeMinutesPerWeek", metric: "activeMinutes" },
|
||||
{ key: "stepsPerWeek", metric: "steps" },
|
||||
],
|
||||
month: [{ key: "stepsPerMonth", metric: "steps" }],
|
||||
};
|
||||
|
||||
export function goalProgress(
|
||||
period: ActivityPeriod,
|
||||
goals: ActivityGoals,
|
||||
totals: Partial<Record<SummaryMetric, number | null>>,
|
||||
): GoalProgress[] {
|
||||
const out: GoalProgress[] = [];
|
||||
for (const { key, metric } of GOALS_BY_PERIOD[period]) {
|
||||
const target = goals[key];
|
||||
if (target === null) continue;
|
||||
const value = totals[metric] ?? 0;
|
||||
out.push({ key, target, value, met: value >= target });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: string;
|
||||
inPeriod: boolean;
|
||||
future: boolean;
|
||||
steps: number | null;
|
||||
activeMinutes: number | null;
|
||||
workoutCount: number;
|
||||
workoutsBySport: Record<string, number>;
|
||||
load: number | null;
|
||||
level: Record<RatedMetric, DayLevel>;
|
||||
}
|
||||
|
||||
export interface CalendarWeekRow {
|
||||
start: string;
|
||||
end: string;
|
||||
steps: number | null;
|
||||
activeMinutes: number | null;
|
||||
workouts: number;
|
||||
/** The weekly goals, met or not, for a week that has started. */
|
||||
goals: GoalProgress[];
|
||||
}
|
||||
|
||||
export interface ActivityCalendar {
|
||||
period: ActivityPeriod;
|
||||
start: string;
|
||||
end: string;
|
||||
gridStart: string;
|
||||
gridEnd: string;
|
||||
today: string;
|
||||
days: CalendarDay[];
|
||||
weeks: CalendarWeekRow[];
|
||||
baseline: Record<RatedMetric, BaselineStats | null>;
|
||||
summary: Record<SummaryMetric, PeriodComparison>;
|
||||
sports: Array<{ sportType: string; count: number }>;
|
||||
goals: ActivityGoals;
|
||||
goalProgress: GoalProgress[];
|
||||
available: Record<SummaryMetric, boolean>;
|
||||
previous: string;
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the calendar from per-day inputs covering at least
|
||||
* `bounds.readStart`..`bounds.gridEnd`. Days missing from `input` count as
|
||||
* days without any reading.
|
||||
*/
|
||||
export function buildActivityCalendar(args: {
|
||||
bounds: PeriodBounds;
|
||||
today: string;
|
||||
input: readonly ActivityDayInput[];
|
||||
goals: ActivityGoals;
|
||||
}): ActivityCalendar {
|
||||
const { bounds, today, input, goals } = args;
|
||||
const byDate = new Map(input.map((d) => [d.date, d]));
|
||||
const empty = (date: string): ActivityDayInput => ({
|
||||
date,
|
||||
steps: null,
|
||||
exerciseMinutes: null,
|
||||
workoutMinutes: 0,
|
||||
workoutCount: 0,
|
||||
workoutsBySport: {},
|
||||
load: null,
|
||||
});
|
||||
const dayOf = (date: string) => byDate.get(date) ?? empty(date);
|
||||
|
||||
// The first day with any activity signal: from there on, a day without
|
||||
// workouts or exercise minutes is a day of 0 active minutes, not a gap.
|
||||
const firstActivityDay =
|
||||
input
|
||||
.filter(
|
||||
(d) =>
|
||||
d.exerciseMinutes !== null ||
|
||||
d.workoutCount > 0 ||
|
||||
d.workoutMinutes > 0,
|
||||
)
|
||||
.map((d) => d.date)
|
||||
.sort()[0] ?? null;
|
||||
const hasActivityOn = (date: string) =>
|
||||
firstActivityDay !== null && date >= firstActivityDay && date <= today;
|
||||
|
||||
const valueOf = (metric: SummaryMetric) => (date: string) => {
|
||||
if (date > today) return null;
|
||||
const day = dayOf(date);
|
||||
if (metric === "workouts")
|
||||
return hasActivityOn(date) ? day.workoutCount : null;
|
||||
return metricValue(day, metric, hasActivityOn(date));
|
||||
};
|
||||
|
||||
const baselineFrom = shiftDateKey(bounds.start, -BASELINE_DAYS);
|
||||
const baselineTo = shiftDateKey(bounds.start, -1);
|
||||
const baselineDays = dayRange(baselineFrom, baselineTo);
|
||||
const ratedMetrics: RatedMetric[] = ["steps", "activeMinutes", "load"];
|
||||
const baseline = Object.fromEntries(
|
||||
ratedMetrics.map((metric) => [
|
||||
metric,
|
||||
baselineStats(
|
||||
baselineDays
|
||||
.map(valueOf(metric))
|
||||
.filter((v): v is number => v !== null),
|
||||
),
|
||||
]),
|
||||
) as Record<RatedMetric, BaselineStats | null>;
|
||||
|
||||
const days: CalendarDay[] = dayRange(bounds.gridStart, bounds.gridEnd).map(
|
||||
(date) => {
|
||||
const day = dayOf(date);
|
||||
const future = date > today;
|
||||
const steps = future ? null : day.steps;
|
||||
const activeMinutes = future
|
||||
? null
|
||||
: activeMinutesOf(day, hasActivityOn(date));
|
||||
const load = future ? null : day.load;
|
||||
return {
|
||||
date,
|
||||
inPeriod: date >= bounds.start && date <= bounds.end,
|
||||
future,
|
||||
steps,
|
||||
activeMinutes,
|
||||
workoutCount: future ? 0 : day.workoutCount,
|
||||
workoutsBySport: future ? {} : day.workoutsBySport,
|
||||
load,
|
||||
level: {
|
||||
steps: dayLevel(steps, baseline.steps),
|
||||
activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes),
|
||||
load: dayLevel(load, baseline.load),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const summary: Record<SummaryMetric, PeriodComparison> = {
|
||||
steps: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("steps"),
|
||||
mode: "sum",
|
||||
}),
|
||||
activeMinutes: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("activeMinutes"),
|
||||
mode: "sum",
|
||||
}),
|
||||
workouts: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("workouts"),
|
||||
mode: "sum",
|
||||
}),
|
||||
load: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("load"),
|
||||
mode: "mean",
|
||||
}),
|
||||
};
|
||||
|
||||
const sportCounts = new Map<string, number>();
|
||||
for (const date of dayRange(bounds.start, bounds.end)) {
|
||||
if (date > today) break;
|
||||
for (const [sport, count] of Object.entries(dayOf(date).workoutsBySport)) {
|
||||
sportCounts.set(sport, (sportCounts.get(sport) ?? 0) + count);
|
||||
}
|
||||
}
|
||||
const sports = [...sportCounts]
|
||||
.map(([sportType, count]) => ({ sportType, count }))
|
||||
.sort(
|
||||
(a, b) => b.count - a.count || a.sportType.localeCompare(b.sportType),
|
||||
);
|
||||
|
||||
const weeks: CalendarWeekRow[] = [];
|
||||
for (
|
||||
let start = bounds.gridStart;
|
||||
start <= bounds.gridEnd;
|
||||
start = shiftDateKey(start, 7)
|
||||
) {
|
||||
const end = shiftDateKey(start, 6);
|
||||
const range = dayRange(start, end);
|
||||
const sum = (metric: SummaryMetric) =>
|
||||
aggregate(range.map(valueOf(metric)), "sum").value;
|
||||
const row = {
|
||||
start,
|
||||
end,
|
||||
steps: sum("steps"),
|
||||
activeMinutes: sum("activeMinutes"),
|
||||
workouts: sum("workouts") ?? 0,
|
||||
};
|
||||
weeks.push({
|
||||
...row,
|
||||
goals:
|
||||
start > today
|
||||
? []
|
||||
: goalProgress("week", goals, {
|
||||
steps: row.steps,
|
||||
activeMinutes: row.activeMinutes,
|
||||
workouts: row.workouts,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const progress = goalProgress(bounds.period, goals, {
|
||||
steps: summary.steps.value,
|
||||
activeMinutes: summary.activeMinutes.value,
|
||||
workouts: summary.workouts.value,
|
||||
});
|
||||
|
||||
const anyIn = (pick: (d: ActivityDayInput) => boolean) => input.some(pick);
|
||||
const available: Record<SummaryMetric, boolean> = {
|
||||
steps: anyIn((d) => d.steps !== null),
|
||||
activeMinutes: firstActivityDay !== null,
|
||||
workouts: anyIn((d) => d.workoutCount > 0),
|
||||
load: anyIn((d) => d.load !== null),
|
||||
};
|
||||
|
||||
const previous =
|
||||
bounds.period === "week"
|
||||
? shiftDateKey(bounds.start, -7)
|
||||
: shiftMonth(bounds.start, -1);
|
||||
const nextStart =
|
||||
bounds.period === "week"
|
||||
? shiftDateKey(bounds.start, 7)
|
||||
: shiftMonth(bounds.start, 1);
|
||||
|
||||
return {
|
||||
period: bounds.period,
|
||||
start: bounds.start,
|
||||
end: bounds.end,
|
||||
gridStart: bounds.gridStart,
|
||||
gridEnd: bounds.gridEnd,
|
||||
today,
|
||||
days,
|
||||
weeks,
|
||||
baseline,
|
||||
summary,
|
||||
sports,
|
||||
goals,
|
||||
goalProgress: progress,
|
||||
available,
|
||||
previous,
|
||||
next: nextStart > today ? null : nextStart,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Database side of the activity calendar (D1036): per local day, the step
|
||||
* total, the exercise minutes, the training load and the workouts, over the
|
||||
* window `periodBounds` asks for. The arithmetic lives in `calendar-math.ts`.
|
||||
*
|
||||
* Steps follow the same rule as the steps chart: each UTC day resolves to the
|
||||
* source the person's priority ladder picks (`seriesRowsFrom`, the helper the
|
||||
* series route reads through), and the picked rows are then cut at the
|
||||
* person's own midnight. A calendar day and the chart's day therefore show
|
||||
* the same number. Workouts go through the cross-source collapse the workout
|
||||
* list applies, so a session recorded by two devices counts once.
|
||||
*
|
||||
* Bounded: four aggregate queries returning at most one row per day of the
|
||||
* window (about 220 days for a month), plus the workouts of that window.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import type { ActivityDayInput } from "./calendar-math";
|
||||
|
||||
/**
|
||||
* Training-load types in the order the calendar prefers them: a device's own
|
||||
* daily figure first (WHOOP day strain, Polar cardio load), HealthLog's
|
||||
* computed strain score last. The first type with any reading in the window
|
||||
* is the one shown; two scales are never mixed on one calendar.
|
||||
*/
|
||||
export const LOAD_TYPES: readonly MeasurementType[] = [
|
||||
"DAY_STRAIN",
|
||||
"CARDIO_LOAD",
|
||||
"STRAIN_SCORE",
|
||||
];
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
interface DayAggregateRow {
|
||||
day: string;
|
||||
total: number;
|
||||
/** Readings that day; the load is their plain mean (one score a day, as a rule). */
|
||||
n: number;
|
||||
}
|
||||
|
||||
async function dailyAggregate(
|
||||
userId: string,
|
||||
tz: string,
|
||||
priorityJson: unknown,
|
||||
type: MeasurementType,
|
||||
from: Date,
|
||||
to: Date,
|
||||
now: Date,
|
||||
): Promise<Map<string, DayAggregateRow>> {
|
||||
// `seriesRowsFrom` scopes its canonical pick to a trailing window counted
|
||||
// from now; it is widened by a day so the edge day's source is picked from
|
||||
// all of its readings, and the outer filter trims to the exact window.
|
||||
const days = Math.min(
|
||||
3649,
|
||||
Math.max(1, Math.ceil((now.getTime() - from.getTime()) / DAY_MS) + 1),
|
||||
);
|
||||
const rows = await prisma.$queryRawUnsafe<DayAggregateRow[]>(
|
||||
`
|
||||
SELECT
|
||||
to_char(
|
||||
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $2),
|
||||
'YYYY-MM-DD'
|
||||
) AS day,
|
||||
SUM(m."value")::double precision AS total,
|
||||
COUNT(*)::int AS n
|
||||
FROM ${seriesRowsFrom(priorityJson, type, days)}
|
||||
WHERE m."measured_at" >= $3
|
||||
AND m."measured_at" < $4
|
||||
GROUP BY 1
|
||||
`,
|
||||
userId,
|
||||
tz,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
return new Map(rows.map((r) => [r.day, r]));
|
||||
}
|
||||
|
||||
function meanOf(row: DayAggregateRow | undefined): number | null {
|
||||
return row && row.n > 0 ? row.total / row.n : null;
|
||||
}
|
||||
|
||||
export interface ActivityReadResult {
|
||||
days: ActivityDayInput[];
|
||||
loadType: MeasurementType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `fromKey`..`toKey` (inclusive local days) for one account.
|
||||
* `today` caps the read: nothing after the person's today is fetched.
|
||||
*/
|
||||
export async function readActivityDays(args: {
|
||||
userId: string;
|
||||
tz: string;
|
||||
fromKey: string;
|
||||
toKey: string;
|
||||
now?: Date;
|
||||
includeWorkouts: boolean;
|
||||
}): Promise<ActivityReadResult> {
|
||||
const { userId, tz, fromKey, toKey, includeWorkouts } = args;
|
||||
const now = args.now ?? new Date();
|
||||
const from = startOfLocalDayKey(fromKey, tz);
|
||||
const to = startOfLocalDayKey(shiftDateKey(toKey, 1), tz);
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
|
||||
const [steps, exercise, loads, workoutRows] = await Promise.all([
|
||||
dailyAggregate(userId, tz, priorityJson, "ACTIVITY_STEPS", from, to, now),
|
||||
dailyAggregate(userId, tz, priorityJson, "EXERCISE_MINUTES", from, to, now),
|
||||
Promise.all(
|
||||
LOAD_TYPES.map((type) =>
|
||||
dailyAggregate(userId, tz, priorityJson, type, from, to, now),
|
||||
),
|
||||
),
|
||||
includeWorkouts
|
||||
? prisma.workout.findMany({
|
||||
where: { userId, startedAt: { gte: from, lt: to } },
|
||||
select: {
|
||||
startedAt: true,
|
||||
durationSec: true,
|
||||
sportType: true,
|
||||
source: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
metadata: true,
|
||||
},
|
||||
orderBy: { startedAt: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const loadIndex = loads.findIndex((m) => m.size > 0);
|
||||
const load = loadIndex >= 0 ? loads[loadIndex] : null;
|
||||
|
||||
const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson);
|
||||
const workoutsByDay = new Map<
|
||||
string,
|
||||
{ minutes: number; count: number; bySport: Record<string, number> }
|
||||
>();
|
||||
for (const w of workouts) {
|
||||
const key = userDayKey(w.startedAt, tz);
|
||||
const slot = workoutsByDay.get(key) ?? {
|
||||
minutes: 0,
|
||||
count: 0,
|
||||
bySport: {},
|
||||
};
|
||||
slot.minutes += Math.max(0, w.durationSec) / 60;
|
||||
slot.count += 1;
|
||||
slot.bySport[w.sportType] = (slot.bySport[w.sportType] ?? 0) + 1;
|
||||
workoutsByDay.set(key, slot);
|
||||
}
|
||||
|
||||
const keys = new Set<string>([
|
||||
...steps.keys(),
|
||||
...exercise.keys(),
|
||||
...(load ? load.keys() : []),
|
||||
...workoutsByDay.keys(),
|
||||
]);
|
||||
const days: ActivityDayInput[] = [...keys]
|
||||
.filter((k) => k >= fromKey && k <= toKey)
|
||||
.sort()
|
||||
.map((date) => {
|
||||
const w = workoutsByDay.get(date);
|
||||
return {
|
||||
date,
|
||||
steps: steps.get(date)?.total ?? null,
|
||||
exerciseMinutes: exercise.get(date)?.total ?? null,
|
||||
workoutMinutes: w ? Math.round(w.minutes) : 0,
|
||||
workoutCount: w?.count ?? 0,
|
||||
workoutsBySport: w?.bySport ?? {},
|
||||
load: meanOf(load?.get(date)),
|
||||
};
|
||||
});
|
||||
|
||||
return { days, loadType: loadIndex >= 0 ? LOAD_TYPES[loadIndex] : null };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The activity-goal keys and their bounds, without a schema library, so the
|
||||
* goals sheet can read them in the browser. The schema that enforces them
|
||||
* on the server is `src/lib/validations/activity-goals.ts`.
|
||||
*/
|
||||
export const ACTIVITY_GOAL_KEYS = [
|
||||
"workoutsPerWeek",
|
||||
"activeMinutesPerWeek",
|
||||
"stepsPerWeek",
|
||||
"stepsPerMonth",
|
||||
] as const;
|
||||
|
||||
export type ActivityGoalKey = (typeof ACTIVITY_GOAL_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Wide on purpose: they refuse typing mistakes (a goal of 5 000 000 steps a
|
||||
* week), they do not judge what a sensible goal is.
|
||||
*/
|
||||
export const ACTIVITY_GOAL_BOUNDS: Record<
|
||||
ActivityGoalKey,
|
||||
{ min: number; max: number }
|
||||
> = {
|
||||
workoutsPerWeek: { min: 1, max: 28 },
|
||||
activeMinutesPerWeek: { min: 10, max: 5000 },
|
||||
stepsPerWeek: { min: 1000, max: 700_000 },
|
||||
stepsPerMonth: { min: 1000, max: 3_000_000 },
|
||||
};
|
||||
@@ -489,6 +489,7 @@ export const USER_RESET = {
|
||||
moodTagLayoutJson: Prisma.DbNull,
|
||||
documentsLayoutJson: Prisma.DbNull,
|
||||
moduleListLayoutJson: Prisma.DbNull,
|
||||
activityGoalsJson: Prisma.DbNull,
|
||||
reportSelectionJson: Prisma.DbNull,
|
||||
modulePreferencesJson: Prisma.DbNull,
|
||||
healthScoreConfigJson: Prisma.DbNull,
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
import { activityGoalsSchema } from "@/lib/validations/activity-goals";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import {
|
||||
glucoseUnitPatchSchema,
|
||||
@@ -267,6 +268,37 @@ const thresholds: SettingCodec = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The activity goals, goal by goal. A goal outside its bounds, or a key this
|
||||
* release does not know, is dropped and named; the valid goals beside it
|
||||
* still come back.
|
||||
*/
|
||||
const activityGoals: SettingCodec = {
|
||||
write: (row) => ({ activityGoalsJson: row.activityGoalsJson ?? null }),
|
||||
read: (entry) => {
|
||||
if (!has(entry, "activityGoalsJson")) return undefined;
|
||||
const value = entry.activityGoalsJson;
|
||||
if (value === null) return Prisma.DbNull;
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
return new Refused("activityGoalsJson");
|
||||
}
|
||||
const kept: Record<string, number> = {};
|
||||
const refused: string[] = [];
|
||||
for (const [key, goal] of Object.entries(value)) {
|
||||
const parsed = activityGoalsSchema.safeParse({ [key]: goal });
|
||||
if (!parsed.success) {
|
||||
refused.push(`activityGoalsJson.${key}`);
|
||||
} else if (typeof goal === "number") {
|
||||
kept[key] = goal;
|
||||
}
|
||||
}
|
||||
return new PartlyRefused(
|
||||
Object.keys(kept).length === 0 ? Prisma.DbNull : kept,
|
||||
refused,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const injectionSites = Object.values(InjectionSite) as string[];
|
||||
|
||||
/** The injection sites to avoid everywhere; an unknown site is dropped. */
|
||||
@@ -505,6 +537,7 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
documentsLayoutJson: json("documentsLayoutJson"),
|
||||
moduleListLayoutJson: json("moduleListLayoutJson"),
|
||||
previewFeaturesJson: json("previewFeaturesJson"),
|
||||
activityGoalsJson: activityGoals,
|
||||
reportSelectionJson: json("reportSelectionJson"),
|
||||
globalExcludedInjectionSites: excludedSites,
|
||||
healthKitConfigJson: json("healthKitConfigJson"),
|
||||
|
||||
@@ -1019,6 +1019,7 @@ export const USER_COLUMN_BACKUP_CLASS = {
|
||||
documentsLayoutJson: "SETTING",
|
||||
moduleListLayoutJson: "SETTING",
|
||||
previewFeaturesJson: "SETTING",
|
||||
activityGoalsJson: "SETTING",
|
||||
reportSelectionJson: "SETTING",
|
||||
globalExcludedInjectionSites: "SETTING",
|
||||
healthKitConfigJson: "SETTING",
|
||||
|
||||
@@ -42,6 +42,9 @@ export const PROXY_BYPASS_ROUTES: readonly string[] = [
|
||||
// Bulk JSON / CSV imports, 16 MB.
|
||||
"/api/import",
|
||||
"/api/import/csv",
|
||||
// Lab and medication CSV imports (v1.44), streamed, 16 MB.
|
||||
"/api/import/labs-csv",
|
||||
"/api/import/medications-csv",
|
||||
"/api/medications/intake/dose-history-import",
|
||||
// Lab scan upload, 12 MB.
|
||||
"/api/labs/ocr/extract",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
labsCsvQuerySchema,
|
||||
medicationsCsvQuerySchema,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
import {
|
||||
RECORD_IMPORT_TEMPLATES,
|
||||
guessFormat,
|
||||
guessMapping,
|
||||
readHead,
|
||||
} from "../csv-mapping";
|
||||
import { normaliseLabRow } from "../labs-csv";
|
||||
import { normaliseMedicationRow } from "../medications-csv";
|
||||
|
||||
describe("guessMapping", () => {
|
||||
it("reads English and German headers, required fields first", () => {
|
||||
expect(
|
||||
guessMapping(
|
||||
["Analyte", "Result", "Unit", "Date", "Reference range"],
|
||||
"labs",
|
||||
),
|
||||
).toEqual({
|
||||
analyte: 0,
|
||||
value: 1,
|
||||
unit: 2,
|
||||
date: 3,
|
||||
referenceRange: 4,
|
||||
});
|
||||
expect(
|
||||
guessMapping(
|
||||
["Datum", "Laborwert", "Wert", "Einheit", "Referenzbereich"],
|
||||
"labs",
|
||||
),
|
||||
).toMatchObject({
|
||||
date: 0,
|
||||
analyte: 1,
|
||||
value: 2,
|
||||
unit: 3,
|
||||
referenceRange: 4,
|
||||
});
|
||||
expect(
|
||||
guessMapping(
|
||||
["Medikament", "Dosis", "Uhrzeiten", "Bei Bedarf", "Beginn", "Ende"],
|
||||
"medications",
|
||||
),
|
||||
).toEqual({
|
||||
name: 0,
|
||||
dose: 1,
|
||||
times: 2,
|
||||
asNeeded: 3,
|
||||
startsOn: 4,
|
||||
endsOn: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a column to one field only and leaves unknown headers alone", () => {
|
||||
const m = guessMapping(["name", "foo", "bar"], "labs");
|
||||
expect(m).toEqual({ analyte: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("guessFormat", () => {
|
||||
it("sees a German file", () => {
|
||||
const head = readHead("Analyt;Wert;Datum\nHbA1c;5,4;01.03.2025\n");
|
||||
expect(head.delimiter).toBe(";");
|
||||
expect(guessFormat(head, 2)).toEqual({
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
});
|
||||
});
|
||||
it("sees a US file by a day above twelve", () => {
|
||||
const head = readHead("a,b,date\nx,5.4,03/15/2025\n");
|
||||
expect(guessFormat(head, 2)).toEqual({ decimal: "dot", dateOrder: "mdy" });
|
||||
});
|
||||
it("sees ISO", () => {
|
||||
expect(guessFormat(readHead("a,date\nx,2025-03-01\n"), 1).dateOrder).toBe(
|
||||
"iso",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the templates", () => {
|
||||
it("import cleanly with the mapping the screen suggests", () => {
|
||||
const labs = readHead(RECORD_IMPORT_TEMPLATES.labs, 10);
|
||||
const labMap = guessMapping(labs.rows[0], "labs");
|
||||
const labQuery = labsCsvQuerySchema.parse(
|
||||
Object.fromEntries(
|
||||
Object.entries(labMap).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
);
|
||||
for (const [i, fields] of labs.rows.slice(1).entries()) {
|
||||
expect(normaliseLabRow({ line: i + 2, fields }, labQuery, "UTC").ok).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
const meds = readHead(RECORD_IMPORT_TEMPLATES.medications, 10);
|
||||
const medMap = guessMapping(meds.rows[0], "medications");
|
||||
const medQuery = medicationsCsvQuerySchema.parse(
|
||||
Object.fromEntries(
|
||||
Object.entries(medMap).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
);
|
||||
for (const [i, fields] of meds.rows.slice(1).entries()) {
|
||||
expect(
|
||||
normaliseMedicationRow({ line: i + 2, fields }, medQuery, "UTC").ok,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* The streaming CSV reader: the spreadsheet shapes, every chunk boundary, a
|
||||
* fuzzed round trip, and a large file read with bounded memory.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CsvTokenizer,
|
||||
CsvTooLargeError,
|
||||
MAX_COLUMNS,
|
||||
MAX_FIELD_CHARS,
|
||||
detectDelimiter,
|
||||
readCsvRecords,
|
||||
textToStream,
|
||||
type CsvRecord,
|
||||
} from "../csv-stream";
|
||||
|
||||
function all(text: string, delimiter: "auto" | "," | ";" | "\t" = "auto") {
|
||||
const t = new CsvTokenizer(delimiter);
|
||||
return [...t.push(text), ...t.end()];
|
||||
}
|
||||
|
||||
/** Feed the text split at every position, one boundary at a time. */
|
||||
function everySplit(text: string): CsvRecord[][] {
|
||||
const out: CsvRecord[][] = [];
|
||||
for (let i = 0; i <= text.length; i++) {
|
||||
const t = new CsvTokenizer();
|
||||
out.push([
|
||||
...t.push(text.slice(0, i)),
|
||||
...t.push(text.slice(i)),
|
||||
...t.end(),
|
||||
]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const fields = (records: CsvRecord[]) => records.map((r) => r.fields);
|
||||
|
||||
describe("detectDelimiter", () => {
|
||||
it.each([
|
||||
["a,b,c", ","],
|
||||
["a;b;c", ";"],
|
||||
["a\tb\tc", "\t"],
|
||||
['"x;y",b,c', ","],
|
||||
["single", ","],
|
||||
] as const)("%j → %j", (line, expected) => {
|
||||
expect(detectDelimiter(line)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CsvTokenizer", () => {
|
||||
it("drops a byte-order mark and reads a semicolon file with decimal commas", () => {
|
||||
expect(fields(all("Analyt;Wert;Einheit\nHbA1c;5,4;%\n"))).toEqual([
|
||||
["Analyt", "Wert", "Einheit"],
|
||||
["HbA1c", "5,4", "%"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps separators, quotes and line breaks inside quoted fields", () => {
|
||||
const text = 'a,b\n"x, y","say ""hi""\nthere"\n';
|
||||
expect(fields(all(text))).toEqual([
|
||||
["a", "b"],
|
||||
["x, y", 'say "hi"\nthere'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads LF, CRLF and lone CR line ends and skips blank lines", () => {
|
||||
expect(fields(all("a,b\r\n1,2\r3,4\n\n\r\n5,6"))).toEqual([
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
["5", "6"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports the physical line each record starts on", () => {
|
||||
const records = all('h\n"multi\nline"\n\nlast\n');
|
||||
expect(records.map((r) => r.line)).toEqual([1, 2, 5]);
|
||||
});
|
||||
|
||||
it("keeps empty fields and a trailing empty field", () => {
|
||||
expect(fields(all("a,,c,\n"))).toEqual([["a", "", "c", ""]]);
|
||||
});
|
||||
|
||||
it("gives the same records whatever the chunk boundaries", () => {
|
||||
const text =
|
||||
'name;dose;"note"\r\n"Ram;ipril";"5 mg";"two\r\nlines ""q"""\r\nX;1;\r';
|
||||
const expected = fields(all(text));
|
||||
for (const records of everySplit(text)) {
|
||||
expect(fields(records)).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks a runaway field instead of growing without end", () => {
|
||||
const records = all(`a\n"${"x".repeat(MAX_FIELD_CHARS + 50)}\n`);
|
||||
expect(records[1].oversized).toBe(true);
|
||||
expect(records[1].fields[0].length).toBe(MAX_FIELD_CHARS);
|
||||
});
|
||||
|
||||
it("marks a record wider than the column cap", () => {
|
||||
const wide = Array.from({ length: MAX_COLUMNS + 5 }, (_, i) =>
|
||||
String(i),
|
||||
).join(",");
|
||||
const [record] = all(`${wide}\n`);
|
||||
expect(record.oversized).toBe(true);
|
||||
expect(record.fields).toHaveLength(MAX_COLUMNS);
|
||||
});
|
||||
});
|
||||
|
||||
/** Deterministic pseudo-random so a failure reproduces. */
|
||||
function rng(seed: number) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) & 0x7fffffff;
|
||||
return s / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
function quoteField(value: string) {
|
||||
return /[",;\t\r\n]/.test(value) || value === ""
|
||||
? `"${value.replace(/"/g, '""')}"`
|
||||
: value;
|
||||
}
|
||||
|
||||
describe("fuzzed round trip", () => {
|
||||
it("reads back exactly what was written, for random tables and chunkings", () => {
|
||||
const alphabet = [
|
||||
"a",
|
||||
"Z",
|
||||
"1",
|
||||
",",
|
||||
";",
|
||||
'"',
|
||||
"\n",
|
||||
"\r\n",
|
||||
" ",
|
||||
"ä",
|
||||
"漢",
|
||||
"\t",
|
||||
".",
|
||||
];
|
||||
const rand = rng(1215);
|
||||
for (let round = 0; round < 300; round++) {
|
||||
const delimiter = ([",", ";", "\t"] as const)[Math.floor(rand() * 3)];
|
||||
const cols = 1 + Math.floor(rand() * 5);
|
||||
const rows = 1 + Math.floor(rand() * 6);
|
||||
const table: string[][] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row: string[] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
let v = "";
|
||||
const len = Math.floor(rand() * 6);
|
||||
for (let k = 0; k < len; k++)
|
||||
v += alphabet[Math.floor(rand() * alphabet.length)];
|
||||
// A field that is all whitespace-free and quotable stays as written.
|
||||
row.push(r === 0 ? `h${c}` : v);
|
||||
}
|
||||
// A record of only empty fields is skipped by design; keep one cell.
|
||||
if (!row.some((f) => f.length > 0)) row[0] = "x";
|
||||
table.push(row);
|
||||
}
|
||||
const text =
|
||||
table
|
||||
.map((row) => row.map((f) => quoteField(f)).join(delimiter))
|
||||
.join(rand() < 0.5 ? "\n" : "\r\n") + (rand() < 0.5 ? "\n" : "");
|
||||
const t = new CsvTokenizer(delimiter);
|
||||
const out: CsvRecord[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const step = 1 + Math.floor(rand() * 7);
|
||||
out.push(...t.push(text.slice(i, i + step)));
|
||||
i += step;
|
||||
}
|
||||
out.push(...t.end());
|
||||
// Quoted line breaks come back as "\n": the reader normalises them.
|
||||
const expected = table.map((row) =>
|
||||
row.map((f) => f.replace(/\r\n/g, "\n")),
|
||||
);
|
||||
expect(fields(out), `round ${round}: ${JSON.stringify(text)}`).toEqual(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCsvRecords", () => {
|
||||
it("streams a large file record by record", async () => {
|
||||
const rows = 200_000;
|
||||
const lines = ["analyte;value;unit;date"];
|
||||
for (let i = 0; i < rows; i++)
|
||||
lines.push(`Marker ${i % 50};${i % 9},${i % 10};mg/dL;01.02.2024`);
|
||||
const text = lines.join("\n");
|
||||
let count = 0;
|
||||
let maxFieldLength = 0;
|
||||
for await (const record of readCsvRecords(textToStream(text, 8192), {
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
})) {
|
||||
count += 1;
|
||||
for (const f of record.fields)
|
||||
maxFieldLength = Math.max(maxFieldLength, f.length);
|
||||
}
|
||||
expect(count).toBe(rows + 1);
|
||||
expect(maxFieldLength).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("stops past the byte cap", async () => {
|
||||
const text = "a,b\n" + "1,2\n".repeat(1000);
|
||||
const consume = async () => {
|
||||
for await (const _ of readCsvRecords(textToStream(text, 100), {
|
||||
maxBytes: 500,
|
||||
})) {
|
||||
void _;
|
||||
}
|
||||
};
|
||||
await expect(consume()).rejects.toBeInstanceOf(CsvTooLargeError);
|
||||
});
|
||||
|
||||
it("decodes a multi-byte character split across chunks", async () => {
|
||||
const text = "name\nÄrztin 漢字\n";
|
||||
const out: string[] = [];
|
||||
for await (const r of readCsvRecords(textToStream(text, 1), {
|
||||
maxBytes: 1000,
|
||||
}))
|
||||
out.push(r.fields[0]);
|
||||
expect(out).toEqual(["name", "Ärztin 漢字"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCsvDate, parseLocaleNumber, parseYesNo } from "../csv-values";
|
||||
|
||||
describe("parseLocaleNumber", () => {
|
||||
it.each([
|
||||
["5.4", "dot", 5.4],
|
||||
["5,4", "comma", 5.4],
|
||||
["-0.5", "dot", -0.5],
|
||||
["1,234.5", "dot", 1234.5],
|
||||
["1.234,5", "comma", 1234.5],
|
||||
["1,234,567", "dot", 1234567],
|
||||
["1.234.567", "comma", 1234567],
|
||||
["5,4", "dot", 5.4],
|
||||
["5.4", "comma", 5.4],
|
||||
["12", "comma", 12],
|
||||
[" 7,25 ", "comma", 7.25],
|
||||
["1 234,5", "comma", 1234.5],
|
||||
[".5", "dot", 0.5],
|
||||
] as const)("%j (%s) → %s", (raw, mark, value) => {
|
||||
expect(parseLocaleNumber(raw, mark)).toEqual({ ok: true, value });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["1,234", "dot"],
|
||||
["1.234", "comma"],
|
||||
["1234,567", "dot"],
|
||||
] as const)("refuses %j (%s) as ambiguous", (raw, mark) => {
|
||||
expect(parseLocaleNumber(raw, mark)).toEqual({
|
||||
ok: false,
|
||||
reason: "ambiguous_number",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", "abc", "<0.5", "5..4", "1,2,3", "5.4.3", "1e5", "5,"])(
|
||||
"refuses %j as not a number",
|
||||
(raw) => {
|
||||
expect(parseLocaleNumber(raw, "dot").ok).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("parseCsvDate", () => {
|
||||
const berlin = { timezone: "Europe/Berlin" };
|
||||
|
||||
it("stores a date without a time on the date-only anchor", () => {
|
||||
const r = parseCsvDate("2024-02-01", undefined, {
|
||||
order: "iso",
|
||||
...berlin,
|
||||
});
|
||||
expect(r).toMatchObject({
|
||||
ok: true,
|
||||
dateKey: "2024-02-01",
|
||||
dateOnly: true,
|
||||
});
|
||||
expect(r.ok && r.instant.toISOString()).toBe("2024-02-01T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("reads a wall-clock time in the import's zone, across DST", () => {
|
||||
const winter = parseCsvDate("15.01.2024", "08:30", {
|
||||
order: "dmy",
|
||||
...berlin,
|
||||
});
|
||||
const summer = parseCsvDate("15.07.2024 08:30", undefined, {
|
||||
order: "dmy",
|
||||
...berlin,
|
||||
});
|
||||
expect(winter.ok && winter.instant.toISOString()).toBe(
|
||||
"2024-01-15T07:30:00.000Z",
|
||||
);
|
||||
expect(summer.ok && summer.instant.toISOString()).toBe(
|
||||
"2024-07-15T06:30:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("honours an explicit offset over the zone", () => {
|
||||
const r = parseCsvDate("2024-07-15T08:30:00+02:00", undefined, {
|
||||
order: "iso",
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
expect(r.ok && r.instant.toISOString()).toBe("2024-07-15T06:30:00.000Z");
|
||||
});
|
||||
|
||||
it("reads US order and refuses a cell that cannot be in it", () => {
|
||||
expect(
|
||||
parseCsvDate("02/15/2024", undefined, { order: "mdy", ...berlin }),
|
||||
).toMatchObject({ ok: true, dateKey: "2024-02-15" });
|
||||
expect(
|
||||
parseCsvDate("15/02/2024", undefined, { order: "mdy", ...berlin }),
|
||||
).toEqual({ ok: false, reason: "ambiguous_date" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["31.02.2024", "dmy", "invalid_date"],
|
||||
["01.02.24", "dmy", "ambiguous_date"],
|
||||
["24-02-01", "iso", "ambiguous_date"],
|
||||
["Feb 1 2024", "iso", "invalid_date"],
|
||||
["", "iso", "invalid_date"],
|
||||
] as const)("%j (%s) → %s", (raw, order, reason) => {
|
||||
expect(parseCsvDate(raw, undefined, { order, ...berlin })).toEqual({
|
||||
ok: false,
|
||||
reason,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an impossible time", () => {
|
||||
expect(
|
||||
parseCsvDate("2024-02-01", "25:00", { order: "iso", ...berlin }),
|
||||
).toEqual({ ok: false, reason: "invalid_time" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseYesNo", () => {
|
||||
it("reads the usual spellings and nothing else", () => {
|
||||
expect(parseYesNo("Ja")).toBe(true);
|
||||
expect(parseYesNo("no")).toBe(false);
|
||||
expect(parseYesNo("")).toBeNull();
|
||||
expect(parseYesNo("maybe")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Row normalisation of the lab and medication CSV imports (#1215): what a
|
||||
* cell may mean, and the reason a row is refused when it could mean two
|
||||
* things. Pure; the database half is in
|
||||
* `tests/integration/record-csv-import.test.ts`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createMedicationSchema } from "@/lib/validations/medication";
|
||||
import {
|
||||
labsCsvQuerySchema,
|
||||
medicationsCsvQuerySchema,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
import { normaliseLabRow, readingKeys } from "../labs-csv";
|
||||
import {
|
||||
medicationCreateBody,
|
||||
normaliseMedicationRow,
|
||||
parseTimes,
|
||||
} from "../medications-csv";
|
||||
|
||||
const NOW = new Date("2026-10-21T10:00:00.000Z");
|
||||
const labQuery = (extra: Record<string, string> = {}) =>
|
||||
labsCsvQuerySchema.parse({
|
||||
analyte: "0",
|
||||
value: "1",
|
||||
unit: "2",
|
||||
date: "3",
|
||||
referenceRange: "4",
|
||||
...extra,
|
||||
});
|
||||
const lab = (fields: string[], extra?: Record<string, string>) =>
|
||||
normaliseLabRow({ line: 7, fields }, labQuery(extra), "Europe/Berlin", NOW);
|
||||
|
||||
describe("lab rows", () => {
|
||||
it("reads a numeric result with its unit, date and printed range", () => {
|
||||
const r = lab(["HbA1c", "5.4", "%", "2025-03-01", "4.0-5.6"]);
|
||||
expect(r).toMatchObject({
|
||||
ok: true,
|
||||
row: {
|
||||
line: 7,
|
||||
analyte: "HbA1c",
|
||||
value: 5.4,
|
||||
valueText: null,
|
||||
unit: "%",
|
||||
sourceReferenceLow: 4,
|
||||
sourceReferenceHigh: 5.6,
|
||||
},
|
||||
});
|
||||
expect(r.ok && r.row.takenAt.toISOString()).toBe(
|
||||
"2025-03-01T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a German file: decimal comma, day-first dates, comma ranges", () => {
|
||||
const r = lab(["Kreatinin", "0,92", "mg/dl", "01.03.2025", "0,7 - 1,2"], {
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
});
|
||||
expect(r).toMatchObject({
|
||||
ok: true,
|
||||
row: { value: 0.92, sourceReferenceLow: 0.7, sourceReferenceHigh: 1.2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a non-numeric result as a qualitative reading", () => {
|
||||
expect(
|
||||
lab(["Urine protein", "negative", "", "2025-03-01", ""]),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
row: { value: null, valueText: "negative", unit: null },
|
||||
});
|
||||
expect(lab(["PSA", "<0.1", "ng/mL", "2025-03-01", ""])).toMatchObject({
|
||||
ok: true,
|
||||
row: { valueText: "<0.1" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[["", "5", "%", "2025-03-01", ""], "missing_value", "analyte"],
|
||||
[["HbA1c", "", "%", "2025-03-01", ""], "missing_value", "value"],
|
||||
[["HbA1c", "5.4", "", "2025-03-01", ""], "missing_unit", "unit"],
|
||||
[
|
||||
["Glucose", "1,234", "mg/dL", "2025-03-01", ""],
|
||||
"ambiguous_number",
|
||||
"value",
|
||||
],
|
||||
[
|
||||
["Glucose", "1.2.3", "mg/dL", "2025-03-01", ""],
|
||||
"invalid_number",
|
||||
"value",
|
||||
],
|
||||
[["Glucose", "90", "mg/dL", "03/01/25", ""], "invalid_date", "date"],
|
||||
[["Glucose", "90", "mg/dL", "2027-01-01", ""], "future_date", "date"],
|
||||
] as const)("refuses %j with %s on %s", (fields, reason, field) => {
|
||||
expect(lab([...fields])).toEqual({ ok: false, reason, field });
|
||||
});
|
||||
|
||||
it("refuses a range whose low lies above its high", () => {
|
||||
expect(
|
||||
lab(["Glucose", "90", "mg/dL", "2025-03-01", "", "120", "70"], {
|
||||
referenceRange: "4",
|
||||
referenceLow: "5",
|
||||
referenceHigh: "6",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "invalid_reference_range",
|
||||
field: "referenceRange",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a mapping that feeds two fields from one column", () => {
|
||||
expect(
|
||||
labsCsvQuerySchema.safeParse({ analyte: "0", value: "0", date: "1" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keys a reading the way the scan commit's duplicate check does", () => {
|
||||
const keys = readingKeys(
|
||||
{
|
||||
biomarkerId: "bm1",
|
||||
analyte: "HbA1c",
|
||||
takenAt: new Date("2025-03-01T12:00:00.000Z"),
|
||||
value: 39,
|
||||
valueText: null,
|
||||
sourceValue: 5.7,
|
||||
sourceUnit: "%",
|
||||
},
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(keys).toEqual([
|
||||
"bm:bm1|2025-03-01|n:39",
|
||||
"bm:bm1|2025-03-01|s:5.7:%",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const medQuery = (extra: Record<string, string> = {}) =>
|
||||
medicationsCsvQuerySchema.parse({
|
||||
name: "0",
|
||||
dose: "1",
|
||||
times: "2",
|
||||
asNeeded: "3",
|
||||
startsOn: "4",
|
||||
endsOn: "5",
|
||||
...extra,
|
||||
});
|
||||
const med = (fields: string[], extra?: Record<string, string>) =>
|
||||
normaliseMedicationRow({ line: 3, fields }, medQuery(extra), "Europe/Berlin");
|
||||
|
||||
describe("medication rows", () => {
|
||||
it.each([
|
||||
["08:00", ["08:00"]],
|
||||
["20:00 8:00", ["08:00", "20:00"]],
|
||||
["8.00; 20.00|12:30", ["08:00", "12:30", "20:00"]],
|
||||
["", []],
|
||||
])("times %j → %j", (raw, expected) => {
|
||||
expect(parseTimes(raw)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(["8", "25:00", "morning", "08:00 x"])("refuses times %j", (raw) => {
|
||||
expect(parseTimes(raw)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a daily medication with a course", () => {
|
||||
expect(
|
||||
med(["Ramipril", "5 mg", "08:00", "", "01.02.2025", ""], {
|
||||
dateOrder: "dmy",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
row: {
|
||||
line: 3,
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
times: ["08:00"],
|
||||
asNeeded: false,
|
||||
startsOn: "2025-02-01",
|
||||
endsOn: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[["", "5 mg", "", "", "", ""], "missing_value", "name"],
|
||||
[
|
||||
["Ibuprofen", "400 mg", "08:00", "yes", "", ""],
|
||||
"as_needed_with_times",
|
||||
"times",
|
||||
],
|
||||
[
|
||||
["Ibuprofen", "400 mg", "", "sometimes", "", ""],
|
||||
"invalid_yes_no",
|
||||
"asNeeded",
|
||||
],
|
||||
[
|
||||
["X", "1", "08:00", "", "2025-03-01", "2025-02-01"],
|
||||
"end_before_start",
|
||||
"endsOn",
|
||||
],
|
||||
[["Vitamin D", "1000 IE", "", "", "", ""], "missing_times", "times"],
|
||||
] as const)("refuses %j with %s", (fields, reason, field) => {
|
||||
expect(med([...fields])).toEqual({ ok: false, reason, field });
|
||||
});
|
||||
|
||||
it("builds a body the create route's own schema accepts, reminders off by default", () => {
|
||||
const daily = med(["Ramipril", "5 mg", "08:00 20:00", "", "", ""]);
|
||||
const asNeeded = med(["Ibuprofen", "400 mg", "", "ja", "", ""]);
|
||||
for (const r of [daily, asNeeded]) {
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) continue;
|
||||
const check = createMedicationSchema.safeParse(
|
||||
medicationCreateBody(r.row, false),
|
||||
);
|
||||
expect(check.success ? [] : check.error.issues).toEqual([]);
|
||||
}
|
||||
if (daily.ok) {
|
||||
expect(medicationCreateBody(daily.row, false)).toMatchObject({
|
||||
notificationsEnabled: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "20:00",
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(medicationCreateBody(daily.row, true).notificationsEnabled).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (asNeeded.ok) {
|
||||
expect(
|
||||
medicationCreateBody(asNeeded.row, true).notificationsEnabled,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("structure", () => {
|
||||
const read = (p: string) => readFileSync(join(process.cwd(), p), "utf8");
|
||||
|
||||
it("writes field by field and never spreads a parsed row", () => {
|
||||
for (const file of [
|
||||
"src/lib/import/labs-csv.ts",
|
||||
"src/lib/import/medications-csv.ts",
|
||||
]) {
|
||||
const src = read(file);
|
||||
expect(src).not.toMatch(/data:\s*\{\s*\.\.\.(row|parsed|data)\b/);
|
||||
expect(src).not.toMatch(/\.\.\.parsed\.data/);
|
||||
}
|
||||
});
|
||||
|
||||
it("checks every medication row with the create route's schema", () => {
|
||||
expect(read("src/lib/import/medications-csv.ts")).toContain(
|
||||
"createMedicationSchema.safeParse(",
|
||||
);
|
||||
});
|
||||
|
||||
it("streams the body instead of reading it whole", () => {
|
||||
const src = read("src/lib/import/record-import-route.ts");
|
||||
expect(src).toContain("stream: request.body");
|
||||
expect(src).not.toMatch(/request\.(text|json|arrayBuffer)\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* The client half of the CSV record imports (#1215): reading the head of a
|
||||
* file for the mapping screen, suggesting which column feeds which field and
|
||||
* how the file writes numbers and dates, and the downloadable templates.
|
||||
*
|
||||
* Suggestions only. The person sees every choice on the mapping screen and
|
||||
* the server checks every row against what was chosen; nothing here decides
|
||||
* a value.
|
||||
*/
|
||||
import { CsvTokenizer, type CsvDelimiter } from "@/lib/import/csv-stream";
|
||||
|
||||
export type RecordImportKind = "labs" | "medications";
|
||||
|
||||
export const LAB_FIELDS = [
|
||||
{ key: "analyte", required: true },
|
||||
{ key: "value", required: true },
|
||||
{ key: "unit", required: false },
|
||||
{ key: "date", required: true },
|
||||
{ key: "time", required: false },
|
||||
{ key: "referenceRange", required: false },
|
||||
{ key: "referenceLow", required: false },
|
||||
{ key: "referenceHigh", required: false },
|
||||
{ key: "panel", required: false },
|
||||
{ key: "note", required: false },
|
||||
] as const;
|
||||
|
||||
export const MEDICATION_FIELDS = [
|
||||
{ key: "name", required: true },
|
||||
{ key: "dose", required: true },
|
||||
{ key: "times", required: false },
|
||||
{ key: "asNeeded", required: false },
|
||||
{ key: "startsOn", required: false },
|
||||
{ key: "endsOn", required: false },
|
||||
] as const;
|
||||
|
||||
export type FieldKey =
|
||||
| (typeof LAB_FIELDS)[number]["key"]
|
||||
| (typeof MEDICATION_FIELDS)[number]["key"];
|
||||
|
||||
export function fieldsFor(kind: RecordImportKind) {
|
||||
return kind === "labs" ? LAB_FIELDS : MEDICATION_FIELDS;
|
||||
}
|
||||
|
||||
/** Header words per field, in the languages HealthLog speaks, folded. */
|
||||
const SYNONYMS: Record<FieldKey, string[]> = {
|
||||
analyte: [
|
||||
"analyte",
|
||||
"marker",
|
||||
"biomarker",
|
||||
"test",
|
||||
"parameter",
|
||||
"analyt",
|
||||
"laborwert",
|
||||
"wert name",
|
||||
"name",
|
||||
"prueba",
|
||||
"analyse",
|
||||
"esame",
|
||||
"badanie",
|
||||
],
|
||||
value: [
|
||||
"value",
|
||||
"result",
|
||||
"wert",
|
||||
"ergebnis",
|
||||
"resultado",
|
||||
"valor",
|
||||
"valeur",
|
||||
"resultat",
|
||||
"valore",
|
||||
"risultato",
|
||||
"wynik",
|
||||
"waarde",
|
||||
"uitslag",
|
||||
"결과",
|
||||
"값",
|
||||
],
|
||||
unit: [
|
||||
"unit",
|
||||
"units",
|
||||
"einheit",
|
||||
"unidad",
|
||||
"unite",
|
||||
"unita",
|
||||
"jednostka",
|
||||
"eenheid",
|
||||
"단위",
|
||||
],
|
||||
date: [
|
||||
"date",
|
||||
"datum",
|
||||
"fecha",
|
||||
"data",
|
||||
"day",
|
||||
"tag",
|
||||
"taken",
|
||||
"taken at",
|
||||
"collected",
|
||||
"abnahme",
|
||||
"날짜",
|
||||
],
|
||||
time: [
|
||||
"time",
|
||||
"uhrzeit",
|
||||
"zeit",
|
||||
"hora",
|
||||
"heure",
|
||||
"ora",
|
||||
"godzina",
|
||||
"tijd",
|
||||
"시간",
|
||||
],
|
||||
referenceRange: [
|
||||
"reference",
|
||||
"reference range",
|
||||
"range",
|
||||
"referenz",
|
||||
"referenzbereich",
|
||||
"normbereich",
|
||||
"norm",
|
||||
"rango",
|
||||
"reference interval",
|
||||
"intervalle",
|
||||
"riferimento",
|
||||
"zakres",
|
||||
"referentie",
|
||||
],
|
||||
referenceLow: ["low", "min", "lower", "untergrenze", "von", "ref low"],
|
||||
referenceHigh: ["high", "max", "upper", "obergrenze", "bis", "ref high"],
|
||||
panel: ["panel", "group", "gruppe", "profil", "category", "kategorie"],
|
||||
note: [
|
||||
"note",
|
||||
"notes",
|
||||
"comment",
|
||||
"kommentar",
|
||||
"notiz",
|
||||
"bemerkung",
|
||||
"nota",
|
||||
"remarque",
|
||||
"uwagi",
|
||||
"opmerking",
|
||||
],
|
||||
name: [
|
||||
"name",
|
||||
"medication",
|
||||
"medicine",
|
||||
"drug",
|
||||
"medikament",
|
||||
"arznei",
|
||||
"präparat",
|
||||
"praparat",
|
||||
"wirkstoff",
|
||||
"medicamento",
|
||||
"medicament",
|
||||
"farmaco",
|
||||
"lek",
|
||||
"medicijn",
|
||||
"약",
|
||||
],
|
||||
dose: [
|
||||
"dose",
|
||||
"dosage",
|
||||
"dosis",
|
||||
"dosierung",
|
||||
"strength",
|
||||
"stärke",
|
||||
"starke",
|
||||
"dosage",
|
||||
"dawka",
|
||||
"용량",
|
||||
],
|
||||
times: [
|
||||
"times",
|
||||
"time",
|
||||
"schedule",
|
||||
"uhrzeit",
|
||||
"uhrzeiten",
|
||||
"einnahme",
|
||||
"zeiten",
|
||||
"horario",
|
||||
"heures",
|
||||
"orari",
|
||||
"godziny",
|
||||
"tijden",
|
||||
"시간",
|
||||
],
|
||||
asNeeded: [
|
||||
"as needed",
|
||||
"prn",
|
||||
"bei bedarf",
|
||||
"bedarf",
|
||||
"a demanda",
|
||||
"si besoin",
|
||||
"al bisogno",
|
||||
"w razie potrzeby",
|
||||
"zo nodig",
|
||||
"필요시",
|
||||
],
|
||||
startsOn: [
|
||||
"start",
|
||||
"starts",
|
||||
"start date",
|
||||
"begin",
|
||||
"beginn",
|
||||
"seit",
|
||||
"from",
|
||||
"von",
|
||||
"inicio",
|
||||
"debut",
|
||||
"inizio",
|
||||
"od",
|
||||
"begin datum",
|
||||
],
|
||||
endsOn: [
|
||||
"end",
|
||||
"ends",
|
||||
"end date",
|
||||
"until",
|
||||
"bis",
|
||||
"ende",
|
||||
"fin",
|
||||
"fine",
|
||||
"do",
|
||||
"einddatum",
|
||||
],
|
||||
};
|
||||
|
||||
function fold(text: string): string {
|
||||
return text
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[_\-./()]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a column per field from the header names. Exact synonym first, then
|
||||
* a header that starts with one; a column is offered to one field at most,
|
||||
* and the required fields choose first.
|
||||
*/
|
||||
export function guessMapping(
|
||||
headers: readonly string[],
|
||||
kind: RecordImportKind,
|
||||
): Partial<Record<FieldKey, number>> {
|
||||
const folded = headers.map(fold);
|
||||
const taken = new Set<number>();
|
||||
const out: Partial<Record<FieldKey, number>> = {};
|
||||
const order = [...fieldsFor(kind)].sort(
|
||||
(a, b) => Number(b.required) - Number(a.required),
|
||||
);
|
||||
for (const pass of ["exact", "prefix"] as const) {
|
||||
for (const { key } of order) {
|
||||
if (out[key] !== undefined) continue;
|
||||
const words = SYNONYMS[key];
|
||||
const index = folded.findIndex(
|
||||
(h, i) =>
|
||||
!taken.has(i) &&
|
||||
words.some((w) =>
|
||||
pass === "exact" ? h === w : h.startsWith(`${w} `),
|
||||
),
|
||||
);
|
||||
if (index >= 0) {
|
||||
out[key] = index;
|
||||
taken.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface FileHead {
|
||||
delimiter: CsvDelimiter;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
/** The first `maxRows` records of a file's head text. */
|
||||
export function readHead(text: string, maxRows = 6): FileHead {
|
||||
const tokenizer = new CsvTokenizer("auto");
|
||||
const rows: string[][] = [];
|
||||
for (const record of [...tokenizer.push(text), ...tokenizer.end()]) {
|
||||
rows.push(record.fields);
|
||||
if (rows.length >= maxRows) break;
|
||||
}
|
||||
return { delimiter: tokenizer.detectedDelimiter ?? ",", rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* How the sample writes numbers and dates. A semicolon file almost always
|
||||
* writes a decimal comma; a sample cell like `5,4` says so outright. The date
|
||||
* order follows the first date-shaped cell: ISO, then a first number above 12
|
||||
* (day first) or a second above 12 (month first), then the separator habit
|
||||
* (`.` is day first, `/` alone is left to the person: it stays ISO-default
|
||||
* `dmy` only when a dot is seen).
|
||||
*/
|
||||
export function guessFormat(
|
||||
head: FileHead,
|
||||
dateColumn: number | undefined,
|
||||
): { decimal: "dot" | "comma"; dateOrder: "iso" | "dmy" | "mdy" } {
|
||||
const data = head.rows.slice(1);
|
||||
const cells = data.flat();
|
||||
const commaNumber = cells.some((c) => /^-?\d+,\d{1,2}$/.test(c.trim()));
|
||||
const decimal = head.delimiter === ";" || commaNumber ? "comma" : "dot";
|
||||
|
||||
let dateOrder: "iso" | "dmy" | "mdy" = "iso";
|
||||
const dates = data
|
||||
.map((r) => (dateColumn === undefined ? "" : (r[dateColumn] ?? "").trim()))
|
||||
.filter(Boolean);
|
||||
for (const d of dates) {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(d)) {
|
||||
dateOrder = "iso";
|
||||
break;
|
||||
}
|
||||
const m = /^(\d{1,2})([./-])(\d{1,2})\2\d{4}/.exec(d);
|
||||
if (!m) continue;
|
||||
if (Number(m[1]) > 12) {
|
||||
dateOrder = "dmy";
|
||||
break;
|
||||
}
|
||||
if (Number(m[3]) > 12) {
|
||||
dateOrder = "mdy";
|
||||
break;
|
||||
}
|
||||
dateOrder = m[2] === "." ? "dmy" : "mdy";
|
||||
}
|
||||
return { decimal, dateOrder };
|
||||
}
|
||||
|
||||
export const RECORD_IMPORT_TEMPLATES: Record<RecordImportKind, string> = {
|
||||
labs: [
|
||||
"analyte,value,unit,date,time,reference range,panel,note",
|
||||
"HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,",
|
||||
"Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,",
|
||||
"Urine protein,negative,,2025-03-01,,,Urine,",
|
||||
"",
|
||||
].join("\n"),
|
||||
medications: [
|
||||
"name,dose,times,as needed,start,end",
|
||||
"Ramipril,5 mg,08:00,,2025-01-01,",
|
||||
"Metformin,500 mg,08:00 20:00,,,",
|
||||
"Ibuprofen,400 mg,,yes,,",
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* A streaming CSV reader for the record imports (#1215).
|
||||
*
|
||||
* The measurement importer reads its whole body into one string and splits
|
||||
* it. That is fine for its 16 MB cap, but a lab or medication history kept
|
||||
* over years in a spreadsheet arrives as one upload, and the import must hold
|
||||
* a bounded amount of it at a time. This reader consumes the body chunk by
|
||||
* chunk and hands out one record at a time; at any moment it holds the bytes
|
||||
* of the record it is building and nothing before it.
|
||||
*
|
||||
* RFC 4180 plus what spreadsheets actually write:
|
||||
* - a UTF-8 byte-order mark at the start is dropped;
|
||||
* - the separator is `,`, `;` or a tab, detected from the header line
|
||||
* outside quotes unless the caller names one (a German export writes `;`
|
||||
* because `,` is its decimal mark);
|
||||
* - fields may be quoted with `"`, a doubled `""` inside is one quote, and
|
||||
* a quoted field may hold the separator and line breaks;
|
||||
* - lines end in `\n`, `\r\n` or a lone `\r`;
|
||||
* - a record of only empty fields (a trailing blank line) is skipped.
|
||||
*
|
||||
* Bounds: the body is counted while it is read and refused past `maxBytes`;
|
||||
* a field longer than `MAX_FIELD_CHARS` or a record wider than
|
||||
* `MAX_COLUMNS` marks that record `oversized` instead of growing without end
|
||||
* (one runaway quote would otherwise swallow the rest of the file).
|
||||
*/
|
||||
|
||||
export type CsvDelimiter = "," | ";" | "\t";
|
||||
|
||||
export const MAX_FIELD_CHARS = 4000;
|
||||
export const MAX_COLUMNS = 100;
|
||||
|
||||
export interface CsvRecord {
|
||||
/** 1-based physical line the record starts on. */
|
||||
line: number;
|
||||
fields: string[];
|
||||
/** A field or the record ran past the bounds; its fields are cut short. */
|
||||
oversized: boolean;
|
||||
}
|
||||
|
||||
/** Count separators outside quotes in a header line; the most frequent wins. */
|
||||
export function detectDelimiter(headerLine: string): CsvDelimiter {
|
||||
const counts: Record<CsvDelimiter, number> = { ",": 0, ";": 0, "\t": 0 };
|
||||
let quoted = false;
|
||||
for (const ch of headerLine) {
|
||||
if (ch === '"') quoted = !quoted;
|
||||
else if (!quoted && (ch === "," || ch === ";" || ch === "\t")) {
|
||||
counts[ch] += 1;
|
||||
}
|
||||
}
|
||||
// Ties go to the comma, then the semicolon: a one-column file is a comma file.
|
||||
let best: CsvDelimiter = ",";
|
||||
for (const d of [";", "\t"] as const) {
|
||||
if (counts[d] > counts[best]) best = d;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The incremental tokenizer. `push` takes the next piece of text and returns
|
||||
* the records it completed; `end` flushes the last one. Pure and synchronous,
|
||||
* so it is fuzzed without streams.
|
||||
*/
|
||||
export class CsvTokenizer {
|
||||
private delimiter: CsvDelimiter | null;
|
||||
private field = "";
|
||||
private fields: string[] = [];
|
||||
private quoted = false;
|
||||
/** The previous character closed a quote, or was a quote inside quotes. */
|
||||
private quotePending = false;
|
||||
private afterCr = false;
|
||||
private line = 1;
|
||||
private recordLine = 1;
|
||||
private oversized = false;
|
||||
private started = false;
|
||||
/** Text held back until the header line shows the separator. */
|
||||
private pending = "";
|
||||
|
||||
constructor(delimiter: CsvDelimiter | "auto" = "auto") {
|
||||
this.delimiter = delimiter === "auto" ? null : delimiter;
|
||||
}
|
||||
|
||||
get detectedDelimiter(): CsvDelimiter | null {
|
||||
return this.delimiter;
|
||||
}
|
||||
|
||||
push(text: string): CsvRecord[] {
|
||||
if (!this.started && text.length > 0) {
|
||||
this.started = true;
|
||||
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
||||
}
|
||||
if (this.delimiter === null) {
|
||||
this.pending += text;
|
||||
const end = firstLineEnd(this.pending);
|
||||
if (end === -1) {
|
||||
// Bound the hold-back too: a header that never ends is a broken file.
|
||||
if (this.pending.length <= MAX_FIELD_CHARS * MAX_COLUMNS) return [];
|
||||
this.delimiter = detectDelimiter(this.pending);
|
||||
} else {
|
||||
this.delimiter = detectDelimiter(this.pending.slice(0, end));
|
||||
}
|
||||
text = this.pending;
|
||||
this.pending = "";
|
||||
}
|
||||
const out: CsvRecord[] = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
this.consume(text[i], out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
end(): CsvRecord[] {
|
||||
const out: CsvRecord[] = [];
|
||||
if (this.delimiter === null) {
|
||||
const held = this.pending;
|
||||
this.pending = "";
|
||||
this.delimiter = detectDelimiter(held);
|
||||
for (const ch of held) this.consume(ch, out);
|
||||
}
|
||||
if (this.quotePending) {
|
||||
// A closing quote right at the end of the input.
|
||||
this.quoted = false;
|
||||
this.quotePending = false;
|
||||
}
|
||||
if (this.field !== "" || this.fields.length > 0) this.finishRecord(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private append(ch: string) {
|
||||
if (this.field.length < MAX_FIELD_CHARS) this.field += ch;
|
||||
else this.oversized = true;
|
||||
}
|
||||
|
||||
private finishField() {
|
||||
if (this.fields.length < MAX_COLUMNS) this.fields.push(this.field);
|
||||
else this.oversized = true;
|
||||
this.field = "";
|
||||
}
|
||||
|
||||
private finishRecord(out: CsvRecord[]) {
|
||||
this.finishField();
|
||||
const blank = this.fields.every((f) => f === "");
|
||||
if (!blank) {
|
||||
out.push({
|
||||
line: this.recordLine,
|
||||
fields: this.fields,
|
||||
oversized: this.oversized,
|
||||
});
|
||||
}
|
||||
this.fields = [];
|
||||
this.oversized = false;
|
||||
}
|
||||
|
||||
private consume(ch: string, out: CsvRecord[]) {
|
||||
// A `\n` straight after a `\r` belongs to the same line end.
|
||||
if (this.afterCr) {
|
||||
this.afterCr = false;
|
||||
if (ch === "\n") return;
|
||||
}
|
||||
if (this.quoted) {
|
||||
if (this.quotePending) {
|
||||
this.quotePending = false;
|
||||
if (ch === '"') {
|
||||
this.append('"');
|
||||
return;
|
||||
}
|
||||
// The quote closed the field; fall through to unquoted handling.
|
||||
this.quoted = false;
|
||||
} else if (ch === '"') {
|
||||
this.quotePending = true;
|
||||
return;
|
||||
} else {
|
||||
if (ch === "\n" || ch === "\r") {
|
||||
this.line += 1;
|
||||
if (ch === "\r") this.afterCr = true;
|
||||
this.append("\n");
|
||||
return;
|
||||
}
|
||||
this.append(ch);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ch === this.delimiter) {
|
||||
this.finishField();
|
||||
return;
|
||||
}
|
||||
if (ch === "\n" || ch === "\r") {
|
||||
this.finishRecord(out);
|
||||
this.line += 1;
|
||||
this.recordLine = this.line;
|
||||
if (ch === "\r") this.afterCr = true;
|
||||
return;
|
||||
}
|
||||
if (ch === '"' && this.field === "") {
|
||||
this.quoted = true;
|
||||
return;
|
||||
}
|
||||
this.append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
function firstLineEnd(text: string): number {
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '"') quoted = !quoted;
|
||||
else if (!quoted && (ch === "\n" || ch === "\r")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export class CsvTooLargeError extends Error {
|
||||
constructor(readonly maxBytes: number) {
|
||||
super(`CSV exceeds ${maxBytes} bytes`);
|
||||
this.name = "CsvTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a byte stream as CSV records, one at a time. The bytes are counted as
|
||||
* they arrive and the read stops with `CsvTooLargeError` past `maxBytes`, so
|
||||
* a body without a Content-Length cannot run past the cap either.
|
||||
*/
|
||||
export async function* readCsvRecords(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
opts: { maxBytes: number; delimiter?: CsvDelimiter | "auto" },
|
||||
): AsyncGenerator<CsvRecord, void, undefined> {
|
||||
const tokenizer = new CsvTokenizer(opts.delimiter ?? "auto");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const reader = stream.getReader();
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > opts.maxBytes) throw new CsvTooLargeError(opts.maxBytes);
|
||||
for (const record of tokenizer.push(
|
||||
decoder.decode(value, { stream: true }),
|
||||
)) {
|
||||
yield record;
|
||||
}
|
||||
}
|
||||
for (const record of tokenizer.push(decoder.decode())) yield record;
|
||||
for (const record of tokenizer.end()) yield record;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/** A string as a one-chunk-at-a-time stream; for tests and the client preview. */
|
||||
export function textToStream(
|
||||
text: string,
|
||||
chunkSize = 64 * 1024,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let offset = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (offset >= bytes.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(bytes.slice(offset, offset + chunkSize));
|
||||
offset += chunkSize;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Reading numbers and dates out of spreadsheet cells (#1215).
|
||||
*
|
||||
* Conservative by rule: a cell that could mean two things is refused with
|
||||
* its own reason rather than read one way. The person sees the line and the
|
||||
* reason and fixes the file or the setting; a wrong number stored quietly is
|
||||
* the one outcome an import must never have.
|
||||
*/
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
export type DecimalMark = "dot" | "comma";
|
||||
export type DateOrder = "iso" | "dmy" | "mdy";
|
||||
|
||||
export type NumberResult =
|
||||
| { ok: true; value: number }
|
||||
| { ok: false; reason: "invalid_number" | "ambiguous_number" };
|
||||
|
||||
/**
|
||||
* A number written with the file's decimal mark. Grouping separators are
|
||||
* accepted only in the unmistakable form (`1.234,5` with a comma mark,
|
||||
* `1,234.5` with a dot mark). A lone grouping-shaped number in the other
|
||||
* convention (`1,234` in a dot file, `1.234` in a comma file) is ambiguous:
|
||||
* it is a thousand and something in one country and one point something in
|
||||
* the next, and lab values live exactly in that range.
|
||||
*/
|
||||
export function parseLocaleNumber(
|
||||
raw: string,
|
||||
mark: DecimalMark,
|
||||
): NumberResult {
|
||||
let s = raw.trim().replace(/[ ]/g, "");
|
||||
let sign = 1;
|
||||
if (s.startsWith("-") || s.startsWith("−")) {
|
||||
sign = -1;
|
||||
s = s.slice(1);
|
||||
} else if (s.startsWith("+")) {
|
||||
s = s.slice(1);
|
||||
}
|
||||
if (!/^[\d.,]+$/.test(s)) return { ok: false, reason: "invalid_number" };
|
||||
const dec = mark === "comma" ? "," : ".";
|
||||
const grp = mark === "comma" ? "." : ",";
|
||||
const e = (c: string) => `\\${c}`;
|
||||
const read = (digits: string) => {
|
||||
const value = sign * Number(digits);
|
||||
return Number.isFinite(value)
|
||||
? ({ ok: true, value } as const)
|
||||
: ({ ok: false, reason: "invalid_number" } as const);
|
||||
};
|
||||
|
||||
// Only the file's own decimal mark, at most once.
|
||||
if (new RegExp(`^(\\d+(${e(dec)}\\d+)?|${e(dec)}\\d+)$`).test(s)) {
|
||||
return read(s.replace(dec, "."));
|
||||
}
|
||||
// Grouped in the file's own convention: 1,234,567 / 1,234.5 (dot file).
|
||||
if (new RegExp(`^\\d{1,3}(${e(grp)}\\d{3})+(${e(dec)}\\d+)?$`).test(s)) {
|
||||
const groups = s.split(grp).length - 1;
|
||||
// One group and no decimal part reads as the other convention's decimal
|
||||
// too (1,234 = one point two three four in a comma country).
|
||||
if (groups === 1 && !s.includes(dec)) {
|
||||
return { ok: false, reason: "ambiguous_number" };
|
||||
}
|
||||
return read(s.split(grp).join("").replace(dec, "."));
|
||||
}
|
||||
// One mark of the other convention with not exactly three digits after it
|
||||
// can only be a decimal mark (5,4 in a dot file, 5.4 in a comma file).
|
||||
const other = new RegExp(`^\\d+${e(grp)}(\\d+)$`).exec(s);
|
||||
if (other) {
|
||||
if (other[1].length === 3) return { ok: false, reason: "ambiguous_number" };
|
||||
return read(s.replace(grp, "."));
|
||||
}
|
||||
return { ok: false, reason: "invalid_number" };
|
||||
}
|
||||
|
||||
export type DateResult =
|
||||
| {
|
||||
ok: true;
|
||||
/** The instant to store. */
|
||||
instant: Date;
|
||||
/** The calendar date the cell states. */
|
||||
dateKey: string;
|
||||
/** The cell gave a date without a time of day. */
|
||||
dateOnly: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "invalid_date" | "invalid_time" | "ambiguous_date";
|
||||
};
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* A date cell (and an optional separate time cell) in the stated order.
|
||||
*
|
||||
* - `iso`: `YYYY-MM-DD`, optionally with `T`/space and `HH:mm[:ss]` and an
|
||||
* offset (`Z`, `+02:00`), the shape every export tool can write;
|
||||
* - `dmy`: `DD.MM.YYYY`, `DD/MM/YYYY` or `DD-MM-YYYY`;
|
||||
* - `mdy`: `MM/DD/YYYY`.
|
||||
*
|
||||
* A time without an offset is a wall-clock time in `timezone` (the account's
|
||||
* zone unless the import names another). A date without a time is stored as
|
||||
* the date-only anchor the scanned and document readings use (noon UTC), so
|
||||
* it is the same calendar date for everyone who opens the record.
|
||||
*
|
||||
* A two-digit year is refused as ambiguous. So is a `dmy`/`mdy` cell whose
|
||||
* first number cannot be what the order says (13/02/2026 under `mdy`):
|
||||
* that file is in the other order, and guessing it per row would mix both.
|
||||
*/
|
||||
export function parseCsvDate(
|
||||
rawDate: string,
|
||||
rawTime: string | undefined,
|
||||
opts: { order: DateOrder; timezone: string },
|
||||
): DateResult {
|
||||
const cell = rawDate.trim();
|
||||
if (cell === "") return { ok: false, reason: "invalid_date" };
|
||||
let y: number;
|
||||
let m: number;
|
||||
let d: number;
|
||||
let timePart: string | undefined;
|
||||
let offset: string | undefined;
|
||||
|
||||
if (opts.order === "iso") {
|
||||
const match =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?)\s*(Z|[+-]\d{2}:?\d{2})?)?$/i.exec(
|
||||
cell,
|
||||
);
|
||||
if (!match) {
|
||||
return /^\d{2}-\d{2}-\d{2}$/.test(cell)
|
||||
? { ok: false, reason: "ambiguous_date" }
|
||||
: { ok: false, reason: "invalid_date" };
|
||||
}
|
||||
y = Number(match[1]);
|
||||
m = Number(match[2]);
|
||||
d = Number(match[3]);
|
||||
timePart = match[4];
|
||||
offset = match[5];
|
||||
} else {
|
||||
const match =
|
||||
/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})(?:[ T](\d{1,2}:\d{2}(?::\d{2})?))?$/.exec(
|
||||
cell,
|
||||
);
|
||||
if (!match) return { ok: false, reason: "invalid_date" };
|
||||
if (match[3].length === 2) return { ok: false, reason: "ambiguous_date" };
|
||||
const first = Number(match[1]);
|
||||
const second = Number(match[2]);
|
||||
y = Number(match[3]);
|
||||
[d, m] = opts.order === "dmy" ? [first, second] : [second, first];
|
||||
if (m > 12 && d <= 12) return { ok: false, reason: "ambiguous_date" };
|
||||
timePart = match[4];
|
||||
}
|
||||
|
||||
const dateKey = `${String(y).padStart(4, "0")}-${pad(m)}-${pad(d)}`;
|
||||
if (!isCalendarDateKey(dateKey)) return { ok: false, reason: "invalid_date" };
|
||||
|
||||
const time = (rawTime ?? "").trim() || timePart;
|
||||
if (!time) {
|
||||
return {
|
||||
ok: true,
|
||||
instant: new Date(`${dateKey}T12:00:00.000Z`),
|
||||
dateKey,
|
||||
dateOnly: true,
|
||||
};
|
||||
}
|
||||
const tm = /^(\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/.exec(time);
|
||||
if (!tm) return { ok: false, reason: "invalid_time" };
|
||||
const hour = Number(tm[1]);
|
||||
const minute = Number(tm[2]);
|
||||
const second = Number(tm[3] ?? 0);
|
||||
if (hour > 23 || minute > 59 || second > 59) {
|
||||
return { ok: false, reason: "invalid_time" };
|
||||
}
|
||||
if (offset) {
|
||||
const norm =
|
||||
offset.toUpperCase() === "Z"
|
||||
? "Z"
|
||||
: offset.replace(/^([+-]\d{2})(\d{2})$/, "$1:$2");
|
||||
const instant = new Date(
|
||||
`${dateKey}T${pad(hour)}:${pad(minute)}:${pad(second)}${norm}`,
|
||||
);
|
||||
if (Number.isNaN(instant.getTime()))
|
||||
return { ok: false, reason: "invalid_time" };
|
||||
return { ok: true, instant, dateKey, dateOnly: false };
|
||||
}
|
||||
const instant = zonedWallClockToUtc(
|
||||
{ year: y, month: m, day: d, hour, minute, second },
|
||||
opts.timezone,
|
||||
);
|
||||
return { ok: true, instant, dateKey, dateOnly: false };
|
||||
}
|
||||
|
||||
/** A yes/no cell in the languages HealthLog speaks; anything else is null. */
|
||||
export function parseYesNo(raw: string): boolean | null {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "") return null;
|
||||
if (
|
||||
[
|
||||
"1",
|
||||
"yes",
|
||||
"y",
|
||||
"true",
|
||||
"x",
|
||||
"ja",
|
||||
"j",
|
||||
"sí",
|
||||
"si",
|
||||
"oui",
|
||||
"tak",
|
||||
"예",
|
||||
"wahr",
|
||||
].includes(v)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
[
|
||||
"0",
|
||||
"no",
|
||||
"n",
|
||||
"false",
|
||||
"nein",
|
||||
"non",
|
||||
"nie",
|
||||
"nee",
|
||||
"아니오",
|
||||
"falsch",
|
||||
].includes(v)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* CSV import of lab results (#1215).
|
||||
*
|
||||
* A row becomes a lab reading the way a scanned report's confirmed row does
|
||||
* (`/api/labs/ocr/commit`): the name resolves to the account's marker by the
|
||||
* same index (name, alias, catalog), a marker is minted from the first row
|
||||
* that names a new analyte, a reading in another unit is converted when the
|
||||
* conversion is known and kept beside what the file said, and a reading the
|
||||
* record already holds is skipped. Nothing about a row is guessed: an
|
||||
* ambiguous number or date, a numeric value without a unit, or a unit that
|
||||
* does not convert into the marker's is refused with its reason and line.
|
||||
*
|
||||
* Memory: the file is read record by record (`readCsvRecords`) and written in
|
||||
* chunks of `CHUNK_ROWS`; what is held across chunks is the marker index, the
|
||||
* duplicate keys of rows already accepted and the bounded report.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
readCsvRecords,
|
||||
type CsvDelimiter,
|
||||
type CsvRecord,
|
||||
} from "@/lib/import/csv-stream";
|
||||
import { parseCsvDate, parseLocaleNumber } from "@/lib/import/csv-values";
|
||||
import { RecordImportReport } from "@/lib/import/record-import-report";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import {
|
||||
addToIndex,
|
||||
loadMarkerIndex,
|
||||
resolveInIndex,
|
||||
unitFitsMarker,
|
||||
type IndexedMarker,
|
||||
type MarkerIndex,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import {
|
||||
labReadingDay,
|
||||
labReadingDaySearchRange,
|
||||
} from "@/lib/labs/reading-day";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import type {
|
||||
LabsCsvQuery,
|
||||
RecordImportResult,
|
||||
RecordImportRowReason,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
export const LABS_CSV_MAX_ROWS = 20_000;
|
||||
const CHUNK_ROWS = 200;
|
||||
const MAX_LISTED_MARKERS = 200;
|
||||
/** A reading dated more than a day ahead is a typing mistake, not a result. */
|
||||
const FUTURE_SLACK_MS = 86_400_000;
|
||||
const EARLIEST = Date.UTC(1900, 0, 1);
|
||||
|
||||
export const LAB_TEXT_LIMITS = {
|
||||
analyte: 200,
|
||||
valueText: 200,
|
||||
unit: 80,
|
||||
panel: 100,
|
||||
note: 1000,
|
||||
} as const;
|
||||
|
||||
export interface LabCsvRow {
|
||||
line: number;
|
||||
analyte: string;
|
||||
value: number | null;
|
||||
valueText: string | null;
|
||||
unit: string | null;
|
||||
takenAt: Date;
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
sourceReferenceText: string | null;
|
||||
panel: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export type LabRowResult =
|
||||
| { ok: true; row: LabCsvRow }
|
||||
| { ok: false; reason: RecordImportRowReason; field: string };
|
||||
|
||||
function cell(fields: string[], index: number | undefined): string {
|
||||
if (index === undefined) return "";
|
||||
return (fields[index] ?? "").trim();
|
||||
}
|
||||
|
||||
/** One CSV record → a lab row, or the first reason it cannot be one. Pure. */
|
||||
export function normaliseLabRow(
|
||||
record: Pick<CsvRecord, "line" | "fields">,
|
||||
query: LabsCsvQuery,
|
||||
timezone: string,
|
||||
now: Date = new Date(),
|
||||
): LabRowResult {
|
||||
const f = record.fields;
|
||||
const fail = (
|
||||
reason: RecordImportRowReason,
|
||||
field: string,
|
||||
): LabRowResult => ({ ok: false, reason, field });
|
||||
|
||||
const analyte = cell(f, query.analyte);
|
||||
if (!analyte) return fail("missing_value", "analyte");
|
||||
if (analyte.length > LAB_TEXT_LIMITS.analyte)
|
||||
return fail("text_too_long", "analyte");
|
||||
|
||||
const rawValue = cell(f, query.value);
|
||||
if (!rawValue) return fail("missing_value", "value");
|
||||
let value: number | null = null;
|
||||
let valueText: string | null = null;
|
||||
const parsed = parseLocaleNumber(rawValue, query.decimal);
|
||||
if (parsed.ok) {
|
||||
value = parsed.value;
|
||||
} else if (parsed.reason === "ambiguous_number") {
|
||||
return fail("ambiguous_number", "value");
|
||||
} else if (/^[+-]?[\d.,\s]+$/.test(rawValue)) {
|
||||
// Digits and marks that still do not read as a number: a broken number,
|
||||
// not a qualitative result.
|
||||
return fail("invalid_number", "value");
|
||||
} else {
|
||||
if (rawValue.length > LAB_TEXT_LIMITS.valueText)
|
||||
return fail("text_too_long", "value");
|
||||
valueText = rawValue;
|
||||
}
|
||||
|
||||
const unit = cell(f, query.unit) || null;
|
||||
if (value !== null && !unit) return fail("missing_unit", "unit");
|
||||
if (unit && unit.length > LAB_TEXT_LIMITS.unit)
|
||||
return fail("text_too_long", "unit");
|
||||
|
||||
const date = parseCsvDate(
|
||||
cell(f, query.date),
|
||||
query.time === undefined ? undefined : cell(f, query.time),
|
||||
{
|
||||
order: query.dateOrder,
|
||||
timezone,
|
||||
},
|
||||
);
|
||||
if (!date.ok) return fail(date.reason, "date");
|
||||
if (date.instant.getTime() > now.getTime() + FUTURE_SLACK_MS)
|
||||
return fail("future_date", "date");
|
||||
if (date.instant.getTime() < EARLIEST) return fail("invalid_date", "date");
|
||||
|
||||
let low: number | null = null;
|
||||
let high: number | null = null;
|
||||
let rangeText: string | null = null;
|
||||
for (const [key, index] of [
|
||||
["referenceLow", query.referenceLow],
|
||||
["referenceHigh", query.referenceHigh],
|
||||
] as const) {
|
||||
const raw = cell(f, index);
|
||||
if (!raw) continue;
|
||||
const n = parseLocaleNumber(raw, query.decimal);
|
||||
if (!n.ok)
|
||||
return fail(
|
||||
n.reason === "ambiguous_number"
|
||||
? "ambiguous_number"
|
||||
: "invalid_reference_range",
|
||||
key,
|
||||
);
|
||||
if (key === "referenceLow") low = n.value;
|
||||
else high = n.value;
|
||||
}
|
||||
const rawRange = cell(f, query.referenceRange);
|
||||
if (rawRange) {
|
||||
// A comma-decimal file writes ranges as "4,0 - 5,6"; the range reader
|
||||
// speaks the dot, so the marks are swapped first for such a file.
|
||||
const forReader =
|
||||
query.decimal === "comma"
|
||||
? rawRange.replace(/(\d),(\d)/g, "$1.$2")
|
||||
: rawRange;
|
||||
const printed = parseReferenceRange(forReader, unit);
|
||||
if (printed) {
|
||||
low ??= printed.low;
|
||||
high ??= printed.high;
|
||||
rangeText = rawRange.replace(/\s+/g, " ").slice(0, 120);
|
||||
}
|
||||
}
|
||||
if (low !== null && high !== null && low > high) {
|
||||
return fail("invalid_reference_range", "referenceRange");
|
||||
}
|
||||
|
||||
const panel = cell(f, query.panel) || null;
|
||||
if (panel && panel.length > LAB_TEXT_LIMITS.panel)
|
||||
return fail("text_too_long", "panel");
|
||||
const note = cell(f, query.note) || null;
|
||||
if (note && note.length > LAB_TEXT_LIMITS.note)
|
||||
return fail("text_too_long", "note");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
line: record.line,
|
||||
analyte,
|
||||
value,
|
||||
valueText,
|
||||
unit,
|
||||
takenAt: date.instant,
|
||||
sourceReferenceLow: valueText === null ? low : null,
|
||||
sourceReferenceHigh: valueText === null ? high : null,
|
||||
sourceReferenceText: valueText === null ? rangeText : null,
|
||||
panel,
|
||||
note,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys a reading is a duplicate under, mirroring the scan commit's
|
||||
* `isDuplicate`: the marker (or the name, for a reading filed without one),
|
||||
* the reading's calendar day, and the value as stored, as printed, or as text.
|
||||
*/
|
||||
export function readingKeys(
|
||||
reading: {
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
takenAt: Date;
|
||||
value: number | null;
|
||||
valueText: string | null;
|
||||
sourceValue?: number | null;
|
||||
sourceUnit?: string | null;
|
||||
unit?: string | null;
|
||||
},
|
||||
tz: string,
|
||||
): string[] {
|
||||
const who = reading.biomarkerId
|
||||
? `bm:${reading.biomarkerId}`
|
||||
: `an:${reading.analyte.trim().toLowerCase()}`;
|
||||
const day = labReadingDay(reading.takenAt, tz);
|
||||
const keys: string[] = [];
|
||||
if (reading.value !== null) keys.push(`${who}|${day}|n:${reading.value}`);
|
||||
if (
|
||||
reading.sourceValue !== null &&
|
||||
reading.sourceValue !== undefined &&
|
||||
reading.sourceUnit
|
||||
) {
|
||||
keys.push(
|
||||
`${who}|${day}|s:${reading.sourceValue}:${reading.sourceUnit.trim().toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
if (reading.valueText !== null) {
|
||||
keys.push(`${who}|${day}|t:${reading.valueText.trim().toLowerCase()}`);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
interface Planned {
|
||||
row: LabCsvRow;
|
||||
marker: IndexedMarker | null;
|
||||
}
|
||||
|
||||
function displayRow(row: LabCsvRow, marker: string) {
|
||||
return {
|
||||
line: row.line,
|
||||
marker,
|
||||
value: row.value,
|
||||
valueText: row.valueText,
|
||||
unit: row.unit,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
referenceLow: row.sourceReferenceLow,
|
||||
referenceHigh: row.sourceReferenceHigh,
|
||||
panel: row.panel,
|
||||
note: row.note !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read, check and (unless a dry run) write a lab CSV. Throws
|
||||
* `CsvTooLargeError` past the byte cap; everything else is reported per row.
|
||||
*/
|
||||
export async function importLabsCsv(args: {
|
||||
userId: string;
|
||||
tz: string;
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
maxBytes: number;
|
||||
query: LabsCsvQuery;
|
||||
now?: Date;
|
||||
}): Promise<RecordImportResult> {
|
||||
const { userId, tz, query } = args;
|
||||
const dryRun = query.dryRun;
|
||||
const timezone = query.timezone ?? tz;
|
||||
const report = new RecordImportReport("labs", dryRun);
|
||||
const index: MarkerIndex = await loadMarkerIndex(prisma, userId);
|
||||
// A dry run works on a copy, so the markers it pretends to mint never leak.
|
||||
const working: MarkerIndex = dryRun
|
||||
? { markers: [...index.markers], aliases: [...index.aliases] }
|
||||
: index;
|
||||
const matched = new Map<
|
||||
string,
|
||||
{ name: string; marker: string; how: "name" | "alias" | "catalog" }
|
||||
>();
|
||||
const created = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
let chunk: LabCsvRow[] = [];
|
||||
let pendingId = 0;
|
||||
|
||||
const delimiter: CsvDelimiter | "auto" =
|
||||
query.delimiter === "comma"
|
||||
? ","
|
||||
: query.delimiter === "semicolon"
|
||||
? ";"
|
||||
: query.delimiter === "tab"
|
||||
? "\t"
|
||||
: "auto";
|
||||
|
||||
const flush = async () => {
|
||||
if (chunk.length === 0) return;
|
||||
const rows = chunk;
|
||||
chunk = [];
|
||||
|
||||
// 1. Resolve every row's marker; a dry run pretends to mint.
|
||||
const planned: Planned[] = [];
|
||||
for (const row of rows) {
|
||||
const hit = resolveInIndex(
|
||||
working,
|
||||
row.analyte,
|
||||
row.valueText === null ? row.unit : null,
|
||||
);
|
||||
let marker: IndexedMarker | null = hit?.marker ?? null;
|
||||
if (hit) {
|
||||
// Listed once per spelling, so the person sees which of the file's
|
||||
// names landed on which of their markers.
|
||||
const key = row.analyte.toLowerCase();
|
||||
if (!matched.has(key) && matched.size < MAX_LISTED_MARKERS) {
|
||||
matched.set(key, {
|
||||
name: row.analyte,
|
||||
marker: hit.marker.name,
|
||||
how: hit.matchKind,
|
||||
});
|
||||
}
|
||||
} else if (dryRun) {
|
||||
const unit = row.unit ?? "";
|
||||
marker = {
|
||||
id: `pending:${pendingId++}`,
|
||||
name: row.analyte,
|
||||
unit,
|
||||
lowerBound: row.sourceReferenceLow,
|
||||
upperBound: row.sourceReferenceHigh,
|
||||
panel: row.panel,
|
||||
analyteKey: matchAnalyteKey({ name: row.analyte, unit }),
|
||||
hidden: false,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
addToIndex(working, marker);
|
||||
if (created.size < MAX_LISTED_MARKERS) created.add(row.analyte);
|
||||
}
|
||||
// The unit must fit the marker, or convert into it.
|
||||
if (
|
||||
marker &&
|
||||
row.value !== null &&
|
||||
// The key as the write derives it (`ensureAnalyteKey`), so the check
|
||||
// and the conversion the import then runs cannot disagree.
|
||||
!unitFitsMarker(
|
||||
{
|
||||
unit: marker.unit,
|
||||
analyteKey:
|
||||
matchAnalyteKey({ name: marker.name, unit: marker.unit }) ??
|
||||
marker.analyteKey,
|
||||
},
|
||||
row.unit,
|
||||
null,
|
||||
)
|
||||
) {
|
||||
report.fail(row.line, "unit_mismatch", "unit");
|
||||
continue;
|
||||
}
|
||||
planned.push({ row, marker });
|
||||
}
|
||||
|
||||
// 2. Duplicates against the record, one query for the chunk.
|
||||
const days = planned.map((p) => labReadingDay(p.row.takenAt, tz)).sort();
|
||||
const existingKeys = new Set<string>();
|
||||
if (planned.length > 0) {
|
||||
const from = labReadingDaySearchRange(days[0]).gte;
|
||||
const to = labReadingDaySearchRange(days[days.length - 1]).lt;
|
||||
const ids = [
|
||||
...new Set(
|
||||
planned
|
||||
.map((p) => p.marker?.id)
|
||||
.filter((id): id is string => !!id && !id.startsWith("pending:")),
|
||||
),
|
||||
];
|
||||
const names = [...new Set(planned.map((p) => p.row.analyte))];
|
||||
const existing = await prisma.labResult.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
takenAt: { gte: from, lt: to },
|
||||
OR: [{ biomarkerId: { in: ids } }, { analyte: { in: names } }],
|
||||
},
|
||||
select: {
|
||||
biomarkerId: true,
|
||||
analyte: true,
|
||||
takenAt: true,
|
||||
value: true,
|
||||
valueText: true,
|
||||
sourceValue: true,
|
||||
sourceUnit: true,
|
||||
},
|
||||
});
|
||||
for (const e of existing) {
|
||||
for (const k of readingKeys(e, tz)) existingKeys.add(k);
|
||||
// A reading filed under a marker also answers to its printed name.
|
||||
if (e.biomarkerId) {
|
||||
for (const k of readingKeys({ ...e, biomarkerId: null }, tz))
|
||||
existingKeys.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Write (or count) what is new.
|
||||
for (const { row, marker } of planned) {
|
||||
const probe = {
|
||||
biomarkerId:
|
||||
marker && !marker.id.startsWith("pending:") ? marker.id : null,
|
||||
analyte: row.analyte,
|
||||
takenAt: row.takenAt,
|
||||
value: row.value,
|
||||
valueText: row.valueText,
|
||||
sourceValue: row.value,
|
||||
sourceUnit: row.unit,
|
||||
};
|
||||
const keys = [
|
||||
...readingKeys(probe, tz),
|
||||
...readingKeys({ ...probe, biomarkerId: null }, tz),
|
||||
...(marker
|
||||
? readingKeys({ ...probe, biomarkerId: marker.id }, tz)
|
||||
: []),
|
||||
];
|
||||
if (keys.some((k) => existingKeys.has(k) || seen.has(k))) {
|
||||
report.duplicate(row.line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
for (const k of keys) seen.add(k);
|
||||
report.valid += 1;
|
||||
report.inserted += 1;
|
||||
report.addPreview(displayRow(row, marker?.name ?? row.analyte));
|
||||
continue;
|
||||
}
|
||||
|
||||
const biomarker =
|
||||
marker ??
|
||||
(await resolveOrMintBiomarker(
|
||||
userId,
|
||||
{
|
||||
analyte: row.analyte,
|
||||
unit: row.unit ?? "",
|
||||
referenceLow:
|
||||
row.valueText === null ? row.sourceReferenceLow : null,
|
||||
referenceHigh:
|
||||
row.valueText === null ? row.sourceReferenceHigh : null,
|
||||
panel: row.panel,
|
||||
},
|
||||
{ index: working },
|
||||
));
|
||||
if (!marker) created.add(row.analyte);
|
||||
|
||||
let storedValue = row.value;
|
||||
let storedLow = row.sourceReferenceLow;
|
||||
let storedHigh = row.sourceReferenceHigh;
|
||||
let convertedFrom: { sourceValue: number; sourceUnit: string } | null =
|
||||
null;
|
||||
if (row.value !== null) {
|
||||
const reading = await readingInMarkerUnit(prisma, {
|
||||
marker: biomarker,
|
||||
value: row.value,
|
||||
unit: row.unit ?? undefined,
|
||||
sourceReferenceLow: storedLow,
|
||||
sourceReferenceHigh: storedHigh,
|
||||
});
|
||||
if (!reading.ok) {
|
||||
report.fail(row.line, "unit_mismatch", "unit");
|
||||
continue;
|
||||
}
|
||||
storedValue = reading.value;
|
||||
storedLow = reading.sourceReferenceLow;
|
||||
storedHigh = reading.sourceReferenceHigh;
|
||||
if (reading.converted) {
|
||||
convertedFrom = {
|
||||
sourceValue: reading.sourceValue,
|
||||
sourceUnit: reading.sourceUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Field by field, never a spread of the parsed row.
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId,
|
||||
biomarkerId: biomarker.id,
|
||||
panel: row.panel ?? biomarker.panel,
|
||||
analyte: biomarker.name,
|
||||
value: storedValue,
|
||||
valueText: row.valueText,
|
||||
unit: biomarker.unit,
|
||||
sourceValue: convertedFrom?.sourceValue ?? null,
|
||||
sourceUnit: convertedFrom?.sourceUnit ?? null,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
sourceReferenceLow: storedLow,
|
||||
sourceReferenceHigh: storedHigh,
|
||||
sourceReferenceText: row.sourceReferenceText,
|
||||
takenAt: row.takenAt,
|
||||
source: "IMPORT",
|
||||
noteEncrypted: row.note ? encryptNoteToBytes(row.note) : null,
|
||||
},
|
||||
});
|
||||
for (const k of [
|
||||
...keys,
|
||||
...readingKeys({ ...probe, biomarkerId: biomarker.id }, tz),
|
||||
])
|
||||
seen.add(k);
|
||||
report.valid += 1;
|
||||
report.inserted += 1;
|
||||
}
|
||||
};
|
||||
|
||||
let first = true;
|
||||
for await (const record of readCsvRecords(args.stream, {
|
||||
maxBytes: args.maxBytes,
|
||||
delimiter,
|
||||
})) {
|
||||
if (first && query.header) {
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
first = false;
|
||||
report.rows += 1;
|
||||
if (report.rows > LABS_CSV_MAX_ROWS) {
|
||||
report.fail(record.line, "row_limit");
|
||||
continue;
|
||||
}
|
||||
if (record.oversized) {
|
||||
report.fail(record.line, "oversized_row");
|
||||
continue;
|
||||
}
|
||||
const result = normaliseLabRow(record, query, timezone, args.now);
|
||||
if (!result.ok) {
|
||||
report.fail(record.line, result.reason, result.field);
|
||||
continue;
|
||||
}
|
||||
chunk.push(result.row);
|
||||
if (chunk.length >= CHUNK_ROWS) await flush();
|
||||
}
|
||||
await flush();
|
||||
|
||||
return report.toResult({
|
||||
markers: {
|
||||
matched: [...matched.values()],
|
||||
created: [...created].slice(0, MAX_LISTED_MARKERS),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* CSV import of medications (#1215).
|
||||
*
|
||||
* Conservative by design: a row maps only what is unambiguous in a
|
||||
* spreadsheet and in the medication model alike. Name and dose (as written),
|
||||
* daily intake times, an as-needed flag, and a start and end date. Every other
|
||||
* rhythm (weekdays, every other week, cycles, injections) is set up in the
|
||||
* app afterwards, where the person sees what it means. A row needs either
|
||||
* times or the as-needed mark: the medication model holds no scheduled
|
||||
* medication without a time, and an import does not invent one.
|
||||
*
|
||||
* Every row is checked against `createMedicationSchema`, the schema the
|
||||
* create route parses with, so an import can never store a medication the
|
||||
* form could not. Reminders stay off unless the import asks for them, so an
|
||||
* upload never starts notifying on its own. A medication the record already
|
||||
* holds under the same name and dose, or an earlier row of the file, is
|
||||
* skipped.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
readCsvRecords,
|
||||
type CsvDelimiter,
|
||||
type CsvRecord,
|
||||
} from "@/lib/import/csv-stream";
|
||||
import { parseCsvDate, parseYesNo } from "@/lib/import/csv-values";
|
||||
import { RecordImportReport } from "@/lib/import/record-import-report";
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
import { setMedicationCategory } from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import {
|
||||
dateOfDayKey,
|
||||
dayKeyOfDate,
|
||||
validateCourses,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { createMedicationSchema } from "@/lib/validations/medication";
|
||||
import type {
|
||||
MedicationsCsvQuery,
|
||||
RecordImportResult,
|
||||
RecordImportRowReason,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
export const MEDICATIONS_CSV_MAX_ROWS = 500;
|
||||
|
||||
export interface MedicationCsvRow {
|
||||
line: number;
|
||||
name: string;
|
||||
dose: string;
|
||||
times: string[];
|
||||
asNeeded: boolean;
|
||||
startsOn: string | null;
|
||||
endsOn: string | null;
|
||||
}
|
||||
|
||||
export type MedicationRowResult =
|
||||
| { ok: true; row: MedicationCsvRow }
|
||||
| { ok: false; reason: RecordImportRowReason; field: string };
|
||||
|
||||
function cell(fields: string[], index: number | undefined): string {
|
||||
if (index === undefined) return "";
|
||||
return (fields[index] ?? "").trim();
|
||||
}
|
||||
|
||||
/** `8:00`, `08:00 20:00`, `08:00|20:00`, `8.00; 20.00` → sorted `HH:mm`s. */
|
||||
export function parseTimes(raw: string): string[] | null {
|
||||
if (raw === "") return [];
|
||||
const parts = raw.split(/[\s,;|/]+/).filter(Boolean);
|
||||
const out = new Set<string>();
|
||||
for (const part of parts) {
|
||||
const m = /^(\d{1,2})[:.](\d{2})$/.exec(part);
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (h > 23 || min > 59) return null;
|
||||
out.add(`${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`);
|
||||
}
|
||||
const sorted = [...out].sort();
|
||||
return sorted.length > 8 ? null : sorted;
|
||||
}
|
||||
|
||||
/** One record → a medication row, or the first reason it cannot be one. Pure. */
|
||||
export function normaliseMedicationRow(
|
||||
record: Pick<CsvRecord, "line" | "fields">,
|
||||
query: MedicationsCsvQuery,
|
||||
timezone: string,
|
||||
): MedicationRowResult {
|
||||
const f = record.fields;
|
||||
const fail = (
|
||||
reason: RecordImportRowReason,
|
||||
field: string,
|
||||
): MedicationRowResult => ({ ok: false, reason, field });
|
||||
|
||||
const name = cell(f, query.name);
|
||||
if (!name) return fail("missing_value", "name");
|
||||
if (name.length > 100) return fail("text_too_long", "name");
|
||||
const dose = cell(f, query.dose);
|
||||
if (!dose) return fail("missing_value", "dose");
|
||||
if (dose.length > 50) return fail("text_too_long", "dose");
|
||||
|
||||
const times = parseTimes(cell(f, query.times));
|
||||
if (times === null) return fail("invalid_times", "times");
|
||||
|
||||
let asNeeded = false;
|
||||
const rawAsNeeded = cell(f, query.asNeeded);
|
||||
if (rawAsNeeded) {
|
||||
const yes = parseYesNo(rawAsNeeded);
|
||||
if (yes === null) return fail("invalid_yes_no", "asNeeded");
|
||||
asNeeded = yes;
|
||||
}
|
||||
if (asNeeded && times.length > 0)
|
||||
return fail("as_needed_with_times", "times");
|
||||
if (!asNeeded && times.length === 0) return fail("missing_times", "times");
|
||||
|
||||
const day = (field: "startsOn" | "endsOn") => {
|
||||
const raw = cell(f, query[field]);
|
||||
if (!raw) return { ok: true as const, key: null };
|
||||
const parsed = parseCsvDate(raw, undefined, {
|
||||
order: query.dateOrder,
|
||||
timezone,
|
||||
});
|
||||
if (!parsed.ok) return { ok: false as const, reason: parsed.reason };
|
||||
return { ok: true as const, key: parsed.dateKey };
|
||||
};
|
||||
const start = day("startsOn");
|
||||
if (!start.ok) return fail(start.reason, "startsOn");
|
||||
const end = day("endsOn");
|
||||
if (!end.ok) return fail(end.reason, "endsOn");
|
||||
if (start.key && end.key && end.key < start.key) {
|
||||
return fail("end_before_start", "endsOn");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
line: record.line,
|
||||
name,
|
||||
dose,
|
||||
times,
|
||||
asNeeded,
|
||||
startsOn: start.key,
|
||||
endsOn: end.key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The create body this row stands for, exactly as the form would send it. */
|
||||
export function medicationCreateBody(
|
||||
row: MedicationCsvRow,
|
||||
reminders: boolean,
|
||||
) {
|
||||
const windowEnd = (() => {
|
||||
if (row.times.length > 1) return row.times[row.times.length - 1];
|
||||
const [h, m] = (row.times[0] ?? "08:00").split(":").map(Number);
|
||||
return `${String((h + 1) % 24).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
})();
|
||||
return {
|
||||
name: row.name,
|
||||
dose: row.dose,
|
||||
asNeeded: row.asNeeded,
|
||||
notificationsEnabled: reminders && row.times.length > 0,
|
||||
...(row.startsOn ? { startsOn: row.startsOn } : {}),
|
||||
...(row.endsOn ? { endsOn: row.endsOn } : {}),
|
||||
...(row.times.length > 0
|
||||
? {
|
||||
schedules: [
|
||||
{
|
||||
windowStart: row.times[0],
|
||||
windowEnd,
|
||||
timesOfDay: row.times,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const identity = (name: string, dose: string) =>
|
||||
`${foldForMatch(name)}|${foldForMatch(dose).replace(/\s+/g, "")}`;
|
||||
|
||||
export async function importMedicationsCsv(args: {
|
||||
userId: string;
|
||||
tz: string;
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
maxBytes: number;
|
||||
query: MedicationsCsvQuery;
|
||||
now?: Date;
|
||||
}): Promise<RecordImportResult> {
|
||||
const { userId, tz, query } = args;
|
||||
const dryRun = query.dryRun;
|
||||
const timezone = query.timezone ?? tz;
|
||||
const report = new RecordImportReport("medications", dryRun);
|
||||
const todayKey = userDayKey(args.now ?? new Date(), tz);
|
||||
|
||||
const existing = await prisma.medication.findMany({
|
||||
where: { userId },
|
||||
select: { name: true, dose: true },
|
||||
});
|
||||
const seen = new Set(existing.map((m) => identity(m.name, m.dose)));
|
||||
|
||||
const delimiter: CsvDelimiter | "auto" =
|
||||
query.delimiter === "comma"
|
||||
? ","
|
||||
: query.delimiter === "semicolon"
|
||||
? ";"
|
||||
: query.delimiter === "tab"
|
||||
? "\t"
|
||||
: "auto";
|
||||
|
||||
let first = true;
|
||||
for await (const record of readCsvRecords(args.stream, {
|
||||
maxBytes: args.maxBytes,
|
||||
delimiter,
|
||||
})) {
|
||||
if (first && query.header) {
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
first = false;
|
||||
report.rows += 1;
|
||||
if (report.rows > MEDICATIONS_CSV_MAX_ROWS) {
|
||||
report.fail(record.line, "row_limit");
|
||||
continue;
|
||||
}
|
||||
if (record.oversized) {
|
||||
report.fail(record.line, "oversized_row");
|
||||
continue;
|
||||
}
|
||||
const result = normaliseMedicationRow(record, query, timezone);
|
||||
if (!result.ok) {
|
||||
report.fail(record.line, result.reason, result.field);
|
||||
continue;
|
||||
}
|
||||
const row = result.row;
|
||||
const parsed = createMedicationSchema.safeParse(
|
||||
medicationCreateBody(row, query.reminders),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
report.fail(row.line, "invalid_medication");
|
||||
continue;
|
||||
}
|
||||
// The course the create route would open for this window.
|
||||
let course: { startsOn: Date; endsOn: Date | null } | null = null;
|
||||
if (parsed.data.startsOn || parsed.data.endsOn) {
|
||||
const endKey = parsed.data.endsOn
|
||||
? dayKeyOfDate(parsed.data.endsOn)
|
||||
: null;
|
||||
course = {
|
||||
startsOn:
|
||||
parsed.data.startsOn ??
|
||||
dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey),
|
||||
endsOn: parsed.data.endsOn ?? null,
|
||||
};
|
||||
if (validateCourses([course], todayKey, false)) {
|
||||
report.fail(row.line, "end_before_start", "endsOn");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const key = identity(row.name, row.dose);
|
||||
if (seen.has(key)) {
|
||||
report.duplicate(row.line);
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
report.valid += 1;
|
||||
report.inserted += 1;
|
||||
if (dryRun) {
|
||||
report.addPreview({
|
||||
line: row.line,
|
||||
name: row.name,
|
||||
dose: row.dose,
|
||||
times: row.times.join(" "),
|
||||
asNeeded: row.asNeeded,
|
||||
startsOn: row.startsOn,
|
||||
endsOn: row.endsOn,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
// Field by field, with the create route's invariants for the shapes an
|
||||
// import produces: a daily schedule (FREQ=DAILY, times dual-written), an
|
||||
// opening course for a dated window, no schedule for as-needed.
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: data.name,
|
||||
dose: data.dose,
|
||||
asNeeded: data.asNeeded ?? false,
|
||||
notificationsEnabled: data.notificationsEnabled ?? false,
|
||||
...(course
|
||||
? {
|
||||
startsOn: course.startsOn,
|
||||
endsOn: course.endsOn,
|
||||
courses: {
|
||||
create: {
|
||||
userId,
|
||||
startsOn: course.startsOn,
|
||||
endsOn: course.endsOn,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
schedules: {
|
||||
create: (data.schedules ?? []).map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: serializeScheduleRecurrence({
|
||||
daysOfWeek: [],
|
||||
intervalWeeks: 1,
|
||||
}),
|
||||
timesOfDay:
|
||||
s.timesOfDay && s.timesOfDay.length > 0
|
||||
? s.timesOfDay
|
||||
: [s.windowStart],
|
||||
rrule: "FREQ=DAILY",
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await setMedicationCategory(medication.id, undefined);
|
||||
}
|
||||
|
||||
if (!dryRun && report.inserted > 0) {
|
||||
invalidateUserMedications(userId, { evict: true });
|
||||
}
|
||||
return report.toResult();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The per-import report both record importers fill (#1215): counts, the row
|
||||
* errors in file order and a short preview, each bounded so a file of twenty
|
||||
* thousand broken rows answers with a report of a fixed size.
|
||||
*/
|
||||
import type {
|
||||
RecordImportResult,
|
||||
RecordImportRowReason,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
export const MAX_REPORTED_ERRORS = 500;
|
||||
export const MAX_PREVIEW_ROWS = 20;
|
||||
|
||||
export type PreviewRow = Record<string, string | number | boolean | null>;
|
||||
|
||||
export class RecordImportReport {
|
||||
rows = 0;
|
||||
valid = 0;
|
||||
inserted = 0;
|
||||
duplicates = 0;
|
||||
failed = 0;
|
||||
private errors: RecordImportResult["errors"] = [];
|
||||
private truncated = false;
|
||||
private preview: PreviewRow[] = [];
|
||||
|
||||
constructor(
|
||||
readonly kind: RecordImportResult["kind"],
|
||||
readonly dryRun: boolean,
|
||||
) {}
|
||||
|
||||
private note(line: number, reason: RecordImportRowReason, field?: string) {
|
||||
if (this.errors.length < MAX_REPORTED_ERRORS) {
|
||||
this.errors.push(field ? { line, reason, field } : { line, reason });
|
||||
} else {
|
||||
this.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
fail(line: number, reason: RecordImportRowReason, field?: string) {
|
||||
this.failed += 1;
|
||||
this.note(line, reason, field);
|
||||
}
|
||||
|
||||
duplicate(line: number) {
|
||||
this.duplicates += 1;
|
||||
this.note(line, "duplicate");
|
||||
}
|
||||
|
||||
addPreview(row: PreviewRow) {
|
||||
if (this.dryRun && this.preview.length < MAX_PREVIEW_ROWS) {
|
||||
this.preview.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
toResult(
|
||||
extra: Pick<RecordImportResult, "markers"> = {},
|
||||
): RecordImportResult {
|
||||
// Errors arrive per chunk; a chunk's duplicates are found after its
|
||||
// refusals, so put the list back in file order.
|
||||
const errors = [...this.errors].sort((a, b) => a.line - b.line);
|
||||
return {
|
||||
kind: this.kind,
|
||||
dryRun: this.dryRun,
|
||||
rows: this.rows,
|
||||
valid: this.valid,
|
||||
inserted: this.inserted,
|
||||
duplicates: this.duplicates,
|
||||
failed: this.failed,
|
||||
errors,
|
||||
errorsTruncated: this.truncated,
|
||||
...extra,
|
||||
preview: this.preview,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The request side the two CSV record imports share (#1215): owner-only auth,
|
||||
* the module gate, the rate buckets, the query parse, the content type and
|
||||
* the streamed body with its byte cap. Each route supplies its schema and its
|
||||
* importer; what a row becomes lives in `labs-csv.ts` / `medications-csv.ts`.
|
||||
*
|
||||
* Buckets: a dry run spends `import-preview:<id>` (30 an hour), because the
|
||||
* mapping screen re-checks the file whenever a column choice changes; the
|
||||
* real import spends the shared `import:<id>` bucket (5 an hour) every other
|
||||
* importer uses.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { CsvTooLargeError } from "@/lib/import/csv-stream";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { RecordImportResult } from "@/lib/validations/record-import";
|
||||
|
||||
export const RECORD_IMPORT_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const PREVIEW_PER_HOUR = 30;
|
||||
const IMPORT_PER_HOUR = 5;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export async function handleRecordImport<
|
||||
S extends z.ZodType<{ dryRun: boolean }>,
|
||||
>(
|
||||
request: NextRequest,
|
||||
opts: {
|
||||
kind: RecordImportResult["kind"];
|
||||
module: ModuleKey;
|
||||
schema: S;
|
||||
run: (args: {
|
||||
userId: string;
|
||||
tz: string;
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
maxBytes: number;
|
||||
query: z.infer<S>;
|
||||
}) => Promise<RecordImportResult>;
|
||||
},
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const action = `import.${opts.kind}.csv`;
|
||||
annotate({ action: { name: action } });
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, opts.module);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = opts.schema.safeParse(
|
||||
Object.fromEntries(new URL(request.url).searchParams),
|
||||
);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const query = parsed.data;
|
||||
|
||||
const rl = query.dryRun
|
||||
? await checkRateLimit(
|
||||
`import-preview:${user.id}`,
|
||||
PREVIEW_PER_HOUR,
|
||||
HOUR_MS,
|
||||
)
|
||||
: await checkRateLimit(`import:${user.id}`, IMPORT_PER_HOUR, HOUR_MS);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
query.dryRun
|
||||
? `Maximum ${PREVIEW_PER_HOUR} checks per hour`
|
||||
: `Maximum ${IMPORT_PER_HOUR} imports per hour`,
|
||||
429,
|
||||
{ errorCode: "import.rate_limited" },
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
||||
if (
|
||||
!contentType.includes("text/csv") &&
|
||||
!contentType.includes("text/plain")
|
||||
) {
|
||||
return apiError("Content-Type must be text/csv", 415, {
|
||||
errorCode: "import.csv.content_type",
|
||||
});
|
||||
}
|
||||
const declared = Number(request.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(declared) && declared > RECORD_IMPORT_MAX_BYTES) {
|
||||
return apiError("CSV exceeds the 16 MB limit", 413, {
|
||||
errorCode: "import.csv.too_large",
|
||||
});
|
||||
}
|
||||
if (!request.body) {
|
||||
return apiError("The CSV file is empty", 422, {
|
||||
errorCode: "import.csv.empty",
|
||||
});
|
||||
}
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
let result: RecordImportResult;
|
||||
try {
|
||||
result = await opts.run({
|
||||
userId: user.id,
|
||||
tz,
|
||||
stream: request.body,
|
||||
maxBytes: RECORD_IMPORT_MAX_BYTES,
|
||||
query,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CsvTooLargeError) {
|
||||
return apiError("CSV exceeds the 16 MB limit", 413, {
|
||||
errorCode: "import.csv.too_large",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (result.rows === 0) {
|
||||
return apiError("The CSV file has no data rows", 422, {
|
||||
errorCode: "import.csv.empty",
|
||||
});
|
||||
}
|
||||
|
||||
if (!query.dryRun) {
|
||||
await auditLog(action, {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
rows: result.rows,
|
||||
inserted: result.inserted,
|
||||
duplicates: result.duplicates,
|
||||
failed: result.failed,
|
||||
},
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
import_dry_run: query.dryRun,
|
||||
import_rows: result.rows,
|
||||
import_inserted: result.inserted,
|
||||
import_duplicates: result.duplicates,
|
||||
import_failed: result.failed,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
|
||||
ai: [
|
||||
"ai.budget.exceeded",
|
||||
"ai.provider.none",
|
||||
@@ -225,7 +226,14 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"illness.episode.parent-not-found",
|
||||
"illness.insights.invalid",
|
||||
],
|
||||
import: ["import.apple_health.busy", "import.write_failed"],
|
||||
import: [
|
||||
"import.apple_health.busy",
|
||||
"import.csv.content_type",
|
||||
"import.csv.empty",
|
||||
"import.csv.too_large",
|
||||
"import.rate_limited",
|
||||
"import.write_failed",
|
||||
],
|
||||
ingest: ["ingest.rate_limited"],
|
||||
insights: [
|
||||
"insights.generate.budgetExceeded",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* OpenAPI route table — the activity calendar and activity goals (D1036).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas
|
||||
* are the runtime ones from `@/lib/validations/activity-*`.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import {
|
||||
activityCalendarQuerySchema,
|
||||
activityCalendarResponseSchema,
|
||||
activityGoalsResponseSchema,
|
||||
} from "@/lib/validations/activity-calendar";
|
||||
import { activityGoalsSchema } from "@/lib/validations/activity-goals";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
MODULE_DISABLED_DESCRIPTION,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const activityGoalsPatch = activityGoalsSchema.meta({
|
||||
id: "ActivityGoalsPatch",
|
||||
description:
|
||||
"The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is absent stays as it is. Unknown keys are refused.",
|
||||
});
|
||||
|
||||
export const activityPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/activity/calendar": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Activity calendar against the person's own normal",
|
||||
description:
|
||||
"A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. Each day is placed against the person's own spread over the 84 days before the period; the period total against the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts module.",
|
||||
requestParams: { query: activityCalendarQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The calendar.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
activityCalendarResponseSchema,
|
||||
"ActivityCalendarEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/activity/goals": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Activity goals",
|
||||
description:
|
||||
"The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = not set; there is no default. Owner only; a delegate reads the goals inside the calendar.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The goals.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
activityGoalsResponseSchema,
|
||||
"ActivityGoalsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Set activity goals",
|
||||
description:
|
||||
"Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated session cannot change another person's goals.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: activityGoalsPatch } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The goals after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
activityGoalsResponseSchema,
|
||||
"ActivityGoalsPatchEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,41 @@ import {
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import {
|
||||
labsCsvQuerySchema,
|
||||
medicationsCsvQuerySchema,
|
||||
recordImportResultSchema,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
const RECORD_IMPORT_COMMON =
|
||||
"Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off.";
|
||||
|
||||
function recordImportResponses(id: string) {
|
||||
return {
|
||||
"200": {
|
||||
description: "Per-file and per-row outcome.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(recordImportResultSchema, id),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"403": {
|
||||
description:
|
||||
"`module.disabled`: the module the import writes to is switched off.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "`import.csv.too_large`: the file is larger than 16 MB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "`import.csv.content_type`: the body is not `text/csv`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const csvImportRowResult = z
|
||||
.object({
|
||||
@@ -351,6 +386,48 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/import/labs-csv": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
summary: "Import lab results from CSV",
|
||||
description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
|
||||
requestParams: { query: labsCsvQuerySchema },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"text/csv": {
|
||||
schema: {
|
||||
type: "string",
|
||||
example:
|
||||
"Analyte;Value;Unit;Date;Range\nHbA1c;5,4;%;01.03.2025;4,0-5,6\nUrine protein;negative;;01.03.2025;",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: recordImportResponses("LabsCsvImportEnvelope"),
|
||||
},
|
||||
},
|
||||
"/api/import/medications-csv": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
summary: "Import medications from CSV",
|
||||
description: `${RECORD_IMPORT_COMMON} Maps name, dose (as written), daily intake times, an as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless \`reminders=1\`. A medication the record holds under the same name and dose is a duplicate. At most 500 data rows.`,
|
||||
requestParams: { query: medicationsCsvQuerySchema },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"text/csv": {
|
||||
schema: {
|
||||
type: "string",
|
||||
example:
|
||||
"name,dose,times,as needed,start,end\nRamipril,5 mg,08:00,,2025-01-01,\nIbuprofen,400 mg,,yes,,",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: recordImportResponses("MedicationsCsvImportEnvelope"),
|
||||
},
|
||||
},
|
||||
"/api/import": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
recordEpochParameter,
|
||||
recordScopeParameter,
|
||||
} from "./account-sharing";
|
||||
import { activityPaths } from "./activity";
|
||||
import { provenancePaths } from "./provenance";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { aiUsagePaths } from "./ai-usage";
|
||||
import { dawarichPaths } from "./dawarich";
|
||||
@@ -105,6 +107,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...syncPaths,
|
||||
...measurementPaths,
|
||||
...workoutPaths,
|
||||
...activityPaths,
|
||||
...provenancePaths,
|
||||
...devicePaths,
|
||||
...medicationPaths,
|
||||
...profilePaths,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* OpenAPI route table — source provenance (#1204, #1108, #576).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas
|
||||
* are the runtime ones from `@/lib/validations/source-provenance`.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import {
|
||||
dayProvenanceQuerySchema,
|
||||
dayProvenanceResponseSchema,
|
||||
sourceComparisonQuerySchema,
|
||||
sourceComparisonResponseSchema,
|
||||
} from "@/lib/validations/source-provenance";
|
||||
|
||||
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
|
||||
|
||||
export const provenancePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/measurements/provenance": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Which source a metric's day value comes from",
|
||||
description:
|
||||
"For one metric and one UTC day: every source with readings, its day value (the total for a cumulative metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and no pick: every source counts there. Read-only.",
|
||||
requestParams: { query: dayProvenanceQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The day's sources and the pick.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
dayProvenanceResponseSchema,
|
||||
"DayProvenanceEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...recordRefusal(),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurements/provenance/compare": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Two sources of one metric side by side",
|
||||
description:
|
||||
"Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. Without `a` / `b` the two sources covering the most days are compared. Read-only.",
|
||||
requestParams: { query: sourceComparisonQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The comparison.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
sourceComparisonResponseSchema,
|
||||
"SourceComparisonEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...recordRefusal(),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RANKED_TYPES } from "@/lib/analytics/source-rank-sql";
|
||||
|
||||
import { PROVENANCE_TYPES, isProvenanceType } from "../ranked-types";
|
||||
|
||||
describe("the provenance metric list", () => {
|
||||
it("is exactly the set of types the ladder ranks", () => {
|
||||
expect([...PROVENANCE_TYPES].sort()).toEqual([...RANKED_TYPES].sort());
|
||||
});
|
||||
it("answers membership", () => {
|
||||
expect(isProvenanceType("ACTIVITY_STEPS")).toBe(true);
|
||||
expect(isProvenanceType("BLOOD_GLUCOSE")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* The provenance explanation says what the canonical readers do.
|
||||
*
|
||||
* 1. The ladder the explanation reads (`ladderFor` + `ladderRank`) gives
|
||||
* every (type, source) the rank the SQL CASE from `buildSourceRankCase`
|
||||
* gives it, for the default ladders and for a reordered one. The CASE is
|
||||
* evaluated from its own text here, so a change to either side fails.
|
||||
* 2. The provenance query orders a day's sources by exactly the expression
|
||||
* `canonicalMeasurementsCte` picks with: the same rank CASE, then the
|
||||
* source name.
|
||||
* 3. The pick explanation and the comparison are right on hand-made rows.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryRawUnsafe: vi.fn(),
|
||||
priority: null as unknown,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { $queryRawUnsafe: mocks.queryRawUnsafe },
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => mocks.priority),
|
||||
}));
|
||||
|
||||
import {
|
||||
RANKED_TYPES,
|
||||
buildSourceRankCase,
|
||||
canonicalMeasurementsCte,
|
||||
} from "@/lib/analytics/source-rank-sql";
|
||||
import { measurementSourceEnum } from "@/lib/validations/measurement";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
OFF_LADDER_RANK,
|
||||
buildComparison,
|
||||
explainPick,
|
||||
ladderFor,
|
||||
ladderRank,
|
||||
readDayProvenance,
|
||||
type SourceRow,
|
||||
} from "../source-provenance";
|
||||
|
||||
/** Evaluate the CASE text `buildSourceRankCase` emits for one (type, source). */
|
||||
function evaluateCase(sql: string, type: string, source: string): number {
|
||||
const branch = new RegExp(
|
||||
`WHEN "type" = '${type}' THEN \\(CASE "source" (.*?) ELSE 90 END\\)`,
|
||||
).exec(sql);
|
||||
if (!branch) return 90;
|
||||
const when = new RegExp(`WHEN '${source}' THEN (\\d+)`).exec(branch[1]);
|
||||
return when ? Number(when[1]) : 90;
|
||||
}
|
||||
|
||||
const REORDERED = {
|
||||
metricPriority: {
|
||||
steps: ["OURA", "WHOOP", "APPLE_HEALTH"],
|
||||
sleep: ["WHOOP", "OURA"],
|
||||
hrvRmssd: ["OURA"],
|
||||
},
|
||||
};
|
||||
|
||||
describe("the explanation ranks exactly like the SQL", () => {
|
||||
it.each([
|
||||
["default ladders", null],
|
||||
["a reordered ladder", REORDERED],
|
||||
])("%s", (_label, priority) => {
|
||||
const sql = buildSourceRankCase(priority, '"type"', '"source"');
|
||||
let compared = 0;
|
||||
for (const type of RANKED_TYPES) {
|
||||
const { ladder } = ladderFor(priority, type);
|
||||
for (const source of measurementSourceEnum.options) {
|
||||
expect(ladderRank(ladder, source), `${type}/${source}`).toBe(
|
||||
evaluateCase(sql, type, source),
|
||||
);
|
||||
compared += 1;
|
||||
}
|
||||
}
|
||||
// A matcher that found no branch would agree with everything.
|
||||
expect(compared).toBeGreaterThan(100);
|
||||
expect(sql).toContain(`WHEN "type" = 'ACTIVITY_STEPS'`);
|
||||
});
|
||||
|
||||
it("a type without a ladder is not ranked", () => {
|
||||
expect(ladderFor(null, "BLOOD_GLUCOSE" as MeasurementType).ranked).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("a reordered ladder moves the rank", () => {
|
||||
const { ladder } = ladderFor(REORDERED, "ACTIVITY_STEPS");
|
||||
expect(ladderRank(ladder, "OURA")).toBe(0);
|
||||
expect(ladderRank(ladder, "WHOOP")).toBe(1);
|
||||
expect(ladderRank(ladder, "NOT_A_SOURCE")).toBe(OFF_LADDER_RANK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the provenance query orders like the canonical pick", () => {
|
||||
beforeEach(() => {
|
||||
mocks.queryRawUnsafe.mockReset();
|
||||
mocks.queryRawUnsafe.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it.each([null, REORDERED])("with priority %j", async (priority) => {
|
||||
mocks.priority = priority;
|
||||
await readDayProvenance({
|
||||
userId: "u1",
|
||||
type: "SLEEP_DURATION",
|
||||
date: "2026-10-01",
|
||||
});
|
||||
const [sql, userId, from, to] = mocks.queryRawUnsafe.mock.calls[0];
|
||||
const rank = buildSourceRankCase(priority, '"type"', '"source"');
|
||||
const squash = (s: string) => s.replace(/\s+/g, " ");
|
||||
expect(squash(sql)).toContain(
|
||||
squash(
|
||||
`ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`,
|
||||
),
|
||||
);
|
||||
expect(squash(canonicalMeasurementsCte(rank))).toContain(
|
||||
squash(
|
||||
`ORDER BY "type", date_trunc('day', "measured_at"), (${rank}), "source"::text`,
|
||||
),
|
||||
);
|
||||
// Values are bound, never spliced.
|
||||
expect(userId).toBe("u1");
|
||||
expect((from as Date).toISOString()).toBe("2026-10-01T00:00:00.000Z");
|
||||
expect((to as Date).toISOString()).toBe("2026-10-02T00:00:00.000Z");
|
||||
expect(sql).not.toContain("u1");
|
||||
});
|
||||
});
|
||||
|
||||
function row(source: string, rank: number, value: number): SourceRow {
|
||||
return {
|
||||
source,
|
||||
rank,
|
||||
readings: 1,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
firstAt: "2026-10-01T06:00:00.000Z",
|
||||
lastAt: "2026-10-01T06:00:00.000Z",
|
||||
unit: "min",
|
||||
devices: ["unknown"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("explainPick", () => {
|
||||
it("names the highest on the ladder and why the others lost", () => {
|
||||
const out = explainPick(true, [
|
||||
row("WHOOP", 0, 420),
|
||||
row("OURA", 2, 400),
|
||||
row("FITBIT", 90, 380),
|
||||
]);
|
||||
expect(out.picked).toBe("WHOOP");
|
||||
expect(out.pickReason).toBe("highest_on_ladder");
|
||||
expect(out.passes).toEqual([null, "lower_on_ladder", "not_on_ladder"]);
|
||||
});
|
||||
|
||||
it("calls a pick among off-ladder sources a name tiebreak", () => {
|
||||
const out = explainPick(true, [row("FITBIT", 90, 1), row("OURA", 90, 2)]);
|
||||
expect(out.pickReason).toBe("name_tiebreak");
|
||||
expect(out.passes).toEqual([null, "name_tiebreak"]);
|
||||
});
|
||||
|
||||
it("one source is simply the only one; an unranked type picks nothing", () => {
|
||||
expect(explainPick(true, [row("OURA", 1, 5)]).pickReason).toBe(
|
||||
"only_source",
|
||||
);
|
||||
const unranked = explainPick(false, [row("A", 90, 1), row("B", 90, 2)]);
|
||||
expect(unranked.picked).toBeNull();
|
||||
expect(unranked.pickReason).toBe("not_ranked");
|
||||
expect(explainPick(true, []).picked).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComparison", () => {
|
||||
const rows = [
|
||||
{ ...row("OURA", 1, 400), day: "2026-10-01" },
|
||||
{ ...row("WHOOP", 0, 430), day: "2026-10-01" },
|
||||
{ ...row("OURA", 1, 410), day: "2026-10-02" },
|
||||
{ ...row("OURA", 1, 390), day: "2026-10-03" },
|
||||
{ ...row("WHOOP", 0, 450), day: "2026-10-03" },
|
||||
].sort((x, y) => x.day.localeCompare(y.day) || x.rank - y.rank);
|
||||
|
||||
it("lines the two up per day and compares only days both cover", () => {
|
||||
const c = buildComparison({
|
||||
type: "SLEEP_DURATION",
|
||||
from: "2026-10-01",
|
||||
to: "2026-10-03",
|
||||
ranked: true,
|
||||
ladder: ["WHOOP", "APPLE_HEALTH", "OURA"],
|
||||
rows,
|
||||
a: "OURA",
|
||||
b: "WHOOP",
|
||||
});
|
||||
expect(c.days).toEqual([
|
||||
{ date: "2026-10-01", a: 400, b: 430, picked: "WHOOP" },
|
||||
{ date: "2026-10-02", a: 410, b: null, picked: "OURA" },
|
||||
{ date: "2026-10-03", a: 390, b: 450, picked: "WHOOP" },
|
||||
]);
|
||||
expect(c.summary).toMatchObject({
|
||||
daysA: 3,
|
||||
daysB: 2,
|
||||
daysBoth: 2,
|
||||
meanA: 395,
|
||||
meanB: 440,
|
||||
meanDifference: 45,
|
||||
meanAbsoluteDifference: 45,
|
||||
pickedA: 1,
|
||||
pickedB: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to the two sources that cover the most days", () => {
|
||||
const c = buildComparison({
|
||||
type: "SLEEP_DURATION",
|
||||
from: "2026-10-01",
|
||||
to: "2026-10-03",
|
||||
ranked: true,
|
||||
ladder: [],
|
||||
rows,
|
||||
});
|
||||
expect([c.a, c.b]).toEqual(["OURA", "WHOOP"]);
|
||||
expect(c.sources).toEqual([
|
||||
{ source: "OURA", days: 3 },
|
||||
{ source: "WHOOP", days: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The measurement types that carry a source-priority ladder, as a plain list
|
||||
* the browser can import (the server's `RANKED_TYPES` lives beside SQL
|
||||
* builders and the schema library). `ranked-types.test.ts` holds the two
|
||||
* lists equal, so the metric picker on the provenance page offers exactly
|
||||
* the types the ladder decides for.
|
||||
*/
|
||||
export const PROVENANCE_TYPES = [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"RESPIRATORY_RATE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
"SKIN_TEMPERATURE",
|
||||
"WEIGHT",
|
||||
"BODY_FAT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"VO2_MAX",
|
||||
"RECOVERY_SCORE",
|
||||
"STRESS_SCORE",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"FLIGHTS_CLIMBED",
|
||||
] as const;
|
||||
|
||||
export type ProvenanceType = (typeof PROVENANCE_TYPES)[number];
|
||||
|
||||
export function isProvenanceType(type: string): type is ProvenanceType {
|
||||
return (PROVENANCE_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Source provenance (#1204, #1108, #576): for one metric, which sources
|
||||
* delivered values on a day, which one the source-priority ladder picked and
|
||||
* why, and two sources side by side over a range.
|
||||
*
|
||||
* Read-only and computed by the SAME SQL the canonical readers use. The pick
|
||||
* is made by `ORDER BY (rank), "source"::text` over one UTC day of one type,
|
||||
* with the rank CASE from `buildSourceRankCase`: exactly the ordering inside
|
||||
* `canonicalMeasurementsCte`'s `DISTINCT ON`, which the charts, the rollups
|
||||
* and the summaries read through. The explanation is derived from the rank
|
||||
* that SQL returned, not recomputed beside it, so the page cannot say one
|
||||
* thing while the charts do another. `source-provenance.test.ts` holds the
|
||||
* TypeScript ladder reading to the CASE the SQL runs.
|
||||
*
|
||||
* Two facts the page states plainly because they follow from that rule:
|
||||
* - the pick is per UTC day, the unit every ladder read works in;
|
||||
* - a type without a ladder (glucose, body water, ...) is not collapsed at
|
||||
* all: every source's readings count, so no source is "picked".
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
RANKED_TYPES,
|
||||
buildSourceRankCase,
|
||||
} from "@/lib/analytics/source-rank-sql";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import {
|
||||
getSourceLadder,
|
||||
parseSourcePriority,
|
||||
} from "@/lib/validations/source-priority";
|
||||
|
||||
/** The rank the CASE gives a source that is not on the type's ladder. */
|
||||
export const OFF_LADDER_RANK = 90;
|
||||
|
||||
const ENUM_RE = /^[A-Z0-9_]+$/;
|
||||
|
||||
/** How one day's value of a source is formed: a day total, or a mean. */
|
||||
export function dayAggregation(type: MeasurementType): "sum" | "mean" {
|
||||
return type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type)
|
||||
? "sum"
|
||||
: "mean";
|
||||
}
|
||||
|
||||
export interface LadderReading {
|
||||
/** False for a type without a ladder: nothing is collapsed, nothing picked. */
|
||||
ranked: boolean;
|
||||
metricKey: string | null;
|
||||
/** The ladder as the person's settings resolve it, highest first. */
|
||||
ladder: string[];
|
||||
}
|
||||
|
||||
/** The ladder a type is ranked by, read the way `buildSourceRankCase` reads it. */
|
||||
export function ladderFor(
|
||||
priorityJson: unknown,
|
||||
type: MeasurementType,
|
||||
): LadderReading {
|
||||
const metricKey = metricKeyForType(type);
|
||||
if (!RANKED_TYPES.includes(type) || !metricKey) {
|
||||
return { ranked: false, metricKey: metricKey ?? null, ladder: [] };
|
||||
}
|
||||
const ladder = [
|
||||
...getSourceLadder(parseSourcePriority(priorityJson), metricKey),
|
||||
];
|
||||
return { ranked: ladder.length > 0, metricKey, ladder };
|
||||
}
|
||||
|
||||
/** The rank `buildSourceRankCase` assigns, computed from the ladder. */
|
||||
export function ladderRank(ladder: readonly string[], source: string): number {
|
||||
const i = ladder.indexOf(source);
|
||||
return i === -1 ? OFF_LADDER_RANK : i;
|
||||
}
|
||||
|
||||
export type PickReason =
|
||||
/** The day has one source; there was nothing to choose. */
|
||||
| "only_source"
|
||||
/** Picked because it stands highest on the ladder among the day's sources. */
|
||||
| "highest_on_ladder"
|
||||
/** None of the day's sources is on the ladder; the name decided. */
|
||||
| "name_tiebreak"
|
||||
/** No ladder for this type: every source counts. */
|
||||
| "not_ranked";
|
||||
|
||||
export type PassReason =
|
||||
/** Lower on the ladder than the picked source. */
|
||||
| "lower_on_ladder"
|
||||
/** Not on the ladder at all, below every source that is. */
|
||||
| "not_on_ladder"
|
||||
/** Same rank as the picked one (both off the ladder); the name decided. */
|
||||
| "name_tiebreak";
|
||||
|
||||
export interface SourceRow {
|
||||
source: string;
|
||||
/** The rank the SQL CASE returned; `OFF_LADDER_RANK` when not on the ladder. */
|
||||
rank: number;
|
||||
readings: number;
|
||||
/** The day's value from this source: total or mean (see `aggregation`). */
|
||||
value: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
firstAt: string;
|
||||
lastAt: string;
|
||||
unit: string | null;
|
||||
/** Device kinds the readings carry (watch, ring, ...); `unknown` when untagged. */
|
||||
devices: string[];
|
||||
}
|
||||
|
||||
export interface DayProvenance {
|
||||
type: MeasurementType;
|
||||
/** The UTC day the pick is made for. */
|
||||
date: string;
|
||||
aggregation: "sum" | "mean";
|
||||
ranked: boolean;
|
||||
metricKey: string | null;
|
||||
ladder: string[];
|
||||
/** The source the canonical readers use for this day; null when not ranked or empty. */
|
||||
picked: string | null;
|
||||
pickReason: PickReason | null;
|
||||
sources: Array<SourceRow & { pass: PassReason | null }>;
|
||||
}
|
||||
|
||||
interface RawSourceRow {
|
||||
source: string;
|
||||
rank: number;
|
||||
readings: number;
|
||||
total: number | null;
|
||||
asleep: number | null;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
first_at: Date;
|
||||
last_at: Date;
|
||||
unit: string | null;
|
||||
devices: string[] | null;
|
||||
}
|
||||
|
||||
/** UTC midnight to UTC midnight: the day the ladder pick is made for. */
|
||||
function utcDayBounds(date: string): { from: Date; to: Date } {
|
||||
return {
|
||||
from: dayKeyAsUtcMidnight(date),
|
||||
to: dayKeyAsUtcMidnight(shiftDateKey(date, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per (UTC day, source) aggregates for one type over `[from, to)`, ordered the
|
||||
* way the canonical pick orders them. The rank CASE and the tiebreak are
|
||||
* spliced from the same builder and in the same order as
|
||||
* `canonicalMeasurementsCte`; every request value is a bound parameter.
|
||||
*/
|
||||
async function readSourceDays(
|
||||
userId: string,
|
||||
priorityJson: unknown,
|
||||
type: MeasurementType,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<Array<RawSourceRow & { day: string }>> {
|
||||
if (!ENUM_RE.test(type)) {
|
||||
throw new Error(`unsafe enum literal for SQL splice: ${type}`);
|
||||
}
|
||||
const rank = buildSourceRankCase(priorityJson, '"type"', '"source"');
|
||||
return prisma.$queryRawUnsafe<Array<RawSourceRow & { day: string }>>(
|
||||
`
|
||||
SELECT
|
||||
to_char(date_trunc('day', "measured_at"), 'YYYY-MM-DD') AS day,
|
||||
"source"::text AS source,
|
||||
(${rank})::int AS rank,
|
||||
COUNT(*)::int AS readings,
|
||||
SUM("value")::double precision AS total,
|
||||
SUM("value") FILTER (
|
||||
WHERE "sleep_stage" IS NULL
|
||||
OR "sleep_stage" NOT IN ('AWAKE'::"sleep_stage", 'IN_BED'::"sleep_stage")
|
||||
)::double precision AS asleep,
|
||||
(SUM("value") / NULLIF(COUNT(*), 0))::double precision AS mean,
|
||||
MIN("value")::double precision AS min,
|
||||
MAX("value")::double precision AS max,
|
||||
MIN("measured_at") AS first_at,
|
||||
MAX("measured_at") AS last_at,
|
||||
MIN("unit") AS unit,
|
||||
ARRAY_AGG(DISTINCT COALESCE("device_type", 'unknown')) AS devices
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "type" = '${type}'::"measurement_type"
|
||||
AND "deleted_at" IS NULL
|
||||
AND "measured_at" >= $2
|
||||
AND "measured_at" < $3
|
||||
GROUP BY date_trunc('day', "measured_at"), "type", "source"
|
||||
ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text
|
||||
`,
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
function dayValue(type: MeasurementType, row: RawSourceRow): number | null {
|
||||
if (type === "SLEEP_DURATION") return row.asleep;
|
||||
return dayAggregation(type) === "sum" ? row.total : row.mean;
|
||||
}
|
||||
|
||||
function toSourceRow(type: MeasurementType, row: RawSourceRow): SourceRow {
|
||||
return {
|
||||
source: row.source,
|
||||
rank: row.rank,
|
||||
readings: row.readings,
|
||||
value: dayValue(type, row),
|
||||
min: row.min,
|
||||
max: row.max,
|
||||
firstAt: row.first_at.toISOString(),
|
||||
lastAt: row.last_at.toISOString(),
|
||||
unit: row.unit,
|
||||
devices: [...(row.devices ?? [])].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain a day's pick from rows already in pick order (rank, then name).
|
||||
* Pure, so the reasoning is tested without a database.
|
||||
*/
|
||||
export function explainPick(
|
||||
ranked: boolean,
|
||||
rows: readonly SourceRow[],
|
||||
): {
|
||||
picked: string | null;
|
||||
pickReason: PickReason | null;
|
||||
passes: Array<PassReason | null>;
|
||||
} {
|
||||
if (rows.length === 0) {
|
||||
return { picked: null, pickReason: null, passes: [] };
|
||||
}
|
||||
if (!ranked) {
|
||||
return {
|
||||
picked: null,
|
||||
pickReason: "not_ranked",
|
||||
passes: rows.map(() => null),
|
||||
};
|
||||
}
|
||||
const winner = rows[0];
|
||||
const pickReason: PickReason =
|
||||
rows.length === 1
|
||||
? "only_source"
|
||||
: winner.rank === OFF_LADDER_RANK
|
||||
? "name_tiebreak"
|
||||
: "highest_on_ladder";
|
||||
const passes = rows.map((row, i): PassReason | null => {
|
||||
if (i === 0) return null;
|
||||
if (row.rank === winner.rank) return "name_tiebreak";
|
||||
return row.rank === OFF_LADDER_RANK ? "not_on_ladder" : "lower_on_ladder";
|
||||
});
|
||||
return { picked: winner.source, pickReason, passes };
|
||||
}
|
||||
|
||||
export async function readDayProvenance(args: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
date: string;
|
||||
}): Promise<DayProvenance> {
|
||||
const { userId, type, date } = args;
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const ladder = ladderFor(priorityJson, type);
|
||||
const { from, to } = utcDayBounds(date);
|
||||
const raw = await readSourceDays(userId, priorityJson, type, from, to);
|
||||
const rows = raw.map((r) => toSourceRow(type, r));
|
||||
const { picked, pickReason, passes } = explainPick(ladder.ranked, rows);
|
||||
return {
|
||||
type,
|
||||
date,
|
||||
aggregation: dayAggregation(type),
|
||||
ranked: ladder.ranked,
|
||||
metricKey: ladder.metricKey,
|
||||
ladder: ladder.ladder,
|
||||
picked,
|
||||
pickReason,
|
||||
sources: rows.map((row, i) => ({ ...row, pass: passes[i] ?? null })),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompareDay {
|
||||
date: string;
|
||||
a: number | null;
|
||||
b: number | null;
|
||||
/** The source the canonical readers use that day (among all its sources). */
|
||||
picked: string | null;
|
||||
}
|
||||
|
||||
export interface SourceComparison {
|
||||
type: MeasurementType;
|
||||
from: string;
|
||||
to: string;
|
||||
aggregation: "sum" | "mean";
|
||||
ranked: boolean;
|
||||
ladder: string[];
|
||||
unit: string | null;
|
||||
/** Every source with readings in the range, by days covered. */
|
||||
sources: Array<{ source: string; days: number }>;
|
||||
a: string | null;
|
||||
b: string | null;
|
||||
days: CompareDay[];
|
||||
summary: {
|
||||
daysA: number;
|
||||
daysB: number;
|
||||
daysBoth: number;
|
||||
/** Means over the days both sources cover, so the two are comparable. */
|
||||
meanA: number | null;
|
||||
meanB: number | null;
|
||||
/** Mean of (b − a) over the days both cover. */
|
||||
meanDifference: number | null;
|
||||
meanAbsoluteDifference: number | null;
|
||||
/** Days each source was the one the ladder picked. */
|
||||
pickedA: number;
|
||||
pickedB: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Pure assembly of a comparison from per-(day, source) rows in pick order. */
|
||||
export function buildComparison(args: {
|
||||
type: MeasurementType;
|
||||
from: string;
|
||||
to: string;
|
||||
ranked: boolean;
|
||||
ladder: string[];
|
||||
rows: ReadonlyArray<SourceRow & { day: string }>;
|
||||
a?: string | null;
|
||||
b?: string | null;
|
||||
}): SourceComparison {
|
||||
const { type, from, to, ranked, ladder, rows } = args;
|
||||
const byDay = new Map<string, Array<SourceRow & { day: string }>>();
|
||||
const coverage = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const list = byDay.get(row.day) ?? [];
|
||||
list.push(row);
|
||||
byDay.set(row.day, list);
|
||||
coverage.set(row.source, (coverage.get(row.source) ?? 0) + 1);
|
||||
}
|
||||
const sources = [...coverage]
|
||||
.map(([source, days]) => ({ source, days }))
|
||||
.sort((x, y) => y.days - x.days || x.source.localeCompare(y.source));
|
||||
const a = args.a ?? sources[0]?.source ?? null;
|
||||
const b = args.b ?? sources.find((s) => s.source !== a)?.source ?? null;
|
||||
|
||||
const days: CompareDay[] = [...byDay.keys()].sort().map((date) => {
|
||||
const list = byDay.get(date) ?? [];
|
||||
const pick = explainPick(ranked, list);
|
||||
return {
|
||||
date,
|
||||
a: list.find((r) => r.source === a)?.value ?? null,
|
||||
b: list.find((r) => r.source === b)?.value ?? null,
|
||||
picked: pick.picked,
|
||||
};
|
||||
});
|
||||
|
||||
const both = days.filter((d) => d.a !== null && d.b !== null);
|
||||
const mean = (xs: number[]) =>
|
||||
xs.length === 0 ? null : xs.reduce((s, x) => s + x, 0) / xs.length;
|
||||
return {
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
aggregation: dayAggregation(type),
|
||||
ranked,
|
||||
ladder,
|
||||
unit: rows.find((r) => r.unit)?.unit ?? null,
|
||||
sources,
|
||||
a,
|
||||
b,
|
||||
days,
|
||||
summary: {
|
||||
daysA: days.filter((d) => d.a !== null).length,
|
||||
daysB: days.filter((d) => d.b !== null).length,
|
||||
daysBoth: both.length,
|
||||
meanA: mean(both.map((d) => d.a as number)),
|
||||
meanB: mean(both.map((d) => d.b as number)),
|
||||
meanDifference: mean(both.map((d) => (d.b as number) - (d.a as number))),
|
||||
meanAbsoluteDifference: mean(
|
||||
both.map((d) => Math.abs((d.b as number) - (d.a as number))),
|
||||
),
|
||||
pickedA: days.filter((d) => a !== null && d.picked === a).length,
|
||||
pickedB: days.filter((d) => b !== null && d.picked === b).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readSourceComparison(args: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
/** Inclusive UTC days. */
|
||||
from: string;
|
||||
to: string;
|
||||
a?: string | null;
|
||||
b?: string | null;
|
||||
}): Promise<SourceComparison> {
|
||||
const { userId, type, from, to } = args;
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const ladder = ladderFor(priorityJson, type);
|
||||
const raw = await readSourceDays(
|
||||
userId,
|
||||
priorityJson,
|
||||
type,
|
||||
utcDayBounds(from).from,
|
||||
utcDayBounds(to).to,
|
||||
);
|
||||
return buildComparison({
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
ranked: ladder.ranked,
|
||||
ladder: ladder.ladder,
|
||||
rows: raw.map((r) => ({ ...toSourceRow(type, r), day: r.day })),
|
||||
a: args.a,
|
||||
b: args.b,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Query keys — the activity calendar and activity goals (D1036).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const activityKeys = {
|
||||
/** Root of every activity-calendar cell; a goal save invalidates it. */
|
||||
activityCalendarAll: () => ["activity-calendar"] as const,
|
||||
activityCalendar: (period: "week" | "month", date: string | null) =>
|
||||
["activity-calendar", period, date ?? "today"] as const,
|
||||
activityGoals: () => ["activity-goals"] as const,
|
||||
};
|
||||
@@ -47,6 +47,8 @@ import { searchKeys } from "./search";
|
||||
import { sharingKeys } from "./sharing";
|
||||
import { vaccinationKeys } from "./vaccinations";
|
||||
import { workoutKeys } from "./workouts";
|
||||
import { activityKeys } from "./activity";
|
||||
import { provenanceKeys } from "./provenance";
|
||||
|
||||
export const queryKeys = {
|
||||
...authKeys,
|
||||
@@ -60,6 +62,8 @@ export const queryKeys = {
|
||||
...adminKeys,
|
||||
...integrationKeys,
|
||||
...workoutKeys,
|
||||
...activityKeys,
|
||||
...provenanceKeys,
|
||||
...settingsKeys,
|
||||
...cycleKeys,
|
||||
...measurementReminderKeys,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Query keys — the source-provenance reads (#1204).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const provenanceKeys = {
|
||||
provenanceDay: (type: string, date: string) =>
|
||||
["provenance", "day", type, date] as const,
|
||||
provenanceCompare: (opts: {
|
||||
type: string;
|
||||
from: string;
|
||||
to: string;
|
||||
a?: string;
|
||||
b?: string;
|
||||
}) => ["provenance", "compare", opts] as const,
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Query of `GET /api/activity/calendar` (D1036).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const activityCalendarQuerySchema = z.object({
|
||||
period: z
|
||||
.enum(["week", "month"])
|
||||
.default("month")
|
||||
.describe("Calendar span: one week (Monday to Sunday) or one month."),
|
||||
date: z.iso
|
||||
.date()
|
||||
.optional()
|
||||
.describe(
|
||||
"Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after today, or more than ten years back, is refused.",
|
||||
),
|
||||
});
|
||||
|
||||
export type ActivityCalendarQuery = z.infer<typeof activityCalendarQuerySchema>;
|
||||
|
||||
const dateKey = z.iso.date();
|
||||
|
||||
const dayLevelEnum = z
|
||||
.enum(["none", "unrated", "low", "below", "usual", "above", "high"])
|
||||
.describe(
|
||||
"Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.",
|
||||
);
|
||||
|
||||
const baselineStatsSchema = z
|
||||
.object({
|
||||
median: z.number(),
|
||||
p10: z.number(),
|
||||
p25: z.number(),
|
||||
p75: z.number(),
|
||||
p90: z.number(),
|
||||
days: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Days with a value the spread was computed from."),
|
||||
})
|
||||
.nullable()
|
||||
.meta({ id: "ActivityBaselineStats" });
|
||||
|
||||
const periodComparisonSchema = z
|
||||
.object({
|
||||
value: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The period so far: a total (steps, active minutes, workouts) or a mean (load).",
|
||||
),
|
||||
daysWithData: z.number().int(),
|
||||
usual: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data.",
|
||||
),
|
||||
usualToDate: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The usual figure for the days elapsed so far; equals `usual` once the period is over.",
|
||||
),
|
||||
referencePeriods: z.number().int(),
|
||||
})
|
||||
.meta({ id: "ActivityPeriodComparison" });
|
||||
|
||||
const goalProgressSchema = z
|
||||
.object({
|
||||
key: z.enum([
|
||||
"workoutsPerWeek",
|
||||
"activeMinutesPerWeek",
|
||||
"stepsPerWeek",
|
||||
"stepsPerMonth",
|
||||
]),
|
||||
target: z.number().int(),
|
||||
value: z.number(),
|
||||
met: z.boolean(),
|
||||
})
|
||||
.meta({ id: "ActivityGoalProgress" });
|
||||
|
||||
const goalsSchema = z
|
||||
.object({
|
||||
workoutsPerWeek: z.number().int().nullable(),
|
||||
activeMinutesPerWeek: z.number().int().nullable(),
|
||||
stepsPerWeek: z.number().int().nullable(),
|
||||
stepsPerMonth: z.number().int().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "ActivityGoals",
|
||||
description:
|
||||
"The person's own goals; `null` = not set. There is no default goal.",
|
||||
});
|
||||
|
||||
export const activityGoalsResponseSchema = goalsSchema;
|
||||
|
||||
export const activityCalendarResponseSchema = z
|
||||
.object({
|
||||
period: z.enum(["week", "month"]),
|
||||
start: dateKey,
|
||||
end: dateKey,
|
||||
gridStart: dateKey.describe(
|
||||
"First day drawn (the Monday on or before `start`).",
|
||||
),
|
||||
gridEnd: dateKey.describe("Last day drawn (the Sunday on or after `end`)."),
|
||||
today: dateKey,
|
||||
timezone: z.string().describe("The IANA zone the days are cut in."),
|
||||
loadType: z
|
||||
.enum(["DAY_STRAIN", "CARDIO_LOAD", "STRAIN_SCORE"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Which training-load score `load` carries; null when the account has none.",
|
||||
),
|
||||
days: z.array(
|
||||
z.object({
|
||||
date: dateKey,
|
||||
inPeriod: z.boolean(),
|
||||
future: z.boolean(),
|
||||
steps: z.number().nullable(),
|
||||
activeMinutes: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The larger of the day's exercise minutes and workout minutes (never their sum).",
|
||||
),
|
||||
workoutCount: z.number().int(),
|
||||
workoutsBySport: z.record(z.string(), z.number().int()),
|
||||
load: z.number().nullable(),
|
||||
level: z.object({
|
||||
steps: dayLevelEnum,
|
||||
activeMinutes: dayLevelEnum,
|
||||
load: dayLevelEnum,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
weeks: z.array(
|
||||
z.object({
|
||||
start: dateKey,
|
||||
end: dateKey,
|
||||
steps: z.number().nullable(),
|
||||
activeMinutes: z.number().nullable(),
|
||||
workouts: z.number().int(),
|
||||
goals: z
|
||||
.array(goalProgressSchema)
|
||||
.describe("The weekly goals for a week that has begun."),
|
||||
}),
|
||||
),
|
||||
baseline: z.object({
|
||||
steps: baselineStatsSchema,
|
||||
activeMinutes: baselineStatsSchema,
|
||||
load: baselineStatsSchema,
|
||||
}),
|
||||
summary: z.object({
|
||||
steps: periodComparisonSchema,
|
||||
activeMinutes: periodComparisonSchema,
|
||||
workouts: periodComparisonSchema,
|
||||
load: periodComparisonSchema,
|
||||
}),
|
||||
sports: z.array(
|
||||
z.object({ sportType: z.string(), count: z.number().int() }),
|
||||
),
|
||||
goals: goalsSchema,
|
||||
goalProgress: z.array(goalProgressSchema),
|
||||
available: z.object({
|
||||
steps: z.boolean(),
|
||||
activeMinutes: z.boolean(),
|
||||
workouts: z.boolean(),
|
||||
load: z.boolean(),
|
||||
}),
|
||||
previous: dateKey.describe("A day in the previous period."),
|
||||
next: dateKey
|
||||
.nullable()
|
||||
.describe(
|
||||
"A day in the next period, or null when that lies after today.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ActivityCalendar" });
|
||||
|
||||
export type ActivityCalendarResponse = z.infer<
|
||||
typeof activityCalendarResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Activity goals a person sets for themselves (D1036).
|
||||
*
|
||||
* Stored in `User.activityGoalsJson`. Every goal is optional and a `null`
|
||||
* clears it; an account that never set one stores nothing and reads as "no
|
||||
* goals". The calendar reports progress against the goals that are set and
|
||||
* says nothing about the ones that are not: there is no default goal and no
|
||||
* population norm here, only the person's own number.
|
||||
*
|
||||
* The keys and bounds live in `src/lib/activity/goal-bounds.ts` so the
|
||||
* browser can read them without the schema.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
ACTIVITY_GOAL_BOUNDS,
|
||||
ACTIVITY_GOAL_KEYS,
|
||||
type ActivityGoalKey,
|
||||
} from "@/lib/activity/goal-bounds";
|
||||
|
||||
export { ACTIVITY_GOAL_BOUNDS, ACTIVITY_GOAL_KEYS, type ActivityGoalKey };
|
||||
|
||||
function goalField(key: ActivityGoalKey) {
|
||||
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
|
||||
return z.number().int().min(min).max(max).nullable().optional();
|
||||
}
|
||||
|
||||
/** The stored shape and the PATCH body: every key optional, `null` clears. */
|
||||
export const activityGoalsSchema = z
|
||||
.object({
|
||||
workoutsPerWeek: goalField("workoutsPerWeek").describe(
|
||||
"Workouts per week (Monday to Sunday). `null` clears the goal.",
|
||||
),
|
||||
activeMinutesPerWeek: goalField("activeMinutesPerWeek").describe(
|
||||
"Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed over the week. `null` clears the goal.",
|
||||
),
|
||||
stepsPerWeek: goalField("stepsPerWeek").describe(
|
||||
"Steps per week. `null` clears the goal.",
|
||||
),
|
||||
stepsPerMonth: goalField("stepsPerMonth").describe(
|
||||
"Steps per calendar month. `null` clears the goal.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ActivityGoalsInput = z.infer<typeof activityGoalsSchema>;
|
||||
|
||||
/** The resolved goals: every key present, `null` = not set. */
|
||||
export type ActivityGoals = Record<ActivityGoalKey, number | null>;
|
||||
|
||||
export const NO_ACTIVITY_GOALS: ActivityGoals = {
|
||||
workoutsPerWeek: null,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: null,
|
||||
stepsPerMonth: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a stored value. Anything that does not parse (a hand-edited row, a
|
||||
* shape from a future release) reads key by key: a valid goal survives, an
|
||||
* invalid one reads as not set. Never throws.
|
||||
*/
|
||||
export function parseActivityGoals(raw: unknown): ActivityGoals {
|
||||
const out: ActivityGoals = { ...NO_ACTIVITY_GOALS };
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return out;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of ACTIVITY_GOAL_KEYS) {
|
||||
const parsed = goalField(key).safeParse(record[key]);
|
||||
if (parsed.success && typeof parsed.data === "number") {
|
||||
out[key] = parsed.data;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a validated PATCH to the stored goals: a key the body names replaces
|
||||
* the stored value (`null` clears it), a key it leaves out stays as it was.
|
||||
* Built key by key, never by spreading the body.
|
||||
*/
|
||||
export function mergeActivityGoals(
|
||||
current: ActivityGoals,
|
||||
patch: ActivityGoalsInput,
|
||||
): ActivityGoals {
|
||||
const next: ActivityGoals = { ...current };
|
||||
for (const key of ACTIVITY_GOAL_KEYS) {
|
||||
const value = patch[key];
|
||||
if (value !== undefined) next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** True when no goal is set, so the column can go back to null. */
|
||||
export function hasNoActivityGoals(goals: ActivityGoals): boolean {
|
||||
return ACTIVITY_GOAL_KEYS.every((key) => goals[key] === null);
|
||||
}
|
||||
@@ -2156,6 +2156,7 @@ const accountSettingsBackupSchema = z
|
||||
documentsLayoutJson: z.unknown().optional(),
|
||||
moduleListLayoutJson: z.unknown().optional(),
|
||||
previewFeaturesJson: z.unknown().optional(),
|
||||
activityGoalsJson: z.unknown().optional(),
|
||||
reportSelectionJson: z.unknown().optional(),
|
||||
globalExcludedInjectionSites: z.array(z.string()).optional(),
|
||||
healthKitConfigJson: z.unknown().optional(),
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Query and response schemas of the CSV record imports for lab results and
|
||||
* medications (#1215).
|
||||
*
|
||||
* The body of both routes is the raw CSV file (`text/csv`), streamed. How to
|
||||
* read it travels in the query: the column each field comes from (0-based
|
||||
* index into the file's columns), the separator, the decimal mark, the date
|
||||
* order and the zone a time without an offset is read in. The client works
|
||||
* these out with the person on the mapping screen; the server never guesses
|
||||
* a column.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { MAX_COLUMNS } from "@/lib/import/csv-stream";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
const column = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_COLUMNS - 1)
|
||||
.describe("0-based index of the file column this field is read from.");
|
||||
|
||||
const formatFields = {
|
||||
dryRun: z
|
||||
.enum(["1", "0", "true", "false"])
|
||||
.default("0")
|
||||
.transform((v) => v === "1" || v === "true")
|
||||
.describe(
|
||||
"`1` validates every row and reports what would happen, writing nothing.",
|
||||
),
|
||||
header: z
|
||||
.enum(["1", "0"])
|
||||
.default("1")
|
||||
.transform((v) => v === "1")
|
||||
.describe("`1` when the first row holds column names and is not data."),
|
||||
delimiter: z
|
||||
.enum(["auto", "comma", "semicolon", "tab"])
|
||||
.default("auto")
|
||||
.describe("Column separator; `auto` detects it from the first line."),
|
||||
decimal: z
|
||||
.enum(["dot", "comma"])
|
||||
.default("dot")
|
||||
.describe(
|
||||
"Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is refused per row as ambiguous.",
|
||||
),
|
||||
dateOrder: z
|
||||
.enum(["iso", "dmy", "mdy"])
|
||||
.default("iso")
|
||||
.describe(
|
||||
"`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit years are refused.",
|
||||
),
|
||||
timezone: z
|
||||
.string()
|
||||
.max(64)
|
||||
.refine(isValidTimezone, { message: "Unknown time zone" })
|
||||
.optional()
|
||||
.describe(
|
||||
"IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time is stored as that calendar date.",
|
||||
),
|
||||
};
|
||||
|
||||
function distinctColumns(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
): boolean {
|
||||
const used = keys
|
||||
.map((k) => value[k])
|
||||
.filter((v): v is number => typeof v === "number");
|
||||
return new Set(used).size === used.length;
|
||||
}
|
||||
|
||||
const LAB_COLUMN_KEYS = [
|
||||
"analyte",
|
||||
"value",
|
||||
"unit",
|
||||
"date",
|
||||
"time",
|
||||
"referenceLow",
|
||||
"referenceHigh",
|
||||
"referenceRange",
|
||||
"panel",
|
||||
"note",
|
||||
] as const;
|
||||
|
||||
export const labsCsvQuerySchema = z
|
||||
.object({
|
||||
...formatFields,
|
||||
analyte: column.describe("Column of the marker name (required)."),
|
||||
value: column.describe(
|
||||
"Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept as a qualitative result.",
|
||||
),
|
||||
unit: column
|
||||
.optional()
|
||||
.describe("Column of the unit. Required for a numeric result."),
|
||||
date: column.describe("Column of the date, or date and time (required)."),
|
||||
time: column.optional().describe("Column of the time, when separate."),
|
||||
referenceLow: column.optional(),
|
||||
referenceHigh: column.optional(),
|
||||
referenceRange: column
|
||||
.optional()
|
||||
.describe("Column of a printed range such as `4.0-5.6` or `< 200`."),
|
||||
panel: column.optional(),
|
||||
note: column.optional().describe("Column of a note; stored encrypted."),
|
||||
})
|
||||
.refine((v) => distinctColumns(v, LAB_COLUMN_KEYS), {
|
||||
message: "Each column can feed one field",
|
||||
path: ["analyte"],
|
||||
});
|
||||
|
||||
export type LabsCsvQuery = z.infer<typeof labsCsvQuerySchema>;
|
||||
|
||||
const MEDICATION_COLUMN_KEYS = [
|
||||
"name",
|
||||
"dose",
|
||||
"times",
|
||||
"asNeeded",
|
||||
"startsOn",
|
||||
"endsOn",
|
||||
] as const;
|
||||
|
||||
export const medicationsCsvQuerySchema = z
|
||||
.object({
|
||||
...formatFields,
|
||||
name: column.describe("Column of the medication name (required)."),
|
||||
dose: column.describe(
|
||||
"Column of the dose as written, e.g. `10 mg` (required).",
|
||||
),
|
||||
times: column
|
||||
.optional()
|
||||
.describe(
|
||||
"Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed.",
|
||||
),
|
||||
asNeeded: column
|
||||
.optional()
|
||||
.describe("Column marking an as-needed medication (yes/no)."),
|
||||
startsOn: column.optional().describe("Column of the start date."),
|
||||
endsOn: column.optional().describe("Column of the end date."),
|
||||
reminders: z
|
||||
.enum(["1", "0"])
|
||||
.default("0")
|
||||
.transform((v) => v === "1")
|
||||
.describe(
|
||||
"`1` turns reminders on for imported medications that have times. Off by default, so an import never starts reminding on its own.",
|
||||
),
|
||||
})
|
||||
.refine((v) => distinctColumns(v, MEDICATION_COLUMN_KEYS), {
|
||||
message: "Each column can feed one field",
|
||||
path: ["name"],
|
||||
});
|
||||
|
||||
export type MedicationsCsvQuery = z.infer<typeof medicationsCsvQuerySchema>;
|
||||
|
||||
export const RECORD_IMPORT_ROW_REASONS = [
|
||||
"missing_value",
|
||||
"invalid_number",
|
||||
"ambiguous_number",
|
||||
"missing_unit",
|
||||
"invalid_date",
|
||||
"invalid_time",
|
||||
"ambiguous_date",
|
||||
"future_date",
|
||||
"text_too_long",
|
||||
"unit_mismatch",
|
||||
"invalid_reference_range",
|
||||
"invalid_times",
|
||||
"missing_times",
|
||||
"invalid_yes_no",
|
||||
"as_needed_with_times",
|
||||
"end_before_start",
|
||||
"invalid_medication",
|
||||
"oversized_row",
|
||||
"row_limit",
|
||||
"duplicate",
|
||||
] as const;
|
||||
|
||||
export type RecordImportRowReason = (typeof RECORD_IMPORT_ROW_REASONS)[number];
|
||||
|
||||
export const recordImportResultSchema = z
|
||||
.object({
|
||||
kind: z.enum(["labs", "medications"]),
|
||||
dryRun: z.boolean(),
|
||||
rows: z.number().int().describe("Data rows read (the header excluded)."),
|
||||
valid: z.number().int().describe("Rows that passed every check."),
|
||||
inserted: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Rows written; in a dry run, the rows that would be."),
|
||||
duplicates: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Rows skipped because the record already holds them, or an earlier row of the file does. A re-upload of the same file therefore writes nothing.",
|
||||
),
|
||||
failed: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Rows refused; each is listed in `errors`."),
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
line: z.number().int().describe("1-based line of the file."),
|
||||
reason: z.enum(RECORD_IMPORT_ROW_REASONS),
|
||||
field: z.string().optional().describe("The mapped field at fault."),
|
||||
}),
|
||||
)
|
||||
.describe("Refused and duplicate rows, in file order, at most 500."),
|
||||
errorsTruncated: z.boolean(),
|
||||
markers: z
|
||||
.object({
|
||||
matched: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().describe("The name as the file writes it."),
|
||||
marker: z.string().describe("The marker it resolves to."),
|
||||
how: z.enum(["name", "alias", "catalog"]),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"File names that resolve to an existing marker, at most 200.",
|
||||
),
|
||||
created: z
|
||||
.array(z.string())
|
||||
.describe("Names that become new markers, at most 200."),
|
||||
})
|
||||
.optional()
|
||||
.describe("Lab imports only: how the file's names meet the markers."),
|
||||
preview: z
|
||||
.array(
|
||||
z.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null()]),
|
||||
),
|
||||
)
|
||||
.describe(
|
||||
"The first 20 valid rows as they will be stored (dry run only).",
|
||||
),
|
||||
})
|
||||
.meta({ id: "RecordImportResult" });
|
||||
|
||||
export type RecordImportResult = z.infer<typeof recordImportResultSchema>;
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Wire schemas of the source-provenance reads (#1204, #1108, #576).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
measurementSourceEnum,
|
||||
measurementTypeEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
|
||||
export const PROVENANCE_MAX_DAYS = 366;
|
||||
|
||||
export const dayProvenanceQuerySchema = z.object({
|
||||
type: measurementTypeEnum.describe("The metric."),
|
||||
date: z.iso
|
||||
.date()
|
||||
.describe(
|
||||
"The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is explained for.",
|
||||
),
|
||||
});
|
||||
|
||||
export const sourceComparisonQuerySchema = z
|
||||
.object({
|
||||
type: measurementTypeEnum.describe("The metric."),
|
||||
from: z.iso.date().describe("First UTC day, inclusive."),
|
||||
to: z.iso.date().describe("Last UTC day, inclusive."),
|
||||
a: measurementSourceEnum
|
||||
.optional()
|
||||
.describe("First source. Defaults to the source covering the most days."),
|
||||
b: measurementSourceEnum
|
||||
.optional()
|
||||
.describe("Second source. Defaults to the next one."),
|
||||
})
|
||||
.refine((q) => q.from <= q.to, {
|
||||
message: "from must not be after to",
|
||||
path: ["from"],
|
||||
})
|
||||
.refine(
|
||||
(q) =>
|
||||
(Date.parse(q.to) - Date.parse(q.from)) / 86_400_000 <
|
||||
PROVENANCE_MAX_DAYS,
|
||||
{ message: `At most ${PROVENANCE_MAX_DAYS} days`, path: ["to"] },
|
||||
);
|
||||
|
||||
const pickReasonEnum = z
|
||||
.enum(["only_source", "highest_on_ladder", "name_tiebreak", "not_ranked"])
|
||||
.describe(
|
||||
"`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this metric has no ladder, every source counts.",
|
||||
);
|
||||
|
||||
const passReasonEnum = z
|
||||
.enum(["lower_on_ladder", "not_on_ladder", "name_tiebreak"])
|
||||
.nullable()
|
||||
.describe("Why this source was not the one picked; null for the picked one.");
|
||||
|
||||
const sourceRowSchema = z.object({
|
||||
source: measurementSourceEnum,
|
||||
rank: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Position on the ladder, 0 = highest; 90 = not on the ladder."),
|
||||
readings: z.number().int(),
|
||||
value: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), the mean of the readings otherwise.",
|
||||
),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
firstAt: z.string().describe("ISO instant of the first reading that day."),
|
||||
lastAt: z.string(),
|
||||
unit: z.string().nullable(),
|
||||
devices: z
|
||||
.array(z.string())
|
||||
.describe("Device kinds the readings carry; `unknown` when untagged."),
|
||||
});
|
||||
|
||||
export const dayProvenanceResponseSchema = z
|
||||
.object({
|
||||
type: measurementTypeEnum,
|
||||
date: z.iso.date(),
|
||||
aggregation: z.enum(["sum", "mean"]),
|
||||
ranked: z.boolean(),
|
||||
metricKey: z.string().nullable(),
|
||||
ladder: z.array(measurementSourceEnum),
|
||||
picked: measurementSourceEnum.nullable(),
|
||||
pickReason: pickReasonEnum.nullable(),
|
||||
sources: z.array(sourceRowSchema.extend({ pass: passReasonEnum })),
|
||||
})
|
||||
.meta({ id: "DayProvenance" });
|
||||
|
||||
export const sourceComparisonResponseSchema = z
|
||||
.object({
|
||||
type: measurementTypeEnum,
|
||||
from: z.iso.date(),
|
||||
to: z.iso.date(),
|
||||
aggregation: z.enum(["sum", "mean"]),
|
||||
ranked: z.boolean(),
|
||||
ladder: z.array(measurementSourceEnum),
|
||||
unit: z.string().nullable(),
|
||||
sources: z.array(
|
||||
z.object({ source: measurementSourceEnum, days: z.number().int() }),
|
||||
),
|
||||
a: measurementSourceEnum.nullable(),
|
||||
b: measurementSourceEnum.nullable(),
|
||||
days: z.array(
|
||||
z.object({
|
||||
date: z.iso.date(),
|
||||
a: z.number().nullable(),
|
||||
b: z.number().nullable(),
|
||||
picked: measurementSourceEnum.nullable(),
|
||||
}),
|
||||
),
|
||||
summary: z.object({
|
||||
daysA: z.number().int(),
|
||||
daysB: z.number().int(),
|
||||
daysBoth: z.number().int(),
|
||||
meanA: z.number().nullable(),
|
||||
meanB: z.number().nullable(),
|
||||
meanDifference: z.number().nullable(),
|
||||
meanAbsoluteDifference: z.number().nullable(),
|
||||
pickedA: z.number().int(),
|
||||
pickedB: z.number().int(),
|
||||
}),
|
||||
})
|
||||
.meta({ id: "SourceComparison" });
|
||||
|
||||
export type DayProvenanceResponse = z.infer<typeof dayProvenanceResponseSchema>;
|
||||
export type SourceComparisonResponse = z.infer<
|
||||
typeof sourceComparisonResponseSchema
|
||||
>;
|
||||
+1
-1
@@ -865,6 +865,6 @@ export const config = {
|
||||
// the matcher and `apiHandler` performs the proxy's duties for it. The
|
||||
// alternation must stay equal to that list; `proxy-body-limit.test.ts`
|
||||
// compiles this exactly as Next does and checks.
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|medications/[^/]+/ingredients/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/labs-csv|/medications-csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|medications/[^/]+/ingredients/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user