From 12d3239de07fc0321789bded5bf2b785747f2cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 23:29:37 +0200 Subject: [PATCH] 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. --- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/pl.json | 11 +- src/app/api/nutrients/route.ts | 103 +++++++++++++++++- src/app/insights/nutrients/page.tsx | 68 ++++++++++-- .../failed-read-never-empty.test.tsx | 52 ++++++++- src/components/insights/nutrients/types.ts | 13 +++ src/lib/validations/nutrients.ts | 10 ++ tests/integration/nutrients-batch.test.ts | 79 ++++++++++++++ 12 files changed, 374 insertions(+), 17 deletions(-) diff --git a/messages/de.json b/messages/de.json index a5c792758..510ee0524 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9111,7 +9111,16 @@ "moduleOffCta": "Nährstoffe aktivieren", "emptyTitle": "Noch keine Nährstoffdaten", "emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag.", - "loadError": "Deine Nährstoffdaten konnten nicht geladen werden" + "loadError": "Deine Nährstoffdaten konnten nicht geladen werden", + "lastAttemptTitle": "Eine Synchronisierung ist angekommen, aber nichts konnte gespeichert werden", + "lastAttemptAt": "Letzter Versuch: {date}", + "lastAttemptReason": { + "unitMismatch": "Die übermittelten Werte entsprachen nicht den Einheiten, die diese App für diese Nährstoffe erwartet, daher konnten sie nicht gespeichert werden.", + "valueOutOfRange": "Die übermittelten Mengen lagen höher, als diese App plausibel speichern kann, daher konnten sie nicht gesichert werden.", + "dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind — das lohnt sich zu prüfen.", + "upsertFailed": "Diese Einträge haben den Server erreicht, aber ein Problem auf unserer Seite hat verhindert, dass sie gespeichert wurden. Du musst nichts weiter tun.", + "unknown": "Diese Einträge sind angekommen, konnten aber nicht gespeichert werden." + } }, "hydration": { "referenceMeta": "Richtwert {value} {unit}/Tag · EFSA", diff --git a/messages/en.json b/messages/en.json index 63f93944b..8c12b07f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9111,7 +9111,16 @@ "moduleOffCta": "Turn on nutrients", "emptyTitle": "No nutrient data yet", "emptyDescription": "Data arrives from your phone's health app sync or a manual water entry.", - "loadError": "Your nutrient data could not be loaded" + "loadError": "Your nutrient data could not be loaded", + "lastAttemptTitle": "A sync arrived, but nothing could be stored", + "lastAttemptAt": "Last attempt: {date}", + "lastAttemptReason": { + "unitMismatch": "The values that arrived didn't match the units this app expects for these nutrients, so they couldn't be stored.", + "valueOutOfRange": "The amounts that arrived were higher than this app can plausibly store, so they couldn't be saved.", + "dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong — it's worth checking.", + "upsertFailed": "These entries reached the server, but a problem on this end kept them from being saved. There's nothing you need to do.", + "unknown": "These entries arrived but couldn't be stored." + } }, "hydration": { "referenceMeta": "Reference intake {value} {unit}/day · EFSA", diff --git a/messages/es.json b/messages/es.json index 7f9d866ec..e4d6838cd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9111,7 +9111,16 @@ "moduleOffCta": "Activar nutrientes", "emptyTitle": "Aún no hay datos de nutrientes", "emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua.", - "loadError": "No se pudieron cargar tus datos de nutrientes" + "loadError": "No se pudieron cargar tus datos de nutrientes", + "lastAttemptTitle": "Se recibió una sincronización, pero no se pudo guardar nada", + "lastAttemptAt": "Último intento: {date}", + "lastAttemptReason": { + "unitMismatch": "Los valores recibidos no coincidían con las unidades que esta app espera para estos nutrientes, así que no se pudieron guardar.", + "valueOutOfRange": "Las cantidades recibidas eran superiores a lo que esta app puede almacenar de forma plausible, así que no se pudieron guardar.", + "dayInvalid": "Las entradas llevaban una fecha que el servidor no pudo aceptar. Esto puede ocurrir cuando el reloj o la zona horaria del lado que envía los datos están mal configurados; merece la pena revisarlo.", + "upsertFailed": "Estas entradas llegaron al servidor, pero un problema de nuestro lado impidió guardarlas. No tienes que hacer nada.", + "unknown": "Estas entradas llegaron, pero no se pudieron guardar." + } }, "hydration": { "referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA", diff --git a/messages/fr.json b/messages/fr.json index 4cbde5c33..3310a4e0d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9111,7 +9111,16 @@ "moduleOffCta": "Activer les nutriments", "emptyTitle": "Pas encore de données nutritionnelles", "emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau.", - "loadError": "Tes données de nutriments n'ont pas pu être chargées" + "loadError": "Tes données de nutriments n'ont pas pu être chargées", + "lastAttemptTitle": "Une synchronisation est arrivée, mais rien n'a pu être enregistré", + "lastAttemptAt": "Dernière tentative : {date}", + "lastAttemptReason": { + "unitMismatch": "Les valeurs reçues ne correspondaient pas aux unités attendues par l'appli pour ces nutriments, elles n'ont donc pas pu être enregistrées.", + "valueOutOfRange": "Les quantités reçues étaient supérieures à ce que l'appli peut raisonnablement enregistrer, elles n'ont donc pas pu être sauvegardées.", + "dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect — cela vaut la peine de vérifier.", + "upsertFailed": "Ces entrées sont arrivées jusqu'au serveur, mais un problème de notre côté a empêché leur enregistrement. Tu n'as rien à faire.", + "unknown": "Ces entrées sont arrivées mais n'ont pas pu être enregistrées." + } }, "hydration": { "referenceMeta": "Apport de référence {value} {unit}/jour · EFSA", diff --git a/messages/it.json b/messages/it.json index 22a752cb6..752f960dd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9111,7 +9111,16 @@ "moduleOffCta": "Attiva nutrienti", "emptyTitle": "Ancora nessun dato sui nutrienti", "emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente.", - "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti" + "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti", + "lastAttemptTitle": "È arrivata una sincronizzazione, ma non è stato possibile salvare nulla", + "lastAttemptAt": "Ultimo tentativo: {date}", + "lastAttemptReason": { + "unitMismatch": "I valori ricevuti non corrispondevano alle unità che questa app si aspetta per questi nutrienti, quindi non è stato possibile salvarli.", + "valueOutOfRange": "Le quantità ricevute erano superiori a quanto questa app può plausibilmente memorizzare, quindi non è stato possibile salvarle.", + "dayInvalid": "Le voci riportavano una data che il server non ha potuto accettare. Questo può succedere quando l'orologio o il fuso orario dal lato che invia i dati non sono corretti: vale la pena controllare.", + "upsertFailed": "Queste voci hanno raggiunto il server, ma un problema da questa parte ne ha impedito il salvataggio. Non devi fare nulla.", + "unknown": "Queste voci sono arrivate ma non è stato possibile salvarle." + } }, "hydration": { "referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA", diff --git a/messages/pl.json b/messages/pl.json index d22ed6408..a997b9966 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9111,7 +9111,16 @@ "moduleOffCta": "Włącz składniki odżywcze", "emptyTitle": "Brak jeszcze danych o składnikach odżywczych", "emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody.", - "loadError": "Nie udało się wczytać danych o składnikach odżywczych" + "loadError": "Nie udało się wczytać danych o składnikach odżywczych", + "lastAttemptTitle": "Synchronizacja dotarła, ale nic nie udało się zapisać", + "lastAttemptAt": "Ostatnia próba: {date}", + "lastAttemptReason": { + "unitMismatch": "Otrzymane wartości nie pasowały do jednostek, których ta aplikacja oczekuje dla tych składników odżywczych, więc nie udało się ich zapisać.", + "valueOutOfRange": "Otrzymane ilości były wyższe, niż ta aplikacja może wiarygodnie zapisać, więc nie udało się ich zapisać.", + "dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe — warto to sprawdzić.", + "upsertFailed": "Te wpisy dotarły do serwera, ale problem po naszej stronie uniemożliwił ich zapisanie. Nie musisz nic robić.", + "unknown": "Te wpisy dotarły, ale nie udało się ich zapisać." + } }, "hydration": { "referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA", diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 2cd5d8824..26e86d34b 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -7,6 +7,12 @@ * does the server hold". Catalog order, no pagination (≤ 26 rows). * Module-gated like the ingest path: 403 `module.disabled` when the * opt-in `nutrients` module is off. `userId` is narrowed from auth. + * + * `lastAttempt` (v1.34.3): non-null only when the window is empty AND the + * most recent `nutrient.batch.ingest` audit row shows a call that landed + * nothing, so `/insights/nutrients` can say a sync arrived and why instead + * of a bare "no data yet". See the lookup below for the AuditLog-retention + * coupling this introduces. */ import { NextRequest } from "next/server"; @@ -21,6 +27,70 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; +/** Highest-count skip reason; ties break on this fixed declaration order. */ +const SKIP_REASON_PRIORITY = [ + "unit_mismatch", + "value_out_of_range", + "day_invalid", + "upsert_failed", +] as const; + +interface LastIngestDetails { + inserted: number; + updated: number; + skipped: number; + skippedByReason: Record; +} + +/** + * `AuditLog.details` is a free-form JSON string; parse it back into the + * shape `nutrient.batch.ingest` (`src/app/api/nutrients/batch/route.ts`) + * writes, rejecting anything that doesn't match rather than trusting the + * blob. + */ +function parseLastIngestDetails(raw: string | null): LastIngestDetails | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const obj = parsed as Record; + const { inserted, updated, skipped } = obj; + if ( + typeof inserted !== "number" || + typeof updated !== "number" || + typeof skipped !== "number" + ) { + return null; + } + const skippedByReason: Record = {}; + const rawByReason = obj.skippedByReason; + if (rawByReason && typeof rawByReason === "object") { + for (const [reason, count] of Object.entries( + rawByReason as Record, + )) { + if (typeof count === "number") skippedByReason[reason] = count; + } + } + return { inserted, updated, skipped, skippedByReason }; +} + +function pickTopSkipReason(byReason: Record): string | null { + let best: string | null = null; + let bestCount = 0; + for (const reason of SKIP_REASON_PRIORITY) { + const count = byReason[reason] ?? 0; + if (count > bestCount) { + best = reason; + bestCount = count; + } + } + return best; +} + export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -106,6 +176,37 @@ export const GET = apiHandler(async (request: NextRequest) => { }; }); + // The account holder's only signal that a sync ever arrived is this + // lookup — the read path stays honest instead of a bare "no data yet" + // when a batch actually landed and every entry was rejected. Gated on + // the zero-rows path ONLY: an AuditLog read must never run on the happy + // path just to serve a read endpoint. COUPLING: this depends on the row + // still existing — `cleanupOldAuditLogs` (`src/lib/jobs/audit-log-cleanup.ts`) + // prunes AuditLog nightly past `AUDIT_LOG_RETENTION_DAYS` (365 days by + // default), so a dormant account whose last failed attempt ages past + // that window silently loses the explanation and falls back to the + // plain empty state — anyone changing that retention job should know a + // user-facing message depends on it. + let lastAttempt: { at: string; topReason: string } | null = null; + if (nutrients.length === 0) { + const lastIngest = await prisma.auditLog.findFirst({ + where: { userId: user.id, action: "nutrient.batch.ingest" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, details: true }, + }); + const details = parseLastIngestDetails(lastIngest?.details ?? null); + // Only surface a reason when the last attempt landed NOTHING: a + // succeeded attempt whose day just falls outside this window is a + // different, unrelated situation — showing a failure notice over it + // would be the very false claim this feature exists to prevent. + if (details && details.inserted === 0 && details.updated === 0) { + const topReason = pickTopSkipReason(details.skippedByReason); + if (topReason) { + lastAttempt = { at: lastIngest!.createdAt.toISOString(), topReason }; + } + } + } + annotate({ action: { name: "nutrient.intake.read" }, meta: { @@ -115,5 +216,5 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); - return apiSuccess({ windowDays: days, nutrients }); + return apiSuccess({ windowDays: days, nutrients, lastAttempt }); }); diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx index 0922ce0ed..0790865e5 100644 --- a/src/app/insights/nutrients/page.tsx +++ b/src/app/insights/nutrients/page.tsx @@ -12,13 +12,36 @@ import { HydrationCard } from "@/components/insights/nutrients/hydration-card"; import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card"; import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card"; import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types"; const WINDOW_DAYS = 30; +/** + * `nutrient.batch.ingest`'s closed skip-reason codes + * (`src/app/api/nutrients/batch/route.ts`) mapped to a human sentence — + * never render the raw code. A reason with no useful action for the + * account holder says plainly that the entries couldn't be stored rather + * than inventing advice; `dayInvalid` is the one case with something + * genuinely worth checking (a clock/time-zone mismatch on the sending + * side), so it says so. + */ +const LAST_ATTEMPT_REASON_KEYS: Record = { + unit_mismatch: "nutrients.page.lastAttemptReason.unitMismatch", + value_out_of_range: "nutrients.page.lastAttemptReason.valueOutOfRange", + day_invalid: "nutrients.page.lastAttemptReason.dayInvalid", + upsert_failed: "nutrients.page.lastAttemptReason.upsertFailed", +}; + +function lastAttemptReasonKey(reason: string): string { + return ( + LAST_ATTEMPT_REASON_KEYS[reason] ?? + "nutrients.page.lastAttemptReason.unknown" + ); +} + /** * `/insights/nutrients` — hydration + micronutrients (v1.29). * @@ -44,13 +67,22 @@ const WINDOW_DAYS = 30; * `queryKeys.nutrientIntake(WINDOW_DAYS)` query and already renders * its own error card, but this page's old empty-state branch * short-circuited before that card ever mounted. - * - module on, zero rows anywhere in the window → one EmptyState - * explaining where data comes from (phone health-app sync or - * manual water entry) instead of three near-empty cards. + * - module on, zero rows anywhere in the window, AND the server found a + * recent sync attempt that landed nothing (`overview.data.lastAttempt`, + * from `GET /api/nutrients` — see that route for the AuditLog lookup + * and its retention coupling) → an EmptyState naming the attempt and + * the reason, not the generic copy. A batch CAN arrive and have every + * entry rejected (wrong unit, an implausible amount, a bad date, a + * write failure); without this branch that read the same as nothing + * ever syncing. + * - module on, zero rows, no recorded attempt → the generic EmptyState + * explaining where data comes from (a health-app sync or a manual + * water entry) instead of three near-empty cards. * - otherwise → the three-card spine. */ export default function InsightsNutrientsPage() { const { t } = useTranslations(); + const fmt = useFormatters(); const { user } = useAuth(); const queryClient = useQueryClient(); @@ -110,16 +142,34 @@ export default function InsightsNutrientsPage() { const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0; if (!overview.isLoading && !hasAnyData) { + const lastAttempt = overview.data?.lastAttempt ?? null; return ( - } - title={t("nutrients.page.emptyTitle")} - description={t("nutrients.page.emptyDescription")} - /> + {lastAttempt ? ( + } + title={t("nutrients.page.lastAttemptTitle")} + description={ + <> + {t(lastAttemptReasonKey(lastAttempt.topReason))} + + {t("nutrients.page.lastAttemptAt", { + date: fmt.dateTime(lastAttempt.at), + })} + + + } + /> + ) : ( + } + title={t("nutrients.page.emptyTitle")} + description={t("nutrients.page.emptyDescription")} + /> + )} ); } diff --git a/src/components/__tests__/failed-read-never-empty.test.tsx b/src/components/__tests__/failed-read-never-empty.test.tsx index 0b008e8e3..cbf79ed4e 100644 --- a/src/components/__tests__/failed-read-never-empty.test.tsx +++ b/src/components/__tests__/failed-read-never-empty.test.tsx @@ -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(, 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(, 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(, client); + + expect(html).toContain(np.lastAttemptTitle); + expect(html).toContain(htmlEscaped(np.lastAttemptReason.unknown)); }); }); diff --git a/src/components/insights/nutrients/types.ts b/src/components/insights/nutrients/types.ts index 9e448fba4..dcae0a1c6 100644 --- a/src/components/insights/nutrients/types.ts +++ b/src/components/insights/nutrients/types.ts @@ -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; } diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index 20c00f344..a48fc76af 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -100,6 +100,16 @@ export const nutrientOverviewSchema = z daysWithData: z.number().int().min(1), }), ), + lastAttempt: z + .object({ + at: z.iso.datetime({ offset: true }), + topReason: z.string(), + }) + .nullable() + .meta({ + description: + "Non-null only when `nutrients` is empty AND the most recent nutrient-batch call landed nothing — the most common skip reason from that call, so the client can say a sync arrived and why instead of a bare empty state.", + }), }) .meta({ id: "NutrientIntakeOverview", diff --git a/tests/integration/nutrients-batch.test.ts b/tests/integration/nutrients-batch.test.ts index 10836c34a..75400f8c0 100644 --- a/tests/integration/nutrients-batch.test.ts +++ b/tests/integration/nutrients-batch.test.ts @@ -301,4 +301,83 @@ describe("GET /api/nutrients (real Postgres)", () => { const res = await GET(new NextRequest("http://localhost/api/nutrients")); expect(res.status).toBe(403); }); + + // The account holder's only signal that a sync ever arrived: when the + // window is genuinely empty AND the most recent nutrient-batch call + // landed nothing, the read surfaces why instead of a bare empty state. + it("surfaces the last failed attempt when the window is genuinely empty", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + const { GET } = await import("@/app/api/nutrients/route"); + + await POST( + postReq({ + entries: [ + { day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 }, + ], + }), + ); + + const res = await GET( + new NextRequest("http://localhost/api/nutrients?days=14"), + ); + const json = (await res.json()) as { + data: { nutrients: unknown[]; lastAttempt: { topReason: string } | null }; + }; + expect(json.data.nutrients).toHaveLength(0); + expect(json.data.lastAttempt).toMatchObject({ + topReason: "unit_mismatch", + }); + }); + + it("omits lastAttempt entirely once the window has data", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + const { GET } = await import("@/app/api/nutrients/route"); + + await POST( + postReq({ + entries: [ + { day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 }, + ], + }), + ); + + const res = await GET( + new NextRequest("http://localhost/api/nutrients?days=14"), + ); + const json = (await res.json()) as { + data: { nutrients: unknown[]; lastAttempt: unknown }; + }; + expect(json.data.nutrients).toHaveLength(1); + expect(json.data.lastAttempt).toBeNull(); + }); + + // A last attempt that actually SUCCEEDED must never be reframed as a + // failure just because the window is empty for an unrelated reason + // (here: the stored row was removed after the fact). Showing a failure + // notice over a real success is the exact false claim this feature + // exists to prevent. + it("stays silent when the last recorded attempt succeeded, even if the window is now empty", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + const { GET } = await import("@/app/api/nutrients/route"); + + await POST( + postReq({ + entries: [ + { day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 }, + ], + }), + ); + await getPrismaClient().nutrientIntakeDay.deleteMany({ + where: { userId: OPTED_IN_USER }, + }); + + const res = await GET( + new NextRequest("http://localhost/api/nutrients?days=14"), + ); + const json = (await res.json()) as { + data: { nutrients: unknown[]; lastAttempt: unknown }; + }; + expect(json.data.nutrients).toHaveLength(0); + expect(json.data.lastAttempt).toBeNull(); + }); });