mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user