Files
HealthLog/src/components/settings/nutrient-intake-card.tsx
T
Marc-André Bombeck 537bad4212 fix(ui): print every displayed decimal in the reader's notation
toFixed() prints a full stop in every language and a locale-less
toLocaleString() follows the browser, so a German reader saw 37.5 next
to 70,5 on the same screen. Fever in the illness log, the cycle phase
crosstab, custom metric values, nutrient day totals, the mood chart and
heatmap tooltips, the mood takeaways, the elevation chart axis, the BP
30-day average, the host metrics clock and the encryption row counts now
go through useFormatters().

The new healthlog/no-unlocalised-decimal rule refuses toFixed(),
toPrecision(), a locale-less toLocaleString() or Intl constructor, and a
quotient rendered straight into text under src/components and src/app.
Rounding through Number(...) stays legal; SVG path data and number-input
values carry a reasoned next-line directive.
2026-10-20 12:55:07 +02:00

136 lines
4.7 KiB
TypeScript

"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 { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, 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[];
}
export function NutrientIntakeCard() {
const { t } = useTranslations();
const fmt = useFormatters();
// Day totals arrive as floats; one decimal is plenty for a glance list and
// trailing zeros read as noise, so the digit count is a ceiling.
const formatAmount = (amount: number) =>
fmt.number(Math.round(amount * 10) / 10);
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
anchor="nutrient-intake"
icon={Leaf}
title={t("settings.sections.sources.nutrients.title")}
description={t("settings.sections.sources.nutrients.description", {
days: WINDOW_DAYS,
})}
/>
<p className="text-sm">
{t("settings.sections.sources.nutrients.detail")}
</p>
{overview.isLoading ? (
<div className="space-y-1" 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 ? (
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.sources.nutrients.empty")}
/>
) : (
<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 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>
);
}