mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
5a3ca330ac
commit
0e2a99f8e7
70 files changed
+4982
-810
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
@@ -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 /> },
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 & 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 & 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 & Dokumentation");
|
||||
expect(html).not.toContain(">Jetzt prüfen<");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user