chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)

Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).

Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
This commit is contained in:
Marc-André Bombeck committed 2026-07-14 13:10:47 +02:00
1 parent 14a9a08a6e
commit 5445fc67ea
34 files changed
+2766 -12

No files matched your search

@@ -0,0 +1,108 @@
/**
* v1.28 — `<NutrientIntakeCard>` render contract.
*
* The read-only Settings → Sources list for the opt-in `nutrients`
* module: renders NOTHING while the module is off (the Modules hub is
* the consent surface), and a flat name / latest-total / days-count
* list when it is on. Test strategy mirrors `modules-section.test.tsx`:
* mock `@tanstack/react-query` + `useAuth`, render under SSR.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
interface QueryState {
data?: unknown;
isLoading: boolean;
}
let queryState: QueryState = { data: undefined, isLoading: false };
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryState,
}));
const authSpy = vi.fn<() => { user: AuthUser | null }>();
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
import { NutrientIntakeCard } from "../nutrient-intake-card";
function user(modules: Record<string, boolean>): AuthUser {
return { id: "user-1", modules } as unknown as AuthUser;
}
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NutrientIntakeCard />
</I18nProvider>,
);
}
beforeEach(() => {
queryState = { data: undefined, isLoading: false };
authSpy.mockReturnValue({ user: user({ nutrients: true }) });
});
describe("<NutrientIntakeCard>", () => {
it("renders nothing while the nutrients module is off", () => {
authSpy.mockReturnValue({ user: user({ nutrients: false }) });
expect(render()).toBe("");
authSpy.mockReturnValue({ user: user({}) });
expect(render()).toBe("");
});
it("renders the synced list with name, latest total (µg rendered), and day count", () => {
queryState = {
isLoading: false,
data: {
windowDays: 14,
nutrients: [
{
nutrient: "vitamin_d",
unit: "ug",
latestDay: "2026-07-13",
latestAmount: 22.5,
daysWithData: 5,
},
{
nutrient: "caffeine",
unit: "mg",
latestDay: "2026-07-13",
latestAmount: 310,
daysWithData: 14,
},
],
},
};
const html = render();
expect(html).toContain('data-slot="nutrient-intake-card"');
expect(html).toContain("Vitamin D");
expect(html).toContain("Caffeine");
// Wire `ug` renders as µg; the day key renders verbatim.
expect(html).toContain("µg");
expect(html).toContain("2026-07-13");
const rows = html.match(/data-slot="nutrient-intake-row"/g) ?? [];
expect(rows).toHaveLength(2);
// Read-only surface: the only interactive element is the modules link.
expect(html).toContain('data-slot="nutrient-intake-modules-link"');
expect(html).not.toContain("<button");
});
it("renders the empty state when the module is on but nothing has synced", () => {
queryState = {
isLoading: false,
data: { windowDays: 14, nutrients: [] },
};
const html = render();
expect(html).toContain("No nutrient data has been synced yet.");
});
});
@@ -47,6 +47,7 @@ import {
FileScan,
FileText,
MessageCircleHeart,
Leaf,
Moon,
Plug,
Pill,
@@ -96,6 +97,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
inboundDocuments: FileScan,
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
mentalHealth: Brain,
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
nutrients: Leaf,
};
export function ModulesSection() {
@@ -0,0 +1,130 @@
"use client";
/**
* `<NutrientIntakeCard>` — Settings → Sources, read-only synced-nutrient
* list (v1.28).
*
* The smallest honest surface for the opt-in `nutrients` module: a flat
* list of what the server holds — per nutrient the latest synced day
* total and the days-with-data count over the last 14 days — so the
* user can verify the sync works and see exactly what the account
* stores. No route slug, no nav entry, no chart, no edit affordance;
* the module toggle in the Modules hub is the consent surface and the
* card links to it. Rendered only when the module is on (resolved
* module map from `useAuth().user.modules`, the environment-section
* pattern); reads unwrap through `apiGet`, the key rides the
* centralized factory.
*/
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Leaf } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
const WINDOW_DAYS = 14;
/** Wire `ug` renders as µg; mg / ml pass through. */
const UNIT_LABELS: Record<string, string> = { mg: "mg", ug: "µg", ml: "ml" };
interface NutrientOverviewRow {
nutrient: string;
unit: string;
latestDay: string;
latestAmount: number;
daysWithData: number;
}
interface NutrientIntakeOverview {
windowDays: number;
nutrients: NutrientOverviewRow[];
}
function formatAmount(amount: number): string {
// Day totals arrive as floats; one decimal is plenty for a glance
// list and trailing zeros read as noise.
return String(Math.round(amount * 10) / 10);
}
export function NutrientIntakeCard() {
const { t } = useTranslations();
const { user } = useAuth();
const enabled = user?.modules?.nutrients === true;
const overview = useQuery({
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
enabled,
queryFn: () =>
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
});
if (!enabled) return null;
const rows = overview.data?.nutrients ?? [];
return (
<SettingsCard data-slot="nutrient-intake-card">
<SettingsCardHeader
icon={Leaf}
title={t("settings.sections.sources.nutrients.title")}
description={t("settings.sections.sources.nutrients.description", {
days: WINDOW_DAYS,
})}
className="mb-3"
/>
{overview.isLoading ? (
<div className="space-y-1 pl-7" aria-hidden="true">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
) : rows.length === 0 ? (
<p className="text-muted-foreground pl-7 text-sm">
{t("settings.sections.sources.nutrients.empty")}
</p>
) : (
<ul className="divide-border divide-y rounded-md border" role="list">
{rows.map((row) => (
<li
key={row.nutrient}
data-slot="nutrient-intake-row"
className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 p-2"
>
<span className="text-sm">
{t(`nutrients.names.${row.nutrient}`)}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{t("settings.sections.sources.nutrients.latest", {
amount: formatAmount(row.latestAmount),
unit: UNIT_LABELS[row.unit] ?? row.unit,
date: row.latestDay,
})}
{" · "}
{t("settings.sections.sources.nutrients.daysWithData", {
count: row.daysWithData,
days: WINDOW_DAYS,
})}
</span>
</li>
))}
</ul>
)}
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.sections.sources.nutrients.moduleHint")}{" "}
<Link
href="/settings/modules"
className="text-primary underline underline-offset-2"
data-slot="nutrient-intake-modules-link"
>
{t("settings.sections.sources.nutrients.moduleHintLink")}
</Link>
</p>
</SettingsCard>
);
}
@@ -44,6 +44,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -580,6 +581,10 @@ export function SourcesSection() {
</Link>
</p>
</SettingsCard>
{/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients`
module (renders nothing while the module is off). */}
<NutrientIntakeCard />
</div>
);
}