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

+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+102 -1
View File
@@ -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<string, number>;
}
/**
* `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<string, unknown>;
const { inserted, updated, skipped } = obj;
if (
typeof inserted !== "number" ||
typeof updated !== "number" ||
typeof skipped !== "number"
) {
return null;
}
const skippedByReason: Record<string, number> = {};
const rawByReason = obj.skippedByReason;
if (rawByReason && typeof rawByReason === "object") {
for (const [reason, count] of Object.entries(
rawByReason as Record<string, unknown>,
)) {
if (typeof count === "number") skippedByReason[reason] = count;
}
}
return { inserted, updated, skipped, skippedByReason };
}
function pickTopSkipReason(byReason: Record<string, number>): 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 });
});
+59 -9
View File
@@ -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<string, string> = {
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 (
<SubPageShell
title={t("nutrients.page.title")}
description={t("nutrients.page.description")}
>
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.page.emptyTitle")}
description={t("nutrients.page.emptyDescription")}
/>
{lastAttempt ? (
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.page.lastAttemptTitle")}
description={
<>
{t(lastAttemptReasonKey(lastAttempt.topReason))}
<span className="mt-1 block text-xs">
{t("nutrients.page.lastAttemptAt", {
date: fmt.dateTime(lastAttempt.at),
})}
</span>
</>
}
/>
) : (
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.page.emptyTitle")}
description={t("nutrients.page.emptyDescription")}
/>
)}
</SubPageShell>
);
}
@@ -85,6 +85,11 @@ function render(node: React.ReactNode, client: QueryClient): string {
);
}
/** React's SSR serialiser escapes `'` as `&#x27;` in text nodes. */
function htmlEscaped(text: string): string {
return text.replace(/'/g, "&#x27;");
}
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;
}
+10
View File
@@ -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",
+79
View File
@@ -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();
});
});