feat(nutrients): tell the account holder when a sync arrived but nothing landed

GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.

The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.

This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 23:30:28 +02:00
1 parent 3ec3a8c6aa
commit 12d3239de0
12 files changed
+374 -17

No files matched your search

@@ -85,6 +85,11 @@ function render(node: React.ReactNode, client: QueryClient): string {
);
}
/** React's SSR serialiser escapes `'` as `'` in text nodes. */
function htmlEscaped(text: string): string {
return text.replace(/'/g, "'");
}
describe("mental wellbeing — screening history", () => {
const mh = en.mentalHealth;
@@ -170,11 +175,56 @@ describe("/insights/nutrients page — overview read shares the micronutrients q
it("still renders the honest empty state when the window genuinely has no rows", () => {
const client = makeClient();
client.setQueryData(queryKeys.nutrientIntake(30), { nutrients: [] });
client.setQueryData(queryKeys.nutrientIntake(30), {
nutrients: [],
lastAttempt: null,
});
const html = render(<InsightsNutrientsPage />, client);
expect(html).not.toContain('data-slot="query-error-card"');
expect(html).toContain(np.emptyTitle);
expect(html).not.toContain(np.lastAttemptTitle);
});
// `GET /api/nutrients` returns `lastAttempt` non-null only when the window
// is empty AND the most recent `nutrient.batch.ingest` audit row shows a
// call that landed nothing — a batch CAN arrive and have every entry
// rejected, which used to read on this page exactly like nothing ever
// synced. The reason must render as the mapped human sentence, never the
// raw wire code.
it("names the sync attempt and its reason instead of the generic empty state", () => {
const client = makeClient();
client.setQueryData(queryKeys.nutrientIntake(30), {
nutrients: [],
lastAttempt: {
at: "2026-07-15T09:30:00.000Z",
topReason: "unit_mismatch",
},
});
const html = render(<InsightsNutrientsPage />, client);
expect(html).toContain(np.lastAttemptTitle);
expect(html).toContain(htmlEscaped(np.lastAttemptReason.unitMismatch));
// The date line renders around a formatted timestamp; assert the fixed
// prefix rather than the locale-formatted date itself.
expect(html).toContain(np.lastAttemptAt.split("{date}")[0]);
expect(html).not.toContain(np.emptyTitle);
expect(html).not.toContain("unit_mismatch");
});
it("falls back to the generic reason sentence for an unmapped code", () => {
const client = makeClient();
client.setQueryData(queryKeys.nutrientIntake(30), {
nutrients: [],
lastAttempt: {
at: "2026-07-15T09:30:00.000Z",
topReason: "some_future_reason",
},
});
const html = render(<InsightsNutrientsPage />, client);
expect(html).toContain(np.lastAttemptTitle);
expect(html).toContain(htmlEscaped(np.lastAttemptReason.unknown));
});
});
@@ -29,7 +29,20 @@ export interface NutrientOverviewRow {
daysWithData: number;
}
/**
* Non-null only when `nutrients` is empty AND the most recent
* `nutrient.batch.ingest` audit row shows a call that landed nothing —
* `topReason` is the most common of the closed skip-reason codes
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
* `upsert_failed`) from that call.
*/
export interface NutrientLastIngestAttempt {
at: string;
topReason: string;
}
export interface NutrientIntakeOverview {
windowDays: number;
nutrients: NutrientOverviewRow[];
lastAttempt: NutrientLastIngestAttempt | null;
}