v1.4.36 — perf, charts, AI payload trim, UX punch

Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 13:22:51 +02:00
1 parent 5a3ca330ac
commit 0e2a99f8e7
70 files changed
+4982 -810

No files matched your search

+12 -1
View File
@@ -21,6 +21,7 @@ import {
Bell,
Database,
FileText,
Info,
KeyRound,
Inbox,
Plug,
@@ -125,6 +126,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.danger-zone.title",
icon: ShieldAlert,
},
// v1.4.36 W4e — About / version / update check folded into Admin.
{
slug: "about",
titleKey: "admin.section.about.title",
icon: Info,
},
] as const;
export interface AdminShellProps {
@@ -171,7 +178,11 @@ export function AdminShell({ active, children }: AdminShellProps) {
if (!activeChip) return;
activeChip.scrollIntoView({
block: "nearest",
inline: "center",
// v1.4.36 W4b — `inline: "start"` pins the active chip to the
// left edge of the scroller. `inline: "center"` over-scrolled
// and the first one or two chips were unreachable on narrow
// viewports.
inline: "start",
behavior: "smooth",
});
}, [activeSlug]);
@@ -452,13 +452,17 @@ export function LoginOverviewSection() {
</span>
{/*
v1.4.27 B3 — carrier chip below the auth-provider
chip. Only renders when the GeoLite2-ASN lookup
found an organisation string for the audit-row's
IP; private/loopback IPs and offline-miss rows
keep the original single-chip layout. The chip
carries the short DACH label ("Telekom",
"Vodafone", "1&1", "O2"); unknown organisations
fall through to the raw GeoLite2 string.
chip. v1.4.36 W4g — fall back to the city + country
string with a "carrier unavailable" qualifier when
the GeoLite2-ASN offline lookup miss (free-tier
ipwho.is does not expose ASN) leaves `entry.carrier`
null but the location lookup populated
`entry.location`. Private/loopback IPs and offline-
miss rows on BOTH columns keep the original
single-chip layout. ASN-known chip carries the
short DACH label ("Telekom", "Vodafone", "1&1",
"O2") with unknown organisations falling through
to the raw GeoLite2 string.
*/}
{entry.carrier ? (
<span
@@ -467,6 +471,16 @@ export function LoginOverviewSection() {
>
{carrierShortLabel(entry.carrier)}
</span>
) : entry.location ? (
<span
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
data-slot="login-overview-carrier"
data-source="geo-fallback"
>
{t("admin.carrierUnavailableGeoFallback", {
location: entry.location,
})}
</span>
) : null}
</td>
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
+4
View File
@@ -29,6 +29,10 @@ export const ADMIN_SECTION_SLUGS = [
"app-logs",
"backups",
"danger-zone",
// v1.4.36 W4e — About / version / update check folded into Admin
// Console. Lives at /admin/about; the legacy /settings/about route
// is unlinked from the user-card dropdown.
"about",
] as const;
export type AdminSectionSlug = (typeof ADMIN_SECTION_SLUGS)[number];
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { ChartSkeleton } from "../chart-skeleton";
/**
* v1.4.36 W2 — `<ChartSkeleton>` carries two variants so the loading
* shell occupies the same visible footprint as the chart that lands
* after `next/dynamic` resolves.
*
* - Default (dashboard / sub-page hero): rounded-xl card chrome,
* full header row, `--chart-height` band.
* - `mini` (trends-row 140 px slot): rounded-md, light padding,
* 140 px band — no header chrome.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<ChartSkeleton>", () => {
it("renders the default card chrome (rounded-xl + p-4 md:p-6)", () => {
const html = render(<ChartSkeleton />);
expect(html).toContain('data-slot="chart-skeleton"');
expect(html).not.toContain('data-mini="true"');
expect(html).toMatch(/rounded-xl/);
expect(html).toMatch(/p-4/);
expect(html).toMatch(/md:p-6/);
});
it("renders the loading announcement for assistive tech", () => {
const html = render(<ChartSkeleton />);
expect(html).toMatch(/role="status"/);
expect(html).toMatch(/aria-busy="true"/);
});
it("mini variant uses the lighter rounded-md + p-2 chrome", () => {
const html = render(<ChartSkeleton mini />);
expect(html).toContain('data-slot="chart-skeleton"');
expect(html).toContain('data-mini="true"');
expect(html).toMatch(/rounded-md/);
expect(html).toMatch(/p-2/);
// Default card-chrome classes must NOT appear in the mini variant.
expect(html).not.toMatch(/rounded-xl/);
});
it("mini variant pins the skeleton band at h-[140px] (trends-row slot)", () => {
const html = render(<ChartSkeleton mini />);
expect(html).toMatch(/h-\[140px\]/);
});
});
+35 -1
View File
@@ -23,12 +23,46 @@ import { useTranslations } from "@/lib/i18n/context";
* The cumulative box matches the loaded chart's height to within a
* pixel so the page does not jump on hydration.
*
* v1.4.36 W2 — `mini` variant for the trends-row 140 px chart slot.
* The default skeleton's rounded-xl border + p-4/p-6 padding inflated
* past the 140 px wrapper and pushed the row taller than the loaded
* chart, re-introducing the very layout shift the wrapper now
* eliminates. The mini variant trades the card chrome for
* `rounded-md p-2` and pins the inner band at 140 px so the loading
* shell occupies the same visible box as the painted chart.
*
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
* primitive's `animate-pulse` is suppressed via Tailwind's
* `motion-reduce:animate-none` modifier.
*/
export function ChartSkeleton({ className }: { className?: string }) {
export function ChartSkeleton({
className,
mini = false,
}: {
className?: string;
/**
* When true, render the compact variant used inside the trends-row
* chart slot (140 px band, lighter chrome). Defaults to false for
* the dashboard hero / sub-page charts.
*/
mini?: boolean;
}) {
const { t } = useTranslations();
if (mini) {
return (
<div
data-slot="chart-skeleton"
data-mini="true"
role="status"
aria-busy="true"
aria-live="polite"
className={cn("bg-card border-border rounded-md border p-2", className)}
>
<span className="sr-only">{t("charts.loadingLabel")}</span>
<Skeleton className="h-[140px] w-full" />
</div>
);
}
return (
<div
data-slot="chart-skeleton"
@@ -27,3 +27,21 @@ export const HealthChartDynamic = dynamic(
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same
* underlying `<HealthChart>` import, but the loading shell renders the
* `mini` `<ChartSkeleton>` so the loading box matches the painted
* chart's footprint instead of inflating past the row contract.
*
* Consumers inside a fixed-height row (BP + weight cards in
* `trends-row.tsx`) pass `mini` on the chart itself; this wrapper
* ensures the next/dynamic loader paints the matching skeleton.
*/
export const HealthChartDynamicMini = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
+12
View File
@@ -585,8 +585,20 @@ export function HealthChart({
// Recharts paint cost ~50× on continuous-monitoring accounts.
// Short windows keep raw fetching so hour-by-hour detail
// stays visible.
//
// v1.4.36 W1 — also route the daily aggregate through the
// persistent rollup buckets via `source=rollup`. The route
// reads from `measurement_rollups` instead of running a live
// `date_trunc` scan over the raw measurements table; the
// three parallel daily fetches on the Insights trends row
// (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small
// indexed read against the ~5 k-row rollup table. The server
// falls back to live SQL when the rollup is empty for the
// requested window so brand-new accounts still see a chart
// on their first render.
if (fetchWindow.windowDays > 7) {
typeParams.set("aggregate", "daily");
typeParams.set("source", "rollup");
}
const res = await fetch(`/api/measurements?${typeParams}`);
@@ -40,15 +40,22 @@ function fakeSummary(count: number): DataSummary {
}
describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
it("renders every pill when availability is omitted (backward compat)", () => {
it("renders only the Overview pill when availability is omitted (v1.4.36 W4d strict gate)", () => {
// v1.4.36 W4d — the pre-v1.4.36 fallback used to render every
// metric pill while the analytics fetch was in flight. Users with
// zero observations on a metric briefly saw nav targets they
// couldn't act on, and on a failed fetch the pills stayed up
// forever. The strict gate flips the contract: no availability =>
// only Overview, then pills light up as data arrives.
const html = render(<InsightsTabStrip />);
expect(html).toContain(">Blood Pressure<");
expect(html).toContain(">Weight<");
expect(html).toContain(">Pulse<");
expect(html).toContain(">Mood<");
expect(html).toContain(">Medication<");
expect(html).toContain(">BMI<");
expect(html).toContain(">Sleep<");
expect(html).toContain(">Overview<");
expect(html).not.toContain(">Blood Pressure<");
expect(html).not.toContain(">Weight<");
expect(html).not.toContain(">Pulse<");
expect(html).not.toContain(">Mood<");
expect(html).not.toContain(">Medication<");
expect(html).not.toContain(">BMI<");
expect(html).not.toContain(">Sleep<");
});
it("drops pills for metrics with zero observations", () => {
@@ -120,14 +127,40 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
});
describe("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
// v1.4.36 W4d — strict gate: every test here passes an explicit
// `availability` that lights up the wave-A metrics so the popover
// parent pill renders. Pre-v1.4.36 the no-availability fallback
// lit up every pill by default; under the strict gate the same
// intent is expressed by passing the right summaries.
const fullAvailability: InsightInputs = {
summaries: {
BLOOD_PRESSURE_SYS: fakeSummary(5),
BLOOD_PRESSURE_DIA: fakeSummary(5),
PULSE: fakeSummary(5),
WEIGHT: fakeSummary(5),
SLEEP_DURATION: fakeSummary(5),
VO2_MAX: fakeSummary(5),
STEPS: fakeSummary(5),
ACTIVE_ENERGY: fakeSummary(5),
ACTIVE_ENERGY_BURNED: fakeSummary(5),
HEART_RATE_VARIABILITY: fakeSummary(5),
RESTING_HEART_RATE: fakeSummary(5),
OXYGEN_SATURATION: fakeSummary(5),
BODY_TEMPERATURE: fakeSummary(5),
},
hasMood: true,
hasMedication: true,
hasWorkouts: true,
};
it("renders a single 'Vitals' parent pill instead of five wave-A pills in the strip row", () => {
// No availability ⇒ every wave-A pill is visible ⇒ the parent pill
// appears once. The five sub-pages still render in the popover
// body but that lives in a Radix Portal which `renderToStaticMarkup`
// does not serialise (no portal target in SSR) — so we assert on
// the strip-row only. Behaviour test for the popover body sits in
// a Playwright spec when the dev server runs.
const html = render(<InsightsTabStrip />);
// Every wave-A pill has data ⇒ the parent pill appears once. The
// five sub-pages still render in the popover body but that lives
// in a Radix Portal which `renderToStaticMarkup` does not serialise
// (no portal target in SSR) — so we assert on the strip-row only.
// Behaviour test for the popover body sits in a Playwright spec
// when the dev server runs.
const html = render(<InsightsTabStrip availability={fullAvailability} />);
// Parent pill — there is exactly one.
const parentMatches = html.match(/>Vitals</g);
expect(parentMatches).not.toBeNull();
@@ -155,13 +188,13 @@ describe("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
});
it("renders the parent pill as a popover trigger (button, not link)", () => {
const html = render(<InsightsTabStrip />);
const html = render(<InsightsTabStrip availability={fullAvailability} />);
expect(html).toContain('data-slot="insights-tab-strip-group"');
expect(html).toContain('data-group="vitals"');
});
it("preserves the non-grouped pills inline", () => {
const html = render(<InsightsTabStrip />);
const html = render(<InsightsTabStrip availability={fullAvailability} />);
// Flat (non-grouped) pills must still be reachable directly.
expect(html).toContain(">Blood Pressure<");
expect(html).toContain(">Pulse<");
@@ -116,4 +116,88 @@ describe("<TrendAnnotation>", () => {
/<p[^>]*data-slot="trend-annotation-empty"[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
);
});
// ── v1.4.36 W2 T3 — render-state contract ──────────────────────────
// Pre-fix the component derived "empty" purely from
// `annotation == null`, which painted "Mehr Daten nötig" on every
// cold mount and every regenerate-in-flight even though the advisor
// was about to deliver an annotation. The tri-state `status` prop
// distinguishes pending / needs_data / generated.
it("status='pending' renders a shimmer block (not the empty hint)", () => {
const html = render(
<TrendAnnotation metric="bp" annotation={null} status="pending" />,
);
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
expect(html).toContain("animate-pulse");
// Empty hint and filled prose MUST be absent during pending.
expect(html).not.toContain('data-slot="trend-annotation-empty"');
expect(html).not.toContain('data-slot="trend-annotation"');
expect(html).not.toContain("Awaiting more data");
});
it("status='pending' announces a busy state for assistive tech", () => {
const html = render(
<TrendAnnotation metric="mood" annotation={null} status="pending" />,
);
expect(html).toMatch(/role="status"/);
expect(html).toMatch(/aria-busy="true"/);
});
it("status='pending' keeps painting the shimmer even when an annotation is supplied (regenerate-in-flight)", () => {
// During a forced regenerate the previous annotation is still in
// cache. We surface the pending state so the user doesn't read
// stale prose alongside a freshly-fired generation.
const html = render(
<TrendAnnotation
metric="weight"
annotation="stale annotation from a previous generation"
status="pending"
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
expect(html).not.toContain("stale annotation");
});
it("status='needs_data' renders the empty hint regardless of annotation presence", () => {
const html = render(
<TrendAnnotation
metric="bp"
annotation="this should be ignored"
status="needs_data"
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-empty"/);
expect(html).toContain("Awaiting more data");
expect(html).not.toContain("this should be ignored");
});
it("status='generated' renders the prose + confidence chip", () => {
const html = render(
<TrendAnnotation
metric="bp"
annotation="BP trending down 4 mmHg over 30 days."
status="generated"
confidence="high"
/>,
);
expect(html).toMatch(/data-slot="trend-annotation"/);
expect(html).toContain("BP trending down 4 mmHg");
expect(html).toMatch(/data-slot="trend-annotation-confidence"/);
expect(html).not.toContain("Awaiting more data");
});
it("omitting status falls back to the legacy annotation==null mapping (back-compat)", () => {
// Old call sites that don't pass `status` must still see the
// previous two-state behaviour: filled prose when supplied, empty
// hint when null.
const filled = render(
<TrendAnnotation metric="bp" annotation="BP trending down." />,
);
expect(filled).toMatch(/data-slot="trend-annotation"/);
expect(filled).toContain("BP trending down");
const empty = render(<TrendAnnotation metric="bp" annotation={null} />);
expect(empty).toMatch(/data-slot="trend-annotation-empty"/);
});
});
@@ -89,6 +89,22 @@ describe("<TrendsRow>", () => {
expect(slots.length).toBe(3);
});
it("pins every chart slot to h-[140px] so Recharts mounts with a known size (CLS fix)", () => {
// v1.4.36 W2 — without an explicit height the Recharts
// ResponsiveContainer mounts with width=-1 height=-1 and emits a
// console warning per chart. The mood card also drifted taller
// than BP / weight on hydration. Pinning the wrapper at 140 px
// matches the mini-chart's internal default; the row paints with
// zero layout shift.
const html = render(<TrendsRow />);
const slots =
html.match(/data-slot="trends-row-chart-slot"[^>]*class="[^"]*"/g) ?? [];
expect(slots.length).toBe(3);
for (const slot of slots) {
expect(slot).toMatch(/h-\[140px\]/);
}
});
it("renders annotations when supplied", () => {
const html = render(
<TrendsRow
@@ -112,6 +128,37 @@ describe("<TrendsRow>", () => {
expect(matches.length).toBe(3);
});
it("loading=true paints the pending shimmer for every metric (no 'mehr Daten' flash)", () => {
// v1.4.36 W2 T3 — pre-fix the row painted "Awaiting more data"
// across all three metrics whenever the advisor query was in
// flight. The loading flag now propagates to each annotation
// slot so the user reads a generating-state shimmer instead.
const html = render(<TrendsRow loading />);
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
expect(html).not.toContain("Awaiting more data");
});
it("loading=true keeps the shimmer even when annotations are supplied (regenerate-in-flight)", () => {
const html = render(
<TrendsRow
loading
annotations={{
bp: "stale BP",
weight: "stale weight",
mood: "stale mood",
}}
/>,
);
expect(html).not.toContain("stale BP");
expect(html).not.toContain("stale weight");
expect(html).not.toContain("stale mood");
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
});
it("propagates per-metric confidence chips", () => {
const html = render(
<TrendsRow
@@ -75,6 +75,18 @@ const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
"steps",
];
// v1.4.36 W3 T2 — optional context blocks the user can opt out of.
// Surfaced as a separate group below the per-metric exclude list so
// the intent ("don't ship my profile / medication context to the
// model") reads distinctly from the per-metric data toggles above.
// Each row gates a single labelled block at the snapshot/feature layer
// so empty blocks never reach the prompt.
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"sleep",
"medications",
"anthropometrics",
];
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
// straight into `scope.window` when the client didn't supply one.
@@ -349,6 +361,47 @@ export function CoachSettingsSheet({
</ul>
</div>
{/* v1.4.36 W3 T2 — optional context blocks. Mirrors the
EXCLUDE_OPTIONS list mechanically (same on/off switch
pattern, same toggleExclude handler) but groups the
profile / medication context toggles in their own
section so the intent reads distinctly. Empty blocks
are dropped at the snapshot/feature layer when the
toggle is on. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsContextLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsContextHint")}
</p>
<ul
data-slot="coach-prefs-context-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CONTEXT_OPTIONS.map((metric) => {
const id = `coach-prefs-context-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-context-${metric}`}
checked={checked}
onCheckedChange={(next) => toggleExclude(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* v1.4.27 F14 — the `showEvidenceByDefault` switch was
retired. The evidence disclosure is now always closed
by default and the user expands by click; surfacing
+16 -4
View File
@@ -171,10 +171,22 @@ const SUB_PAGE_GROUP_META: Record<
};
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
const visibleSlugs = SUB_PAGE_SLUGS.filter((slug) => {
if (!availability) return true;
return hasMetricData(SUB_PAGE_TABS[slug].metric, availability);
});
// v1.4.36 W4d — strict gating contract: a sub-page only surfaces a
// pill when the availability inputs CONFIRM the metric has at
// least one observation in the 90-day window. The legacy
// `if (!availability) return true` fallback rendered every pill
// while the analytics fetch was in flight, so users with empty
// metrics briefly saw nav targets they couldn't act on (and on
// a failed/stale fetch the pills stayed up forever). The new
// contract: no availability → no metric pills, only Overview.
// The pills appear progressively as `summaries[METRIC].count`
// flips from 0 to ≥1 — exactly the auto-light-up behaviour
// described in `metric-availability.ts` line 26.
const visibleSlugs = availability
? SUB_PAGE_SLUGS.filter((slug) =>
hasMetricData(SUB_PAGE_TABS[slug].metric, availability),
)
: [];
const visibleSet = new Set(visibleSlugs);
const entries: TabEntry[] = [
+55 -2
View File
@@ -19,17 +19,44 @@ import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
* Confidence band is optional — surfaced as a small `Badge` chip when
* a backing correlation gives us one. The chip is purely visual and
* never adds new copy beyond the `low / moderate / high` translation.
*
* v1.4.36 W2 T3 — render-state contract (`status`). Pre-fix the
* component derived empty vs filled from `annotation == null` alone,
* which painted "Mehr Daten nötig" on every cold mount and every
* regenerate-in-flight, even when the advisor was about to deliver an
* annotation. The status prop now distinguishes:
*
* - `"pending"` — advisor query in flight or regenerate firing.
* Renders a 3-line shimmer block matching the
* filled-state row contract.
* - `"needs_data"` — advisor returned `annotation = null`. Renders
* the "Mehr Daten nötig" hint.
* - `"generated"` — advisor returned a string. Renders the prose +
* optional confidence chip.
*
* Back-compat: when `status` is omitted, the legacy
* `annotation == null → empty` mapping still applies so existing call
* sites that don't pass the prop keep their current behaviour.
*/
export type TrendAnnotationConfidenceBand = "low" | "moderate" | "high";
export type TrendAnnotationStatus = "pending" | "needs_data" | "generated";
interface TrendAnnotationProps {
/** The metric this annotation describes. Drives the empty-state copy. */
metric: "bp" | "weight" | "mood";
/** AI-authored sentence. `null` renders the empty-state hint. */
/** AI-authored sentence. `null` renders the empty-state hint (legacy path). */
annotation: string | null;
/** Optional discrete confidence band. */
confidence?: TrendAnnotationConfidenceBand;
/**
* Tri-state render contract. When supplied, drives the branch
* directly (and overrides the legacy `annotation == null` empty
* fallback). Default `undefined` keeps the legacy two-state mapping
* for back-compat with existing call sites.
*/
status?: TrendAnnotationStatus;
}
const CONFIDENCE_LABEL_KEY: Record<TrendAnnotationConfidenceBand, string> = {
@@ -48,10 +75,36 @@ export function TrendAnnotation({
metric,
annotation,
confidence,
status,
}: TrendAnnotationProps) {
const { t } = useTranslations();
if (!annotation) {
// v1.4.36 W2 T3 — render-state contract. When `status` is supplied
// it drives the branch directly; otherwise we fall back on the
// legacy `annotation == null → empty` mapping so existing callers
// (tests, isolated mounts) keep their previous behaviour.
const resolvedStatus: TrendAnnotationStatus =
status ?? (annotation ? "generated" : "needs_data");
if (resolvedStatus === "pending") {
return (
<div
data-slot="trend-annotation-pending"
data-metric={metric}
role="status"
aria-busy="true"
aria-live="polite"
className="border-border/60 bg-card/40 space-y-1.5 rounded-md border p-3 motion-reduce:animate-none"
aria-label={t("insights.trendAnnotation.pendingLabel")}
>
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse rounded" />
</div>
);
}
if (resolvedStatus === "needs_data" || !annotation) {
return (
<p
data-slot="trend-annotation-empty"
+32 -8
View File
@@ -4,10 +4,11 @@ import dynamic from "next/dynamic";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
type TrendAnnotationStatus,
} from "./trend-annotation";
/**
@@ -32,7 +33,7 @@ const MoodChart = dynamic(
import("@/components/charts/mood-chart").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
interface TrendsRowProps {
@@ -56,15 +57,35 @@ interface TrendsRowProps {
weight?: TrendAnnotationConfidenceBand;
mood?: TrendAnnotationConfidenceBand;
};
/**
* v1.4.36 W2 T3 — advisor query / mutation in flight. When true,
* every per-metric annotation slot paints a pending shimmer instead
* of the "Mehr Daten nötig" empty hint. Resolves the recurring
* complaint where the empty hint flashed across all three metrics
* while the advisor was generating fresh annotations.
*/
loading?: boolean;
}
export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
export function TrendsRow({
annotations,
confidence,
loading = false,
}: TrendsRowProps) {
const { t } = useTranslations();
const { user } = useAuth();
const userTimezone = user?.timezone;
const bpAnnotation = annotations?.bp ?? null;
const weightAnnotation = annotations?.weight ?? null;
const moodAnnotation = annotations?.mood ?? null;
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
// advisor's loading flag + the annotation presence. Pending wins
// over needs_data so a mid-generation regenerate doesn't flash the
// empty hint between the spinner and the new prose.
const statusFor = (annotation: string | null): TrendAnnotationStatus => {
if (loading) return "pending";
return annotation ? "generated" : "needs_data";
};
return (
<section
@@ -117,8 +138,8 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
wrapper (which carries a heavier shell on a default
shadcn Card) lines up with the BP/weight tiles' lighter
shell. Both chart types ship the same data-slot now. */}
<div data-slot="trends-row-chart-slot" className="shrink-0">
<HealthChartDynamic
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
<HealthChartDynamicMini
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
@@ -132,6 +153,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
metric="bp"
annotation={bpAnnotation}
confidence={confidence?.bp}
status={statusFor(bpAnnotation)}
/>
</div>
<div
@@ -139,8 +161,8 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
data-metric="weight"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<div data-slot="trends-row-chart-slot" className="shrink-0">
<HealthChartDynamic
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
<HealthChartDynamicMini
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
@@ -153,6 +175,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
metric="weight"
annotation={weightAnnotation}
confidence={confidence?.weight}
status={statusFor(weightAnnotation)}
/>
</div>
<div
@@ -160,7 +183,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
data-metric="mood"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<div data-slot="trends-row-chart-slot" className="shrink-0">
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
<MoodChart
title={t("charts.mood")}
mini
@@ -171,6 +194,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
metric="mood"
annotation={moodAnnotation}
confidence={confidence?.mood}
status={statusFor(moodAnnotation)}
/>
</div>
</div>
+4 -12
View File
@@ -8,7 +8,6 @@ import {
ChevronsLeft,
ChevronsRight,
Home,
Info,
Lightbulb,
LogOut,
Monitor,
@@ -144,17 +143,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{/* v1.4.33 IW7 — "About" used to live as its own Settings
section but the three small cards inside (identity / links /
update check) collectively get read once or twice a year, so
the top-level slot has been collapsed into this dropdown. The
route stays alive at `/settings/about`. */}
<DropdownMenuItem asChild>
<Link href="/settings/about" className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
{t("nav.about")}
</Link>
</DropdownMenuItem>
{/* v1.4.36 W4e — About moved into the Admin Console
(`/admin/about`). Admins reach it through the Admin sidebar
and the mobile section strip; non-admin users see the
public `/about` landing instead. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
+4 -10
View File
@@ -3,7 +3,6 @@
import {
Bell,
ChevronDown,
Info,
LogIn,
LogOut,
Monitor,
@@ -95,15 +94,10 @@ export function TopBar() {
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{/* v1.4.33 IW7 — "About" folded out of Settings into the
user-card dropdown; mobile dropdown mirrors the
desktop sidebar so the entry shows up here too. */}
<DropdownMenuItem asChild>
<Link href="/settings/about" className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
{t("nav.about")}
</Link>
</DropdownMenuItem>
{/* v1.4.36 W4e — About moved into the Admin Console
(`/admin/about`). The dropdown entry was redundant
for the small audience that still reaches it (admins
only, on the order of once or twice a year). */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
@@ -0,0 +1,275 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
/**
* v1.4.36 W4a — IntakeHistoryListV2 SSR smoke tests.
*
* Mirrors the project convention: `renderToStaticMarkup` +
* react-query data seeded via `QueryClient.setQueryData()`. Sort-toggle
* is a useState interaction and cannot fire through SSR, so the
* toggle test exercises the seeded sortDir/sortBy variants and pins
* the rendered indicator markup.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
type IntakeRow = {
id: string;
medicationId: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
source: "WEB" | "API" | "REMINDER" | "IMPORT";
createdAt: string;
};
function seedIntake(
client: QueryClient,
medId: string,
rows: IntakeRow[],
total: number = rows.length,
opts: {
sortBy?: "takenAt" | "scheduledFor";
sortDir?: "asc" | "desc";
limit?: number;
offset?: number;
} = {},
) {
const sortBy = opts.sortBy ?? "takenAt";
const sortDir = opts.sortDir ?? "desc";
const limit = opts.limit ?? 25;
const offset = opts.offset ?? 0;
client.setQueryData(
[
"medications",
medId,
"intake",
"list",
{ sortBy, sortDir, limit, offset },
],
{ events: rows, meta: { total, limit, offset } },
);
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<IntakeHistoryListV2> — empty state", () => {
it("renders the empty-state title and CTA in English", () => {
const client = makeClient();
seedIntake(client, "med-1", []);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
expect(html).toContain("No intakes recorded yet");
expect(html).toContain("Open daily intake");
expect(html).toContain('href="/medications"');
});
it("renders the empty-state title and CTA in German", () => {
const client = makeClient();
seedIntake(client, "med-1", []);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
"de",
);
expect(html).toContain("Noch keine Einnahmen erfasst");
expect(html).toContain("Zur Tagesübersicht");
});
});
describe("<IntakeHistoryListV2> — populated rows", () => {
it("renders one row per intake event with status + source badges", () => {
const client = makeClient();
seedIntake(client, "med-1", [
{
id: "evt-1",
medicationId: "med-1",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
{
id: "evt-2",
medicationId: "med-1",
scheduledFor: "2026-05-14T08:00:00.000Z",
takenAt: null,
skipped: true,
source: "REMINDER",
createdAt: "2026-05-14T20:00:00.000Z",
},
]);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
expect(html).toContain("Taken");
expect(html).toContain("Skipped");
expect(html).toContain("Website");
expect(html).toContain("Telegram / Reminder");
});
it("renders the section title across both locales", () => {
const client = makeClient();
seedIntake(client, "med-1", []);
const enHtml = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
expect(enHtml).toContain("Intake history");
const deClient = makeClient();
seedIntake(deClient, "med-1", []);
const deHtml = render(
<IntakeHistoryListV2 medicationId="med-1" />,
deClient,
"de",
);
expect(deHtml).toContain("Einnahme-Verlauf");
});
});
describe("<IntakeHistoryListV2> — sort indicators", () => {
it("renders a desc arrow on the active sort column", () => {
const client = makeClient();
seedIntake(client, "med-1", [
{
id: "evt-1",
medicationId: "med-1",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
]);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
// Default sort = takenAt desc, so the "taken" header carries the
// arrow-down icon and the "scheduled" header carries the neutral
// dual-arrow icon.
expect(html).toContain("arrow-down");
expect(html).toContain("arrow-up-down");
});
it("applies focus-visible ring + min-h-11 to sort header buttons", () => {
const client = makeClient();
seedIntake(client, "med-a11y", [
{
id: "evt-1",
medicationId: "med-a11y",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
]);
const html = render(
<IntakeHistoryListV2 medicationId="med-a11y" />,
client,
);
expect(html).toContain("focus-visible:ring-ring");
expect(html).toContain("focus-visible:ring-2");
expect(html).toContain("focus-visible:ring-offset-2");
expect(html).toContain("min-h-11");
});
it("renders pagination controls only when more than one page", () => {
const client = makeClient();
// 50 total > pageSize 25 → pagination should render
seedIntake(
client,
"med-2",
[
{
id: "evt-1",
medicationId: "med-2",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
],
50,
);
const html = render(
<IntakeHistoryListV2 medicationId="med-2" />,
client,
);
expect(html).toContain("Previous");
expect(html).toContain("Next");
// a11y: pagination buttons clear the 44 px mobile touch floor and
// collapse to 36 px from sm+ so the row stays compact on desktop.
expect(html).toContain("min-h-11");
expect(html).toContain("sm:min-h-9");
});
it("hides pagination when total ≤ pageSize", () => {
const client = makeClient();
seedIntake(
client,
"med-3",
[
{
id: "evt-1",
medicationId: "med-3",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
],
1,
);
const html = render(
<IntakeHistoryListV2 medicationId="med-3" />,
client,
);
expect(html).not.toContain(">Previous<");
expect(html).not.toContain(">Next<");
});
});
@@ -0,0 +1,280 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState, useMemo } from "react";
import Link from "next/link";
import { Loader2, ArrowUp, ArrowDown, ArrowUpDown, History } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
/**
* v1.4.36 W4a — Lite intake-history surface.
*
* The 886-LoC v1 was retired in v1.4.28 (commit 8c81af10) with the
* note "no other consumer". The standalone medication-history page
* lost its dose timeline as a side effect — Marc reported the page
* now shows only the heading for non-GLP-1 meds. This v2 restores a
* read-only timeline for ALL medication kinds without the inline CRUD
* the v1 carried (edit / delete still happen through the regular
* medication-intake routes elsewhere).
*
* Contract:
* - Paginated 25/page via the existing `?limit=&offset=` query on
* `GET /api/medications/[id]/intake`. The route already supports
* `sortBy=takenAt|scheduledFor|createdAt|source` + `sortDir=asc|desc`.
* - Sortable by `takenAt` (default) and `scheduledFor`. Click the
* header to toggle the direction; switching column resets to desc.
* - Source badge surfaces WEB / API / REMINDER / IMPORT using the
* existing `medications.source*` keys.
* - Empty state copy + CTA back to the daily intake page.
* - No edit-in-row, no delete buttons.
*/
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
interface IntakeEvent {
id: string;
medicationId: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
source: IntakeSource;
createdAt: string;
}
interface IntakeListResponse {
events: IntakeEvent[];
meta: { total: number; limit: number; offset: number };
}
interface IntakeHistoryListV2Props {
medicationId: string;
/** Override the default page size — primarily for tests. */
pageSize?: number;
}
type SortKey = "takenAt" | "scheduledFor";
const DEFAULT_PAGE_SIZE = 25;
export function IntakeHistoryListV2({
medicationId,
pageSize = DEFAULT_PAGE_SIZE,
}: IntakeHistoryListV2Props) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const formatters = useFormatters();
const [sortBy, setSortBy] = useState<SortKey>("takenAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(0);
const offset = page * pageSize;
const { data, isLoading, isError } = useQuery<IntakeListResponse>({
queryKey: [
"medications",
medicationId,
"intake",
"list",
{ sortBy, sortDir, limit: pageSize, offset },
],
queryFn: async () => {
const params = new URLSearchParams({
sortBy,
sortDir,
limit: String(pageSize),
offset: String(offset),
});
const res = await fetch(
`/api/medications/${medicationId}/intake?${params.toString()}`,
);
if (!res.ok) throw new Error("Failed to load intake history");
return (await res.json()).data as IntakeListResponse;
},
enabled: isAuthenticated,
});
const sourceLabels: Record<IntakeSource, string> = useMemo(
() => ({
WEB: t("medications.sourceWeb"),
API: t("medications.sourceApi"),
REMINDER: t("medications.sourceReminder"),
IMPORT: t("medications.sourceImport"),
}),
[t],
);
const total = data?.meta.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const events = data?.events ?? [];
const toggleSort = (key: SortKey) => {
if (key === sortBy) {
setSortDir((d) => (d === "desc" ? "asc" : "desc"));
} else {
setSortBy(key);
setSortDir("desc");
}
setPage(0);
};
const sortIndicator = (key: SortKey) => {
if (key !== sortBy) {
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-50" />;
}
return sortDir === "desc" ? (
<ArrowDown className="ml-1 inline h-3 w-3" />
) : (
<ArrowUp className="ml-1 inline h-3 w-3" />
);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("medications.intakeHistory")}
</CardTitle>
</CardHeader>
<CardContent className="pb-4 md:pb-6">
{isLoading ? (
<div className="flex h-24 items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : isError ? (
<p className="text-destructive text-sm">
{t("medications.loadFailed")}
</p>
) : events.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
icon={<History className="size-6" />}
title={t("medications.intakeHistoryEmptyTitle")}
description={t("medications.intakeHistoryEmptyDescription")}
action={
<Button asChild size="sm">
<Link href="/medications">
{t("medications.intakeHistoryEmptyAction")}
</Link>
</Button>
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<button
type="button"
onClick={() => toggleSort("takenAt")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByTaken")}
>
{t("medications.intakeHistoryColTaken")}
{sortIndicator("takenAt")}
</button>
</TableHead>
<TableHead>
<button
type="button"
onClick={() => toggleSort("scheduledFor")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByScheduled")}
>
{t("medications.intakeHistoryColScheduled")}
{sortIndicator("scheduledFor")}
</button>
</TableHead>
<TableHead>{t("medications.intakeHistoryColStatus")}</TableHead>
<TableHead>{t("medications.intakeHistoryColSource")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => (
<TableRow key={event.id}>
<TableCell className="text-sm">
{event.takenAt ? formatters.dateTime(event.takenAt) : "—"}
</TableCell>
<TableCell className="text-sm">
{formatters.dateTime(event.scheduledFor)}
</TableCell>
<TableCell>
<Badge
variant={event.skipped ? "outline" : "secondary"}
className="text-xs"
>
{event.skipped
? t("medications.intakeHistoryStatusSkipped")
: t("medications.intakeHistoryStatusTaken")}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{sourceLabels[event.source] ?? event.source}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
<span>
{t("medications.intakeHistoryPageInfo", {
page: page + 1,
total: totalPages,
count: total,
})}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
{t("medications.intakeHistoryPrev")}
</Button>
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() =>
setPage((p) => Math.min(totalPages - 1, p + 1))
}
disabled={page >= totalPages - 1}
>
{t("medications.intakeHistoryNext")}
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,147 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { AboutSection, UpdateBadge } from "../about-section";
/**
* v1.4.36 W4f — AboutSection update-badge contract.
*
* The dedicated "Updates" card with the manual "Check for updates"
* button is gone. The 24 h auto-check still runs on mount and
* surfaces a subtle ArrowUpCircle badge next to the version line
* ONLY when the check returns `{ status: "newer_available", ... }`.
*
* These SSR smoke tests pin the badge presence by seeding the
* react-query cache with a fake version payload (so the version
* line paints synchronously) and exercising the path that decides
* whether the badge mounts. The badge itself owns its own
* `useState`, so its presence is gated on the runCheck branch —
* SSR renders the initial `null` state. The actual badge render
* is therefore covered by the runtime store-set + re-render in
* future Playwright surfaces; here we pin the *baseline* contract
* that the version line + Sources/docs panel still render and
* the legacy manual-check chrome is gone.
*/
function makeClient() {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
// Seed the version query so the section paints inline rather than
// rendering its loader skeleton.
client.setQueryData(["api", "version"], {
version: "1.4.36",
buildSha: "abc1234",
builtAt: "2026-05-17T08:00:00.000Z",
license: "AGPL-3.0",
repository: "https://github.com/MBombeck/HealthLog",
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
docs: "https://github.com/MBombeck/HealthLog/tree/main/docs",
});
return client;
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = makeClient();
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
// Fail the auto-check fetch so the badge stays in its initial
// (no-result) state. This lets the SSR smoke tests assert the
// "no badge" baseline; the badge-visible branch is unit-tested
// separately by toggling `updateResult` via a forced re-render
// in the future Playwright + RTL suite.
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network disabled in tests");
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("<AboutSection> — update badge", () => {
it("renders the version line without a badge by default", () => {
const html = render(<AboutSection />);
expect(html).toContain("v1.4.36");
// No badge — ArrowUpCircle's lucide class name is the unique signal.
expect(html).not.toContain("lucide-arrow-up-circle");
});
it("drops the manual 'Check for updates' button surface entirely", () => {
const html = render(<AboutSection />);
// Pre-v1.4.36 the section carried a "Check for updates" CTA
// alongside a last-checked timestamp. Both are now gone — only
// the 24 h auto-check stays, surfacing through the badge above.
expect(html).not.toContain(">Check for updates<");
expect(html).not.toContain(">Check now<");
expect(html).not.toContain("last-checked-iso");
});
it("preserves the Sources & docs panel", () => {
const html = render(<AboutSection />);
expect(html).toContain("Sources &amp; docs");
});
it("uses a dedicated 'Built' label for the build-time row rather than the gitSha fallback", () => {
const html = render(<AboutSection />);
// Distinct label keeps the dt copy separate from the "Build" gitSha
// header above it; the prior `t('builtAt', { time: '' }).trim()`
// pattern silently leaked the gitSha key as a fallback.
expect(html).toContain(">Built<");
// Only ONE column should ever read "Build" — the gitSha header.
const buildOccurrences = html.match(/>Build</g) ?? [];
expect(buildOccurrences.length).toBe(1);
});
});
describe("<UpdateBadge> — a11y contract", () => {
it("renders an anchor with aria-label, focus-visible ring, and 44 px hit target when a URL is known", () => {
const html = renderToStaticMarkup(
<UpdateBadge
latestTag="v1.4.99"
htmlUrl="https://example.test/releases/v1.4.99"
ariaLabel="A newer version is available: v1.4.99"
/>,
);
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
expect(html).toContain("focus-visible:ring-ring");
expect(html).toContain("focus-visible:ring-2");
expect(html).toContain("focus-visible:ring-offset-2");
expect(html).toContain("text-primary");
expect(html).toContain('href="https://example.test/releases/v1.4.99"');
});
it("falls back to a span with role=img + aria-label when no URL is known", () => {
const html = renderToStaticMarkup(
<UpdateBadge
latestTag="v1.4.99"
htmlUrl={null}
ariaLabel="A newer version is available: v1.4.99"
/>,
);
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
});
});
@@ -132,16 +132,17 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("onboarding.tour.");
});
it("<AboutSection> renders version + license + check-for-updates button", () => {
it("<AboutSection> renders version + license + sources/docs cards (no Updates panel)", () => {
const html = render(<AboutSection />);
expect(html).toContain("About");
// The new about.* keys resolve. v1.4.3 split the page into three
// titled cards (HealthLog identity, Sources & docs, Updates) and
// shortened the button label from "Check for updates" to "Check now"
// because the surrounding heading already names the section.
expect(html).toContain("Updates");
// v1.4.36 W4f — the dedicated "Updates" card with its manual
// "Check now" button is gone. The 24 h auto-check still runs and
// surfaces a subtle ArrowUpCircle badge next to the version line
// when a newer release is available. The Sources & docs card
// stays put.
expect(html).toContain("Sources &amp; docs");
expect(html).toContain("Check now");
expect(html).not.toContain(">Check now<");
expect(html).not.toContain(">Check for updates<");
expect(html).not.toContain("settings.about.");
});
@@ -198,11 +199,12 @@ describe("settings sections — SSR smoke", () => {
it("<AboutSection> resolves about.* keys in German", () => {
const html = render(<AboutSection />, "de");
// v1.4.3: button label shortened to "Jetzt prüfen" because the
// section heading "Updates" already provides context.
expect(html).toContain("Updates");
expect(html).toContain("Jetzt prüfen");
// v1.4.36 W4f — "Updates" panel + "Jetzt prüfen" button removed;
// the surviving German copy is the section title + the Sources
// & docs heading + the version line.
expect(html).toContain("Über");
expect(html).toContain("Quellen &amp; Dokumentation");
expect(html).not.toContain(">Jetzt prüfen<");
});
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
+81 -102
View File
@@ -3,15 +3,13 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ArrowUpCircle,
BookOpen,
CheckCircle2,
ExternalLink,
GitBranch,
Info,
Loader2,
RefreshCw,
Sparkles,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -69,6 +67,51 @@ function writeLastCheckedISO(iso: string): void {
}
}
/**
* v1.4.36 QA H7 — keyboard- and touch-accessible update badge.
*
* Renders as a 44 px-square hit target (anchor when a release URL is
* known, span fallback otherwise) with a focus-visible ring, semantic
* `text-primary` colour, and a real `aria-label` describing the
* available version. Exported so the SSR test can pin the contract
* without driving the parent's auto-check effect.
*/
export function UpdateBadge({
latestTag,
htmlUrl,
ariaLabel,
}: {
latestTag: string;
htmlUrl: string | null;
ariaLabel: string;
}) {
if (htmlUrl) {
return (
<a
href={htmlUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaLabel}
title={ariaLabel}
className="text-primary focus-visible:ring-ring inline-flex min-h-11 min-w-11 items-center justify-center rounded-md hover:opacity-80 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<ArrowUpCircle className="h-4 w-4" />
<span className="sr-only">{latestTag}</span>
</a>
);
}
return (
<span
role="img"
aria-label={ariaLabel}
title={ariaLabel}
className="text-primary inline-flex min-h-11 min-w-11 items-center justify-center"
>
<ArrowUpCircle className="h-4 w-4" />
</span>
);
}
export function AboutSection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -85,16 +128,15 @@ export function AboutSection() {
staleTime: Infinity,
});
const [checking, setChecking] = useState(false);
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
// only the 24 h auto-check stays. When the auto-check reports
// `newer_available`, a subtle upward-arrow badge appears next to
// the version line with the latest tag in its tooltip.
const [updateResult, setUpdateResult] = useState<CheckUpdatesResult | null>(
null,
);
const [lastCheckedISO, setLastCheckedISO] = useState<string | null>(() =>
readLastCheckedISO(),
);
async function runCheck(): Promise<void> {
setChecking(true);
try {
const res = await fetch("/api/version/check-updates");
if (!res.ok) {
@@ -109,9 +151,7 @@ export function AboutSection() {
const result = json.data as CheckUpdatesResult;
setUpdateResult(result);
if (result.status !== "unknown") {
const iso = new Date().toISOString();
writeLastCheckedISO(iso);
setLastCheckedISO(iso);
writeLastCheckedISO(new Date().toISOString());
}
} catch {
setUpdateResult({
@@ -119,14 +159,12 @@ export function AboutSection() {
current: version?.version ?? "",
reason: "network_error",
});
} finally {
setChecking(false);
}
}
// Auto-check on mount when the last successful check is older than the
// refresh interval (or has never run). Keeps the UI populated without
// requiring a click — but won't spam GitHub on every navigation.
// refresh interval (or has never run). Keeps the badge fresh without a
// click — but won't spam GitHub on every navigation.
// The setState inside runCheck() is intentional: the check is the
// whole point of the effect, and the staleness gate prevents a
// cascading-render loop because the timestamp resets after the run.
@@ -184,6 +222,23 @@ export function AboutSection() {
{t("settings.about.version")}
</dt>
<dd className="font-mono font-medium">v{version.version}</dd>
{/* v1.4.36 W4f — subtle upward-arrow badge that
appears only when the 24 h auto-check reports a
newer release. Hover/tap reveals the latest tag via
the title attribute (read by screen readers + as a
native tooltip on desktop, and announced inline as
the link copy on mobile). No bouncing, no badge
counter, no manual recheck button — the badge is
the entire surface. */}
{updateResult?.status === "newer_available" && (
<UpdateBadge
latestTag={updateResult.latest_tag}
htmlUrl={updateResult.html_url}
ariaLabel={t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
/>
)}
</div>
<div className="flex items-baseline gap-2">
@@ -205,8 +260,7 @@ export function AboutSection() {
{version.builtAt && (
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.builtAt", { time: "" }).trim() ||
t("settings.about.gitSha")}
{t("settings.about.builtAtLabel")}
</dt>
<dd className="text-muted-foreground">
{fmt.date(version.builtAt)}
@@ -262,91 +316,16 @@ export function AboutSection() {
</div>
)}
{/* Updates — proper heading + last-check timestamp + manual button
that proxies through `/api/version/check-updates`. The v1.4.2
version called `api.github.com` directly from the browser, which
the production CSP blocked silently — that's why "nothing
happened" when the user clicked the button. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<RefreshCw className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.about.updatesHeading")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={runCheck}
disabled={checking}
>
{checking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{checking
? t("settings.about.checking")
: t("settings.about.checkUpdates")}
</Button>
<p className="text-muted-foreground text-xs">
{lastCheckedISO
? t("settings.about.lastChecked", {
time: fmt.dateTime(lastCheckedISO),
})
: t("settings.about.lastCheckedNever")}
</p>
</div>
{updateResult?.status === "up_to_date" && (
<p
role="status"
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
>
<CheckCircle2 className="h-4 w-4" />
{t("settings.about.upToDate")}
</p>
)}
{updateResult?.status === "newer_available" && (
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
<Sparkles className="text-dracula-purple h-4 w-4" />
{updateResult.html_url ? (
<a
href={updateResult.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-2 hover:underline"
>
{t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
</a>
) : (
<span>
{t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
</span>
)}
</p>
)}
{updateResult?.status === "unknown" && (
<p
role="alert"
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
>
<XCircle className="h-4 w-4" />
{t("settings.about.checkFailed")}
</p>
)}
</div>
)}
{/* v1.4.36 W4f — the dedicated "Updates" card with the
manual "Check for updates" button is gone. The 24 h auto-
check still runs on mount and writes its result into the
local `updateResult` state; the consequence renders as the
subtle upward-arrow badge next to the version line above
rather than as a separate panel. The card-level last-check
timestamp, success / failure states, and the explicit
button were collectively never the point of the surface —
the only signal users ever cared about was "is a newer
release out?", and the badge answers that with no clicks. */}
</section>
);
}
+5 -1
View File
@@ -164,7 +164,11 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
if (!active) return;
active.scrollIntoView({
block: "nearest",
inline: "center",
// v1.4.36 W4b — `inline: "start"` pins the active chip to the
// left edge of the scroller. `inline: "center"` over-scrolled
// and the first one or two chips were unreachable on narrow
// viewports.
inline: "start",
behavior: "smooth",
});
}, [activeSlug]);