mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(medications): identify an imported dose by when it was taken
The intake import built its replay key from the row's optional `zaehler` field. That reads as a per-row counter — the documented example showed one — but nothing required it to be unique and nothing checked. A reported export carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry queued under the same key, one dose reached the ledger, and the other 27 came back reported as duplicates of a dose they had nothing to do with. The key is the instant now, and `zaehler` is out of the contract rather than accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on `(user_id, medication_id, scheduled_for, source)` already makes an imported dose unique per instant, so a re-import of a file first imported under the old key still dedups. The telling is the other half. Two different facts were counted as one number: entries of a submission that collide with each other, and doses the ledger already holds. They are separate reasons now, grouped with a count, and the dialog renders them through the same classifier and the same outcome line as the CSV and JSON import cards — so an import that wrote nothing can no longer answer in success tone. That rule shipped for the settings import panel and this surface was not part of it; the guard now lists every import surface by path, so a fourth one has to be added deliberately. `skippedInvalid` is gone: it was hardcoded to zero on every path and reported a capability the importer never had. Alongside it, the other half of the environment report — a scheduled job that fails every run has to say so. `recordError()` increments one unnamed counter, so the admin status page could show a number and not which queue produced it, and the module's own screen showed a zero day count with nothing to distinguish "nothing stored yet" from "every run failed". pg-boss has held the answer the whole time: a job that exhausts its retries keeps its queue name, its error and its timestamp. The admin status now names the failing queues with counts and the last error, the environment overview reports its own account's failed runs, and neither needed a change at any of the thirty `recordError()` call sites — a visibility fix that has to be remembered per call site is the same failure one level up. Refs #650, #646
This commit is contained in:
1 parent
a8d798442f
commit
f0c5b5ae94
27 files changed
+1626
-154
No files matched your search
@@ -3,6 +3,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import {
|
||||
JOB_FAILURE_WINDOW_HOURS,
|
||||
readFailingQueues,
|
||||
} from "@/lib/jobs/job-failures";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Build timestamp (set at build time via next.config)
|
||||
@@ -57,6 +61,12 @@ export const GET = apiHandler(async () => {
|
||||
appSettings?.webPushVapidSubject,
|
||||
);
|
||||
const workerStatus = getWorkerStatus();
|
||||
// `worker.errors` is one unnamed counter, which is how a queue that failed on
|
||||
// every run for months could sit behind a number nobody could act on. The
|
||||
// queue itself knows which job broke and why, so that is what the status
|
||||
// reports. `null` = no queue schema to ask (web-only deployment); an empty
|
||||
// array = asked, nothing is failing.
|
||||
const failingJobs = await readFailingQueues();
|
||||
|
||||
return apiSuccess({
|
||||
version: process.env.npm_package_version ?? "0.1.0",
|
||||
@@ -66,6 +76,10 @@ export const GET = apiHandler(async () => {
|
||||
startTime: START_TIME,
|
||||
database: "connected",
|
||||
worker: workerStatus,
|
||||
failingJobs:
|
||||
failingJobs === null
|
||||
? null
|
||||
: { windowHours: JOB_FAILURE_WINDOW_HOURS, queues: failingJobs },
|
||||
counts: {
|
||||
users: userCount,
|
||||
measurements: measurementCount,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo";
|
||||
import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch";
|
||||
import { readQueueFailureForUser } from "@/lib/jobs/job-failures";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,37 +23,43 @@ export const GET = apiHandler(async () => {
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [profile, travel, contextCount, latest] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
homeSince: true,
|
||||
timezone: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentContext.count({ where: { userId: user.id } }),
|
||||
prisma.environmentContext.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true, fetchedAt: true },
|
||||
}),
|
||||
]);
|
||||
const [profile, travel, contextCount, latest, lastFetchFailure] =
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
homeSince: true,
|
||||
timezone: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentContext.count({ where: { userId: user.id } }),
|
||||
prisma.environmentContext.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true, fetchedAt: true },
|
||||
}),
|
||||
// A day count on its own cannot tell an empty module from a broken one —
|
||||
// which is exactly how a job that failed on every run since the module
|
||||
// shipped could render as "0 days recorded" and nothing else. The queue
|
||||
// knows; the overview asks it.
|
||||
readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id),
|
||||
]);
|
||||
|
||||
const home =
|
||||
profile?.homeLat != null && profile?.homeLon != null
|
||||
@@ -72,6 +80,7 @@ export const GET = apiHandler(async () => {
|
||||
has_home: home !== null,
|
||||
travel_count: travel.length,
|
||||
context_days: contextCount,
|
||||
last_fetch_failed: lastFetchFailure !== null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,6 +92,10 @@ export const GET = apiHandler(async () => {
|
||||
latestDate: latest?.date ?? null,
|
||||
latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null,
|
||||
},
|
||||
// null = the last background runs did not fail (or there is no queue to
|
||||
// ask). Non-null names the failure so a zero day count is never left to
|
||||
// stand alone. No error text: that message is written for an operator.
|
||||
lastFetchFailure,
|
||||
attribution: OPEN_METEO_ATTRIBUTION,
|
||||
});
|
||||
});
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{ "datum": "2026-05-31", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-01", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-02", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-03", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-04", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-05", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-06", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-07", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-08", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-09", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-10", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-11", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-12", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-13", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-14", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-15", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-16", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-17", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-18", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-19", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-20", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-21", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-22", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-23", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-24", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-25", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-26", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-27", "uhrzeit": "08:30:00", "zaehler": 1.0 }
|
||||
]
|
||||
@@ -4,6 +4,9 @@
|
||||
* The request process validates and persists the complete payload, then
|
||||
* returns a polling handle without performing intake writes itself.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -233,14 +236,17 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
status: "queued",
|
||||
// A legacy `zaehler` on the first row rides along and decides nothing:
|
||||
// both keys come from the instant, so two different times can never
|
||||
// collapse onto one key again.
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
idempotencyKey: "import-m1-41",
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`,
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
idempotencyKey: expect.stringMatching(/^import-m1-\d+$/),
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`,
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
],
|
||||
@@ -248,7 +254,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
progress: expect.objectContaining({
|
||||
processed: 0,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
total: 2,
|
||||
}),
|
||||
}),
|
||||
@@ -412,3 +418,69 @@ describe("POST /api/medications/[id]/intake/import — future bound and limiter"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/import — the reported export (#650)", () => {
|
||||
// The file attached to the report: 28 doses, one per day, all at 08:30, and
|
||||
// `"zaehler": 1.0` on every single row — a dose quantity, not a row counter.
|
||||
// The old key derivation used that field verbatim, so all 28 entries queued
|
||||
// under `import-m1-1`, one row was written and 27 were reported back as
|
||||
// duplicates of each other.
|
||||
const REPORTED_EXPORT = JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, "fixtures", "reported-intake-export.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Array<{ datum: string; uhrzeit: string; zaehler: number }>;
|
||||
|
||||
it("is the reported shape (a fixture that drifted would prove nothing)", () => {
|
||||
expect(REPORTED_EXPORT).toHaveLength(28);
|
||||
expect(new Set(REPORTED_EXPORT.map((row) => row.zaehler)).size).toBe(1);
|
||||
expect(
|
||||
new Set(REPORTED_EXPORT.map((row) => `${row.datum}T${row.uhrzeit}`)).size,
|
||||
).toBe(28);
|
||||
});
|
||||
|
||||
it("queues 28 distinct doses instead of collapsing them onto one key", async () => {
|
||||
const res = await POST(postReq(REPORTED_EXPORT), ROUTE_CTX);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock
|
||||
.calls[0][0] as unknown as {
|
||||
data: {
|
||||
payload: {
|
||||
entries: Array<{ takenAt: string; idempotencyKey: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const entries = created.data.payload.entries;
|
||||
expect(entries).toHaveLength(28);
|
||||
expect(new Set(entries.map((entry) => entry.idempotencyKey)).size).toBe(28);
|
||||
expect(new Set(entries.map((entry) => entry.takenAt)).size).toBe(28);
|
||||
// The key is a function of the instant and nothing else.
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const row = REPORTED_EXPORT[index];
|
||||
const instant = Date.parse(`${row.datum}T${row.uhrzeit}`);
|
||||
expect(entry.idempotencyKey).toBe(`import-m1-${instant}`);
|
||||
expect(entry.takenAt).toBe(new Date(instant).toISOString());
|
||||
}
|
||||
});
|
||||
|
||||
it("keys two rows at the same instant identically, whatever else they carry", async () => {
|
||||
await POST(
|
||||
postReq([
|
||||
{ datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 7 },
|
||||
{ datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 9 },
|
||||
]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock
|
||||
.calls[0][0] as unknown as {
|
||||
data: { payload: { entries: Array<{ idempotencyKey: string }> } };
|
||||
};
|
||||
const keys = created.data.payload.entries.map(
|
||||
(entry) => entry.idempotencyKey,
|
||||
);
|
||||
expect(keys[0]).toBe(keys[1]);
|
||||
});
|
||||
});
|
||||
@@ -21,14 +21,31 @@ import {
|
||||
type MedicationImportProgress,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
// The entry shape carried a third field, `zaehler`, and the replay key was
|
||||
// built from it whenever it was present. That reads as a per-row counter,
|
||||
// which is what the documented example showed — but the field was optional,
|
||||
// its uniqueness was never stated and never checked, and an export that puts
|
||||
// the dose quantity there (1 tablet, on every row) collapsed a month of
|
||||
// history onto one key: the first row imported, every later one lost the
|
||||
// `(userId, idempotencyKey)` unique and came back reported as a duplicate of
|
||||
// a dose it had nothing to do with.
|
||||
//
|
||||
// `zaehler` is gone from the contract rather than accepted-and-ignored. A
|
||||
// payload that still carries it imports unchanged — `z.object` drops
|
||||
// unrecognised keys — it simply no longer decides anything. The key now comes
|
||||
// from the instant (`buildMedicationImportEntries`), which is also the grain
|
||||
// the database already enforces: the live-row unique on
|
||||
// `(user_id, medication_id, scheduled_for, source)` makes an imported dose
|
||||
// unique per instant whatever the key says, so a re-import of a file first
|
||||
// imported under the old key still dedups.
|
||||
const importEntrySchema = z.object({
|
||||
datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
uhrzeit: z.string().regex(/^\d{2}:\d{2}:\d{2}$/),
|
||||
zaehler: z.union([z.number().int(), z.string()]).optional(),
|
||||
});
|
||||
|
||||
function isValidImportTimestamp(datum: string, uhrzeit: string): boolean {
|
||||
@@ -149,19 +166,13 @@ export const POST = apiHandler(
|
||||
|
||||
const entries = parsed.data;
|
||||
const normalized: MedicationImportPayload = {
|
||||
entries: entries.map((entry) => {
|
||||
const takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`);
|
||||
const idempotencyKey = entry.zaehler
|
||||
? `import-${id}-${String(entry.zaehler)}`
|
||||
: `import-${id}-${takenAt.getTime()}`;
|
||||
return { takenAt: takenAt.toISOString(), idempotencyKey };
|
||||
}),
|
||||
entries: buildMedicationImportEntries(id, entries),
|
||||
};
|
||||
const progress: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: normalized.entries.length,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
|
||||
@@ -45,6 +45,14 @@ export interface WorkerStatus {
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** One queue with exhausted-retry failures, as `/api/admin/status` reports it. */
|
||||
export interface FailingQueue {
|
||||
queue: string;
|
||||
failures: number;
|
||||
lastFailedAt: string;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
nodeVersion: string;
|
||||
@@ -53,6 +61,13 @@ export interface SystemStatus {
|
||||
startTime: string;
|
||||
database: string;
|
||||
worker: WorkerStatus;
|
||||
/**
|
||||
* Queues whose jobs exhausted their retries recently. `null` means the
|
||||
* deployment has no pg-boss schema to ask (web-only); an empty `queues`
|
||||
* array means it was asked and nothing is failing. The two are rendered
|
||||
* differently on purpose — absence is not health.
|
||||
*/
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
counts: {
|
||||
users: number;
|
||||
measurements: number;
|
||||
|
||||
@@ -23,7 +23,12 @@ import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
import {
|
||||
StatusItem,
|
||||
usePublicVersion,
|
||||
useSystemStatus,
|
||||
type FailingQueue,
|
||||
} from "./_shared";
|
||||
|
||||
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
|
||||
// metrics chart so the system-status page only ships it when an admin
|
||||
@@ -43,6 +48,83 @@ const HostMetricsChart = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The queues whose jobs gave up, named.
|
||||
*
|
||||
* Before this, a background job that threw on every single run showed up as one
|
||||
* digit in an unnamed `errors` counter, and the module it fed rendered an empty
|
||||
* screen as if empty were the answer. A broken queue has to say its own name,
|
||||
* so this card is rendered whenever there is anything to report and stays
|
||||
* silent otherwise — an operator should be able to trust that a quiet card
|
||||
* means quiet queues, which is why the "cannot ask" case renders its own line
|
||||
* rather than looking identical to health.
|
||||
*/
|
||||
function FailingJobsCard({
|
||||
failingJobs,
|
||||
}: {
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
|
||||
if (failingJobs === null) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-4 pl-7 text-sm">
|
||||
{t("admin.failingJobs.unavailable")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
{failingJobs.queues.length === 0 ? (
|
||||
<p
|
||||
data-testid="failing-jobs-none"
|
||||
className="text-muted-foreground mt-4 pl-7 text-sm"
|
||||
>
|
||||
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
|
||||
{failingJobs.queues.map((queue) => (
|
||||
<li key={queue.queue} className="text-sm">
|
||||
<p className="text-foreground font-medium">
|
||||
<span className="font-mono">{queue.queue}</span>{" "}
|
||||
<span className="text-destructive font-normal">
|
||||
{tCount("admin.failingJobs.count", queue.failures, {
|
||||
count: queue.failures,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.failingJobs.lastFailed", {
|
||||
when: formatDateTime(queue.lastFailedAt),
|
||||
})}
|
||||
</p>
|
||||
{queue.lastError.length > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 font-mono text-xs break-all">
|
||||
{queue.lastError}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -216,6 +298,7 @@ export function SystemStatusSection() {
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
{status && <FailingJobsCard failingJobs={status.failingJobs} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* #650 — the intake import says what actually happened.
|
||||
*
|
||||
* The report was a file of 28 doses where 27 were "skipped" and the dialog
|
||||
* answered in success tone with a duplicate count, so nothing on screen said
|
||||
* the history had not landed. The cause is fixed elsewhere; this pins the
|
||||
* telling, which is what turned a recoverable problem into a silent one.
|
||||
*
|
||||
* Project convention is SSR-only component tests (vitest runs `node`;
|
||||
* `@testing-library/react` is not installed), so the view takes its state as a
|
||||
* prop and each case is rendered directly.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
IntakeImportResultView,
|
||||
type IntakeImportResultState,
|
||||
} from "../intake-import-result";
|
||||
|
||||
/** `<li>` elements only — the lucide icon markup carries `<line>` tags. */
|
||||
function countListItems(html: string): number {
|
||||
return html.split("<li>").length - 1;
|
||||
}
|
||||
|
||||
function render(state: IntakeImportResultState): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<IntakeImportResultView result={state} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** What the reported run would have produced before the key was fixed. */
|
||||
const ALL_SKIPPED: IntakeImportResultState = {
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 27,
|
||||
skipReasons: [{ reason: "duplicate_in_file", count: 27 }],
|
||||
};
|
||||
|
||||
describe("<IntakeImportResultView> — an import that wrote nothing", () => {
|
||||
it("renders as a failure, never a success", () => {
|
||||
const html = render(ALL_SKIPPED);
|
||||
expect(html).toContain('data-outcome="failed"');
|
||||
expect(html).not.toContain('data-outcome="success"');
|
||||
expect(html).toContain("Nothing was imported");
|
||||
expect(html).toContain("27");
|
||||
});
|
||||
|
||||
it("names the skip reason once with its count, not once per entry", () => {
|
||||
const html = render(ALL_SKIPPED);
|
||||
expect(html).toContain("27 entries skipped");
|
||||
expect(html).toContain("same date and time");
|
||||
// One list item for one reason — 27 entries must not become 27 lines.
|
||||
expect(countListItems(html)).toBe(1);
|
||||
});
|
||||
|
||||
it("separates the two skip reasons instead of calling both duplicates", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 4,
|
||||
skipped: 5,
|
||||
skipReasons: [
|
||||
{ reason: "already_recorded", count: 3 },
|
||||
{ reason: "duplicate_in_file", count: 2 },
|
||||
],
|
||||
});
|
||||
expect(html).toContain("3 entries skipped");
|
||||
expect(html).toContain("already recorded");
|
||||
expect(html).toContain("2 entries skipped");
|
||||
expect(html).toContain("another entry in the same file");
|
||||
expect(countListItems(html)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeImportResultView> — the other outcomes", () => {
|
||||
it("renders a mixed run as a warning, not a tick", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 26,
|
||||
skipped: 2,
|
||||
skipReasons: [{ reason: "already_recorded", count: 2 }],
|
||||
});
|
||||
expect(html).toContain('data-outcome="partial"');
|
||||
expect(html).toContain("26 imported, 2 skipped");
|
||||
});
|
||||
|
||||
it("renders a clean run as a success", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 28,
|
||||
skipped: 0,
|
||||
skipReasons: [],
|
||||
});
|
||||
expect(html).toContain('data-outcome="success"');
|
||||
expect(html).toContain("28 intakes imported");
|
||||
expect(countListItems(html)).toBe(0);
|
||||
});
|
||||
|
||||
it("gives a file with no intakes its own message rather than either", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
skipReasons: [],
|
||||
});
|
||||
expect(html).toContain('data-outcome="empty"');
|
||||
expect(html).not.toContain('data-outcome="success"');
|
||||
expect(html).toContain("no intakes");
|
||||
});
|
||||
|
||||
it("falls back to a stated absence for an unrecognised reason", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "something_new", count: 1 }],
|
||||
});
|
||||
expect(html).toContain("reason not recorded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeImportResultView> — notices are not outcomes", () => {
|
||||
it("reports a loaded file in neutral tone, not as an import success", () => {
|
||||
const html = render({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: 'File "intakes.json" loaded',
|
||||
});
|
||||
expect(html).toContain('data-tone="info"');
|
||||
expect(html).not.toContain("text-success");
|
||||
expect(html).not.toContain("data-outcome");
|
||||
});
|
||||
|
||||
it("reports a refusal in destructive tone", () => {
|
||||
const html = render({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: "Import failed",
|
||||
});
|
||||
expect(html).toContain("text-destructive");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
|
||||
|
||||
import {
|
||||
IntakeImportResultView,
|
||||
type IntakeImportResultState,
|
||||
} from "@/components/medications/intake-import-result";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
@@ -51,10 +56,7 @@ export function IntakeImportDialog({
|
||||
const { t } = useTranslations();
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [resultType, setResultType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [result, setResult] = useState<IntakeImportResultState | null>(null);
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -62,7 +64,6 @@ export function IntakeImportDialog({
|
||||
function resetImportForm() {
|
||||
setJsonText("");
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
setSelectedFileName(null);
|
||||
setFileInputKey((prev) => prev + 1);
|
||||
}
|
||||
@@ -77,18 +78,25 @@ export function IntakeImportDialog({
|
||||
if (!file) return;
|
||||
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
JSON.parse(content);
|
||||
setJsonText(content);
|
||||
setSelectedFileName(file.name);
|
||||
setResult(t("medications.importFileLoaded", { name: file.name }));
|
||||
setResultType("success");
|
||||
// A readable file is a step, not an import. It says so in neutral tone —
|
||||
// the success affordance belongs to a run that actually wrote rows.
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: t("medications.importFileLoaded", { name: file.name }),
|
||||
});
|
||||
} catch {
|
||||
setResult(t("medications.importInvalidJson"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: t("medications.importInvalidJson"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +104,6 @@ export function IntakeImportDialog({
|
||||
if (!medicationId || !jsonText.trim()) return;
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(jsonText.trim());
|
||||
@@ -114,37 +121,41 @@ export function IntakeImportDialog({
|
||||
statusUrl: string;
|
||||
}>(`/api/medications/${medicationId}/intake/import`, data);
|
||||
const d = await waitForMedicationIntakeImport(kickoff.statusUrl);
|
||||
setResult(
|
||||
t("medications.importResult", { imported: d.imported }) +
|
||||
(d.skippedDuplicates > 0
|
||||
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
|
||||
: "") +
|
||||
(d.skippedInvalid > 0
|
||||
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
setResult({
|
||||
kind: "outcome",
|
||||
imported: d.imported,
|
||||
skipped: d.skipped,
|
||||
skipReasons: d.skipReasons,
|
||||
});
|
||||
// An import can land today-dated doses, so clear the Today hero +
|
||||
// dashboard through the blessed helper (they are unmounted behind the
|
||||
// import dialog on the detail page).
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch (err) {
|
||||
if (err instanceof MedicationIntakeImportError) {
|
||||
setResult(t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: t("medications.importFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(err instanceof ApiError)) throw err;
|
||||
setResult(err.message || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: err.message || t("medications.importFailed"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
);
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text:
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -226,8 +237,8 @@ export function IntakeImportDialog({
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43"},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42"}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
@@ -239,15 +250,9 @@ export function IntakeImportDialog({
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div role="status" aria-live="polite">
|
||||
{result && <IntakeImportResultView result={result} />}
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* What the intake-import dialog says once an attempt has finished.
|
||||
*
|
||||
* Split out of the dialog so the outcome can be rendered and asserted on its
|
||||
* own — the dialog itself is a sheet with a query client and a file input, and
|
||||
* the thing that has to be right is the sentence at the end of the run.
|
||||
*
|
||||
* The rules are the ones the settings import panel already follows, reused
|
||||
* rather than restated: `classifyImportOutcome` decides the tone from the
|
||||
* counts, so an import that wrote nothing can never carry the success tick,
|
||||
* and `ImportOutcomeLine` owns the tick and the success colour. The skips are
|
||||
* grouped by reason with a count, because a file that trips one rule trips it
|
||||
* on every row and a per-row list is the same sentence a hundred times.
|
||||
*
|
||||
* This dialog was not covered when that work landed for the CSV and JSON
|
||||
* cards; the medication intake import is the third import surface and it now
|
||||
* answers the same way.
|
||||
*/
|
||||
import {
|
||||
classifyImportOutcome,
|
||||
type ImportOutcome,
|
||||
} from "@/components/settings/import-panel/import-result-state";
|
||||
import { ImportOutcomeLine } from "@/components/settings/import-panel/import-result-view";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** A skip group exactly as the import job reports it. */
|
||||
export interface IntakeImportSkipGroup {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `notice` covers everything that happened before the importer had an opinion —
|
||||
* a file was read, the JSON did not parse, the request was refused. `outcome`
|
||||
* is the import itself.
|
||||
*/
|
||||
export type IntakeImportResultState =
|
||||
| { kind: "notice"; tone: "info" | "error"; text: string }
|
||||
| {
|
||||
kind: "outcome";
|
||||
imported: number;
|
||||
skipped: number;
|
||||
skipReasons: IntakeImportSkipGroup[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Plain language per machine-readable skip reason. Written as literal `t(…)`
|
||||
* calls rather than a computed key so the i18n call-site coverage guard sees
|
||||
* every one of them.
|
||||
*/
|
||||
function useSkipReasonLabel(): (reason: string) => string {
|
||||
const { t } = useTranslations();
|
||||
const labels: Record<string, string> = {
|
||||
duplicate_in_file: t("medications.import.reasonDuplicateInFile"),
|
||||
already_recorded: t("medications.import.reasonAlreadyRecorded"),
|
||||
};
|
||||
const fallback = t("medications.import.reasonUnknown");
|
||||
return (reason: string) => labels[reason] ?? fallback;
|
||||
}
|
||||
|
||||
export function IntakeImportResultView({
|
||||
result,
|
||||
}: {
|
||||
result: IntakeImportResultState;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const labelFor = useSkipReasonLabel();
|
||||
|
||||
if (result.kind === "notice") {
|
||||
return (
|
||||
<p
|
||||
data-testid="intake-import-notice"
|
||||
data-tone={result.tone}
|
||||
className={`text-sm ${result.tone === "error" ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{result.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const outcome: ImportOutcome = classifyImportOutcome({
|
||||
written: result.imported,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
|
||||
const message = (() => {
|
||||
if (outcome === "empty") return t("medications.import.resultEmpty");
|
||||
if (outcome === "failed") {
|
||||
return t("medications.import.resultNothing", { skipped: result.skipped });
|
||||
}
|
||||
if (outcome === "partial") {
|
||||
return t("medications.import.resultPartial", {
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
}
|
||||
return t("medications.import.resultSuccess", { imported: result.imported });
|
||||
})();
|
||||
|
||||
return (
|
||||
<div data-testid="intake-import-result" className="space-y-1.5">
|
||||
<ImportOutcomeLine
|
||||
outcome={outcome}
|
||||
message={message}
|
||||
testId="intake-import-outcome"
|
||||
/>
|
||||
{result.skipReasons.length > 0 && (
|
||||
<ul
|
||||
data-testid="intake-import-skip-groups"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{result.skipReasons.map((group) => (
|
||||
<li key={group.reason}>
|
||||
{tCount("medications.import.skipGroup", group.count, {
|
||||
count: group.count,
|
||||
reason: labelFor(group.reason),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -64,6 +65,10 @@ interface EnvironmentOverview {
|
||||
latestDate: string | null;
|
||||
latestFetchedAt: string | null;
|
||||
};
|
||||
/** Set when the background weather fetch for this account recently gave up.
|
||||
* A zero day count alone cannot distinguish "nothing to store yet" from
|
||||
* "every run failed", so the count is never rendered on its own. */
|
||||
lastFetchFailure: { lastFailedAt: string; failures: number } | null;
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ function todayKey(): string {
|
||||
}
|
||||
|
||||
export function EnvironmentSection() {
|
||||
const { t } = useTranslations();
|
||||
const { t, tCount } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -500,6 +505,22 @@ export function EnvironmentSection() {
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data?.lastFetchFailure && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="environment-fetch-failed"
|
||||
className="text-destructive mt-1 pl-7 text-xs"
|
||||
>
|
||||
{tCount(
|
||||
"settings.sections.environment.fetchFailed",
|
||||
data.lastFetchFailure.failures,
|
||||
{
|
||||
count: data.lastFetchFailure.failures,
|
||||
when: formatDateTime(data.lastFetchFailure.lastFailedAt),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Attribution (CC BY 4.0, required) */}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
* exactly one key of the outcome table, and no card reaches for them
|
||||
* directly. A future card that hand-rolls a green tick fails here rather than
|
||||
* shipping and lying to a self-hoster about their sensor history.
|
||||
*
|
||||
* #650 widened it. The medication intake import is a third import surface, in
|
||||
* another directory, and it was not covered when the rule landed — it kept its
|
||||
* own success/error tone and reported a run that wrote one row out of
|
||||
* twenty-eight as a success. "Every import surface" now means every one of
|
||||
* them, listed by path, so a fourth surface has to be added here deliberately
|
||||
* rather than inheriting the old behaviour by being somewhere else.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
@@ -14,6 +21,34 @@ import { join } from "node:path";
|
||||
|
||||
const PANEL_DIR = join(__dirname, "..");
|
||||
const PRESENTATION_FILE = "import-result-view.tsx";
|
||||
const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..");
|
||||
|
||||
/**
|
||||
* Every surface that reports an import result, and the module each one must
|
||||
* render its outcome through. Paths from the repository root.
|
||||
*/
|
||||
const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [
|
||||
{
|
||||
path: "src/components/settings/import-panel/csv-import-card.tsx",
|
||||
through: /CsvImportResultView|ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/json-import-card.tsx",
|
||||
through: /CsvImportResultView|ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/apple-health-import-card.tsx",
|
||||
through: /CsvImportResultView|ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/medications/intake-import-result.tsx",
|
||||
through: /ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/medications/intake-import-dialog.tsx",
|
||||
through: /IntakeImportResultView/,
|
||||
},
|
||||
];
|
||||
|
||||
/** The success affordance: the tick component and the success colour token. */
|
||||
const SUCCESS_MARKERS = ["CheckCircle2", "text-success"] as const;
|
||||
@@ -25,10 +60,16 @@ function panelSourceFiles(): string[] {
|
||||
}
|
||||
|
||||
/** Code only — a comment naming the marker is prose, not an affordance. */
|
||||
function stripComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
function readCode(name: string): string {
|
||||
return readFileSync(join(PANEL_DIR, name), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
return stripComments(readFileSync(join(PANEL_DIR, name), "utf8"));
|
||||
}
|
||||
|
||||
function readRepoCode(relativePath: string): string {
|
||||
return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8"));
|
||||
}
|
||||
|
||||
describe("import panel — success affordance is owned by the outcome table", () => {
|
||||
@@ -63,13 +104,27 @@ describe("import panel — success affordance is owned by the outcome table", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("routes every card's result line through the shared outcome line", () => {
|
||||
for (const name of [
|
||||
"csv-import-card.tsx",
|
||||
"json-import-card.tsx",
|
||||
"apple-health-import-card.tsx",
|
||||
]) {
|
||||
expect(readCode(name)).toMatch(/CsvImportResultView|ImportOutcomeLine/);
|
||||
it("routes every import surface's result line through the shared outcome line", () => {
|
||||
for (const surface of IMPORT_SURFACES) {
|
||||
expect(
|
||||
readRepoCode(surface.path),
|
||||
`${surface.path} must render its outcome through the shared line`,
|
||||
).toMatch(surface.through);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the success markers out of every import surface", () => {
|
||||
const offenders = IMPORT_SURFACES.filter((surface) =>
|
||||
SUCCESS_MARKERS.some((marker) =>
|
||||
readRepoCode(surface.path).includes(marker),
|
||||
),
|
||||
).map((surface) => surface.path);
|
||||
expect(
|
||||
offenders,
|
||||
offenders.length > 0
|
||||
? `Import surface(s) hand-rolling the success affordance instead of ` +
|
||||
`classifying the counts: ${offenders.join(", ")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE,
|
||||
_setMedicationIntakeImportPrismaForTests,
|
||||
advanceMedicationImportProgress,
|
||||
groupMedicationImportSkips,
|
||||
medicationImportChunk,
|
||||
processMedicationIntakeImportJob,
|
||||
sanitiseMedicationImportFailure,
|
||||
totalMedicationImportSkips,
|
||||
type MedicationImportProgress,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
|
||||
@@ -15,7 +17,7 @@ const INITIAL: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: 1_000,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
@@ -43,21 +45,21 @@ describe("medication intake import worker bounds", () => {
|
||||
from: 0,
|
||||
processed: 100,
|
||||
imported: 98,
|
||||
skippedDuplicates: 2,
|
||||
skippedByReason: { duplicate_in_file: 2 },
|
||||
touchedDays: ["2026-07-20", "2026-07-21"],
|
||||
});
|
||||
const second = advanceMedicationImportProgress(first, {
|
||||
from: 100,
|
||||
processed: 100,
|
||||
imported: 97,
|
||||
skippedDuplicates: 3,
|
||||
skippedByReason: { duplicate_in_file: 1, already_recorded: 2 },
|
||||
touchedDays: ["2026-07-21", "2026-07-22"],
|
||||
});
|
||||
const replay = advanceMedicationImportProgress(second, {
|
||||
from: 0,
|
||||
processed: 100,
|
||||
imported: 98,
|
||||
skippedDuplicates: 2,
|
||||
skippedByReason: { duplicate_in_file: 2 },
|
||||
touchedDays: ["2026-07-20"],
|
||||
});
|
||||
|
||||
@@ -66,7 +68,7 @@ describe("medication intake import worker bounds", () => {
|
||||
processed: 200,
|
||||
total: 1_000,
|
||||
imported: 195,
|
||||
skippedDuplicates: 5,
|
||||
skippedByReason: { duplicate_in_file: 3, already_recorded: 2 },
|
||||
touchedDays: ["2026-07-20", "2026-07-21", "2026-07-22"],
|
||||
rollupProcessed: 0,
|
||||
});
|
||||
@@ -93,7 +95,7 @@ describe("medication intake import worker bounds", () => {
|
||||
processed: 0,
|
||||
total: 0,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays,
|
||||
};
|
||||
let status = "running";
|
||||
@@ -185,3 +187,62 @@ describe("medication intake import worker bounds", () => {
|
||||
expect(auditCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication import skip reporting (#650)", () => {
|
||||
it("reports one entry per reason, count descending, and omits zero", () => {
|
||||
expect(
|
||||
groupMedicationImportSkips({
|
||||
duplicate_in_file: 2,
|
||||
already_recorded: 9,
|
||||
}),
|
||||
).toEqual([
|
||||
{ reason: "already_recorded", count: 9 },
|
||||
{ reason: "duplicate_in_file", count: 2 },
|
||||
]);
|
||||
expect(
|
||||
groupMedicationImportSkips({
|
||||
duplicate_in_file: 0,
|
||||
already_recorded: 4,
|
||||
}),
|
||||
).toEqual([{ reason: "already_recorded", count: 4 }]);
|
||||
expect(groupMedicationImportSkips({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("totals every reason so the headline count matches the groups", () => {
|
||||
const counts = { duplicate_in_file: 2, already_recorded: 9 };
|
||||
expect(totalMedicationImportSkips(counts)).toBe(11);
|
||||
expect(
|
||||
groupMedicationImportSkips(counts).reduce(
|
||||
(sum, group) => sum + group.count,
|
||||
0,
|
||||
),
|
||||
).toBe(totalMedicationImportSkips(counts));
|
||||
expect(totalMedicationImportSkips({})).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the two reasons apart while accumulating across chunks", () => {
|
||||
const first = advanceMedicationImportProgress(INITIAL, {
|
||||
from: 0,
|
||||
processed: 100,
|
||||
imported: 90,
|
||||
skippedByReason: { duplicate_in_file: 10 },
|
||||
touchedDays: [],
|
||||
});
|
||||
const second = advanceMedicationImportProgress(first, {
|
||||
from: 100,
|
||||
processed: 100,
|
||||
imported: 95,
|
||||
skippedByReason: { already_recorded: 5 },
|
||||
touchedDays: [],
|
||||
});
|
||||
|
||||
expect(second.skippedByReason).toEqual({
|
||||
duplicate_in_file: 10,
|
||||
already_recorded: 5,
|
||||
});
|
||||
expect(groupMedicationImportSkips(second.skippedByReason)).toEqual([
|
||||
{ reason: "duplicate_in_file", count: 10 },
|
||||
{ reason: "already_recorded", count: 5 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* What the queue already knows about its own broken jobs, read back out.
|
||||
*
|
||||
* The `environment-fetch` job threw on every single run for the whole life of
|
||||
* the environmental-context module. It was handled correctly at the call site —
|
||||
* `recordError()`, a `workerLog("error", …)`, a rethrow so pg-boss retries —
|
||||
* and none of that reached a screen. `recordError()` increments one unnamed
|
||||
* counter, so `/api/admin/status` could say "errors: 41" and not which queue.
|
||||
* The worker log only exists where someone is tailing the container. The user
|
||||
* saw "0 days recorded" and had no way to tell an empty module from a broken
|
||||
* one.
|
||||
*
|
||||
* pg-boss has held the answer the whole time: a job that exhausts its retries
|
||||
* lands in `pgboss.job` with `state = 'failed'`, its queue name, the thrown
|
||||
* error in `output`, and the instant it gave up. Reading that back is a
|
||||
* complete signal for every queue at once, and it needs nothing from the ~30
|
||||
* `recordError()` call sites — which is the point, because a visibility fix
|
||||
* that has to be remembered at each call site is the same failure mode one
|
||||
* level up.
|
||||
*
|
||||
* Both readers fail soft to `null`. The `pgboss` schema is created by the
|
||||
* worker, so a web-only deployment legitimately has no such table, and the
|
||||
* admin status page must not 500 because the queue is absent. `null` means "no
|
||||
* queue to ask", which is not the same fact as an empty array ("asked, nothing
|
||||
* broken") — the callers keep the two apart.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
|
||||
/** How far back a failure still counts as news. */
|
||||
export const JOB_FAILURE_WINDOW_HOURS = 72;
|
||||
|
||||
/** Queues named in one report — enough to see a pattern, bounded for the wire. */
|
||||
const MAX_QUEUES = 20;
|
||||
|
||||
/** The error text is for a human reading a status page, not for a stack trace. */
|
||||
const MAX_ERROR_CHARS = 300;
|
||||
|
||||
export interface FailingQueue {
|
||||
/** The pg-boss queue name, e.g. `environment-fetch`. */
|
||||
queue: string;
|
||||
/** Jobs that exhausted their retries inside the window. */
|
||||
failures: number;
|
||||
/** ISO instant of the newest failure. */
|
||||
lastFailedAt: string;
|
||||
/** The newest failure's message, redacted and truncated. */
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
interface FailingQueueRow {
|
||||
name: string;
|
||||
failures: number;
|
||||
last_failed_at: Date | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn whatever pg-boss stored in `output` into one line. A thrown `Error`
|
||||
* serialises to `{ message, stack, … }`; a thrown non-Error can be any JSON at
|
||||
* all, so the raw text is the fallback rather than an invented placeholder.
|
||||
*/
|
||||
function presentError(raw: string | null): string {
|
||||
if (raw === null) return "";
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return "";
|
||||
const redacted = redactSecrets(trimmed);
|
||||
return redacted.length > MAX_ERROR_CHARS
|
||||
? `${redacted.slice(0, MAX_ERROR_CHARS)}…`
|
||||
: redacted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every queue with at least one exhausted-retry failure in the window, newest
|
||||
* first. `null` when the queue schema is not present at all.
|
||||
*/
|
||||
export async function readFailingQueues(
|
||||
withinHours: number = JOB_FAILURE_WINDOW_HOURS,
|
||||
): Promise<FailingQueue[] | null> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<FailingQueueRow[]>`
|
||||
SELECT
|
||||
name,
|
||||
count(*)::int AS failures,
|
||||
max(completed_on) AS last_failed_at,
|
||||
(
|
||||
array_agg(
|
||||
COALESCE(output->>'message', output::text)
|
||||
ORDER BY completed_on DESC NULLS LAST
|
||||
)
|
||||
)[1] AS last_error
|
||||
FROM pgboss.job
|
||||
WHERE state = 'failed'
|
||||
AND completed_on > now() - make_interval(hours => ${withinHours}::int)
|
||||
GROUP BY name
|
||||
ORDER BY max(completed_on) DESC
|
||||
LIMIT ${MAX_QUEUES}
|
||||
`;
|
||||
return rows
|
||||
.filter((row) => row.last_failed_at !== null)
|
||||
.map((row) => ({
|
||||
queue: row.name,
|
||||
failures: row.failures,
|
||||
lastFailedAt: row.last_failed_at!.toISOString(),
|
||||
lastError: presentError(row.last_error),
|
||||
}));
|
||||
} catch {
|
||||
// No `pgboss` schema (web-only deployment) or no permission to read it.
|
||||
// Absence of a queue is not a failing queue, and it is not zero either.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueueFailureForUser {
|
||||
/** ISO instant the user's newest job on this queue gave up. */
|
||||
lastFailedAt: string;
|
||||
/** Failures for this user on this queue inside the window. */
|
||||
failures: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest exhausted-retry failure of `queue` for one account, matched on the
|
||||
* `userId` the job payload carries. Used by a user-facing surface to say "the
|
||||
* last background run failed" instead of rendering a zero as if it were a
|
||||
* measurement. Deliberately carries no error text: the message is written for
|
||||
* an operator and can name internals, so a non-admin reader gets the fact and
|
||||
* the time only.
|
||||
*/
|
||||
export async function readQueueFailureForUser(
|
||||
queue: string,
|
||||
userId: string,
|
||||
withinHours: number = JOB_FAILURE_WINDOW_HOURS,
|
||||
): Promise<QueueFailureForUser | null> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ failures: number; last_failed_at: Date | null }>
|
||||
>`
|
||||
SELECT count(*)::int AS failures, max(completed_on) AS last_failed_at
|
||||
FROM pgboss.job
|
||||
WHERE name = ${queue}
|
||||
AND state = 'failed'
|
||||
AND data->>'userId' = ${userId}
|
||||
AND completed_on > now() - make_interval(hours => ${withinHours}::int)
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row || row.last_failed_at === null || row.failures === 0) return null;
|
||||
return {
|
||||
lastFailedAt: row.last_failed_at.toISOString(),
|
||||
failures: row.failures,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -32,19 +32,78 @@ export interface MedicationImportPayload {
|
||||
entries: MedicationImportEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a submitted entry did not become a row. Machine-readable; the client
|
||||
* turns each into a sentence.
|
||||
*
|
||||
* `duplicate_in_file` — two entries in the same submission resolve to the same
|
||||
* instant, so only one can be written.
|
||||
* `already_recorded` — the dose is already on the ledger for that instant, from
|
||||
* an earlier import or from any other intake surface.
|
||||
*
|
||||
* They used to be one `skippedDuplicates` number, which is how a file whose
|
||||
* every row collapsed onto one key could report "27 duplicates skipped" about
|
||||
* 27 doses that had no duplicate anywhere. Naming the reason is what makes the
|
||||
* count checkable against what the person actually submitted.
|
||||
*/
|
||||
export const MEDICATION_IMPORT_SKIP_REASONS = [
|
||||
"duplicate_in_file",
|
||||
"already_recorded",
|
||||
] as const;
|
||||
|
||||
export type MedicationImportSkipReason =
|
||||
(typeof MEDICATION_IMPORT_SKIP_REASONS)[number];
|
||||
|
||||
/** Skip counts keyed by reason. A reason with no skips is absent, not zero. */
|
||||
export type MedicationImportSkipCounts = Partial<
|
||||
Record<MedicationImportSkipReason, number>
|
||||
>;
|
||||
|
||||
export interface MedicationImportProgress {
|
||||
processed: number;
|
||||
total: number;
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedByReason: MedicationImportSkipCounts;
|
||||
touchedDays: string[];
|
||||
rollupProcessed: number;
|
||||
}
|
||||
|
||||
export interface MedicationImportSkipGroup {
|
||||
reason: MedicationImportSkipReason;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MedicationImportResult {
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedInvalid: number;
|
||||
/** Total entries that did not become a row. */
|
||||
skipped: number;
|
||||
/**
|
||||
* One entry per reason, count descending then reason — never the same
|
||||
* sentence repeated per row. Empty when nothing was skipped.
|
||||
*/
|
||||
skipReasons: MedicationImportSkipGroup[];
|
||||
}
|
||||
|
||||
/** Collapse the running counts into the wire shape, in a stable order. */
|
||||
export function groupMedicationImportSkips(
|
||||
counts: MedicationImportSkipCounts,
|
||||
): MedicationImportSkipGroup[] {
|
||||
return MEDICATION_IMPORT_SKIP_REASONS.map((reason) => ({
|
||||
reason,
|
||||
count: counts[reason] ?? 0,
|
||||
}))
|
||||
.filter((group) => group.count > 0)
|
||||
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
|
||||
}
|
||||
|
||||
/** Total skipped across every reason. */
|
||||
export function totalMedicationImportSkips(
|
||||
counts: MedicationImportSkipCounts,
|
||||
): number {
|
||||
return MEDICATION_IMPORT_SKIP_REASONS.reduce(
|
||||
(sum, reason) => sum + (counts[reason] ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export interface MedicationIntakeImportQueuePayload {
|
||||
@@ -55,7 +114,7 @@ export interface MedicationImportChunkResult {
|
||||
from: number;
|
||||
processed: number;
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedByReason: MedicationImportSkipCounts;
|
||||
touchedDays: string[];
|
||||
}
|
||||
|
||||
@@ -98,13 +157,20 @@ export function advanceMedicationImportProgress(
|
||||
}
|
||||
}
|
||||
|
||||
const skippedByReason: MedicationImportSkipCounts = {
|
||||
...previous.skippedByReason,
|
||||
};
|
||||
for (const reason of MEDICATION_IMPORT_SKIP_REASONS) {
|
||||
const added = Math.max(0, Math.trunc(chunk.skippedByReason[reason] ?? 0));
|
||||
if (added === 0) continue;
|
||||
skippedByReason[reason] = (skippedByReason[reason] ?? 0) + added;
|
||||
}
|
||||
|
||||
return {
|
||||
processed,
|
||||
total: previous.total,
|
||||
imported: previous.imported + Math.max(0, Math.trunc(chunk.imported)),
|
||||
skippedDuplicates:
|
||||
previous.skippedDuplicates +
|
||||
Math.max(0, Math.trunc(chunk.skippedDuplicates)),
|
||||
skippedByReason,
|
||||
rollupProcessed: previous.rollupProcessed,
|
||||
touchedDays,
|
||||
};
|
||||
@@ -146,6 +212,32 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload {
|
||||
return { entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted skip counts. Strict, like every other field of the
|
||||
* progress blob: an unknown reason key or a non-integer count means the row was
|
||||
* not written by this code, and the job fails rather than reporting a number it
|
||||
* cannot stand behind. Returns null on anything malformed.
|
||||
*/
|
||||
function parseSkipCounts(
|
||||
value: Prisma.JsonValue | undefined,
|
||||
): MedicationImportSkipCounts | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const counts: MedicationImportSkipCounts = {};
|
||||
for (const [key, raw] of Object.entries(value)) {
|
||||
if (
|
||||
!(MEDICATION_IMPORT_SKIP_REASONS as readonly string[]).includes(key) ||
|
||||
typeof raw !== "number" ||
|
||||
!Number.isInteger(raw) ||
|
||||
raw < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
counts[key as MedicationImportSkipReason] = raw;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function parseProgress(
|
||||
value: Prisma.JsonValue,
|
||||
total: number,
|
||||
@@ -155,7 +247,7 @@ function parseProgress(
|
||||
}
|
||||
const processed = Number(value.processed);
|
||||
const imported = Number(value.imported);
|
||||
const skippedDuplicates = Number(value.skippedDuplicates);
|
||||
const skippedByReason = parseSkipCounts(value.skippedByReason);
|
||||
const rawTouchedDays = value.touchedDays;
|
||||
const touchedDays =
|
||||
Array.isArray(rawTouchedDays) &&
|
||||
@@ -170,8 +262,7 @@ function parseProgress(
|
||||
processed > total ||
|
||||
!Number.isInteger(imported) ||
|
||||
imported < 0 ||
|
||||
!Number.isInteger(skippedDuplicates) ||
|
||||
skippedDuplicates < 0 ||
|
||||
skippedByReason === null ||
|
||||
touchedDays === null ||
|
||||
!Number.isInteger(rollupProcessed) ||
|
||||
rollupProcessed < 0 ||
|
||||
@@ -183,7 +274,7 @@ function parseProgress(
|
||||
processed,
|
||||
total,
|
||||
imported,
|
||||
skippedDuplicates,
|
||||
skippedByReason,
|
||||
touchedDays,
|
||||
rollupProcessed,
|
||||
};
|
||||
@@ -289,8 +380,8 @@ async function processNextChunk(
|
||||
}
|
||||
const result: MedicationImportResult = {
|
||||
imported: progress.imported,
|
||||
skippedDuplicates: progress.skippedDuplicates,
|
||||
skippedInvalid: 0,
|
||||
skipped: totalMedicationImportSkips(progress.skippedByReason),
|
||||
skipReasons: groupMedicationImportSkips(progress.skippedByReason),
|
||||
};
|
||||
const completedAt = new Date();
|
||||
await tx.auditLog.create({
|
||||
@@ -301,8 +392,8 @@ async function processNextChunk(
|
||||
jobId: row.id,
|
||||
medicationId: row.medicationId,
|
||||
imported: result.imported,
|
||||
skippedDuplicates: result.skippedDuplicates,
|
||||
skippedInvalid: 0,
|
||||
skipped: result.skipped,
|
||||
skipReasons: result.skipReasons,
|
||||
total: progress.total,
|
||||
}),
|
||||
},
|
||||
@@ -365,11 +456,20 @@ async function processNextChunk(
|
||||
const touchedDays = created.map((event) =>
|
||||
dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
|
||||
);
|
||||
// Two different facts, counted apart. The Map collapse above is entries
|
||||
// of this submission that resolve to the same instant; `skipDuplicates`
|
||||
// then drops whatever the ledger already holds for that instant, from an
|
||||
// earlier import or any other intake surface. Reporting both as one
|
||||
// "duplicates" number is what let a whole month of distinct doses read
|
||||
// as duplicates of each other.
|
||||
const nextProgress = advanceMedicationImportProgress(progress, {
|
||||
from: progress.processed,
|
||||
processed: entries.length,
|
||||
imported: created.length,
|
||||
skippedDuplicates: entries.length - created.length,
|
||||
skippedByReason: {
|
||||
duplicate_in_file: entries.length - uniqueEntries.length,
|
||||
already_recorded: uniqueEntries.length - created.length,
|
||||
},
|
||||
touchedDays,
|
||||
});
|
||||
const heartbeatAt = new Date();
|
||||
|
||||
@@ -12,8 +12,8 @@ describe("waitForMedicationIntakeImport", () => {
|
||||
status: "done",
|
||||
result: {
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
skippedInvalid: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "already_recorded", count: 1 }],
|
||||
},
|
||||
});
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -25,8 +25,8 @@ describe("waitForMedicationIntakeImport", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
skippedInvalid: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "already_recorded", count: 1 }],
|
||||
});
|
||||
expect(readStatus).toHaveBeenCalledTimes(3);
|
||||
expect(wait).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The one derivation from a submitted intake-import row to the queued entry.
|
||||
*
|
||||
* It lives here rather than inline in the route so the test that proves a
|
||||
* reported export lands 28 rows can drive the real derivation instead of
|
||||
* restating it. A test that mirrors the production formula proves the mirror.
|
||||
*/
|
||||
|
||||
/** A validated row of the import body: a local date and a local wall time. */
|
||||
export interface MedicationImportRow {
|
||||
datum: string;
|
||||
uhrzeit: string;
|
||||
}
|
||||
|
||||
export interface MedicationImportQueueEntry {
|
||||
takenAt: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An imported dose is identified by when it was taken, and by nothing else.
|
||||
*
|
||||
* The key used to be built from the row's optional `zaehler` field whenever it
|
||||
* was present. That reads as a per-row counter, but nothing required it to be
|
||||
* one, and an export that put the dose quantity there — 1 tablet, on every row
|
||||
* — collapsed a month of distinct doses onto a single key. Deriving from the
|
||||
* instant makes the key a function of the fact it identifies, and matches the
|
||||
* live-row unique on `(user_id, medication_id, scheduled_for, source)` that the
|
||||
* database enforces regardless.
|
||||
*/
|
||||
export function buildMedicationImportEntries(
|
||||
medicationId: string,
|
||||
rows: readonly MedicationImportRow[],
|
||||
): MedicationImportQueueEntry[] {
|
||||
return rows.map((row) => {
|
||||
const takenAt = new Date(`${row.datum}T${row.uhrzeit}`);
|
||||
return {
|
||||
takenAt: takenAt.toISOString(),
|
||||
idempotencyKey: `import-${medicationId}-${takenAt.getTime()}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface MedicationIntakeImportResult {
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedInvalid: number;
|
||||
skipped: number;
|
||||
/** One entry per reason with its count — never the same line per row. */
|
||||
skipReasons: Array<{ reason: string; count: number }>;
|
||||
}
|
||||
|
||||
interface MedicationIntakeImportStatus {
|
||||
|
||||
Reference in new issue
Block a user