fix(ui): draw no empty frame and say when a read failed

"Since the last visit" was wrapped in a card by the visits section, so
the card stayed on screen empty while the window loaded, when it held
nothing and when the read failed. The component now owns its card and
draws it only with a row; a failed read shows a compact error row.

The same shapes elsewhere: the caffeine card and the mood forecast no
longer open a loading card that most often collapses, and a failed read
on them, the mood tag catalogue, the usual-range strip, the cycle ring
and the measurement drill-down now says so with a retry instead of
passing for no data. The scores strip keeps the ring's cell on a failed
calendar read. The mental-health card no longer draws an empty action
slot for a viewer without the right to start.

The frame guard now also flags a frame whose only content sits behind a
condition.

Refs #1241
This commit is contained in:
Marc-André Bombeck committed 2026-10-19 16:43:52 +02:00
1 parent d00281f35a
commit 38c106e358
23 files changed
+816 -193

No files matched your search

@@ -152,13 +152,19 @@ describe("<CycleRingTile>", () => {
expect(render(<CycleRingTile />)).toBe("");
});
it("renders nothing on a hard calendar read error", () => {
// A failed read is not a day without a cycle: the tile says it failed and
// offers a retry, instead of passing the failure off as nothing to show.
it("says a hard calendar read error failed, with a retry", () => {
calendarState.current = {
data: undefined,
isLoading: false,
isError: true,
};
expect(render(<CycleRingTile />)).toBe("");
const html = render(<CycleRingTile />);
expect(html).toContain('data-slot="wellness-cycle-tile-error"');
expect(html).toContain('role="alert"');
expect(html).toContain('data-slot="query-error-row-retry"');
expect(html).not.toContain('data-slot="wellness-cycle-tile"');
});
it("renders nothing when there is no active cycle today", () => {
@@ -26,16 +26,35 @@ function window(): [string, string] {
}
function Probe({ enabled }: { enabled: boolean }) {
const { dial, pending } = useCycleRingDial(enabled);
return <span>{`${dial}:${pending}`}</span>;
const { dial, pending, failed } = useCycleRingDial(enabled);
return <span>{`${dial}:${pending}${failed ? ":failed" : ""}`}</span>;
}
function probe(verdict: unknown | undefined, enabled = true): string {
/** Marks the calendar read as failed, with no data behind it. */
const FAILED = Symbol("failed");
function probe(
verdict: unknown | undefined | typeof FAILED,
enabled = true,
): string {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, retry: 0 } },
// `retryOnMount: false` keeps a seeded failure a failure on mount, the
// state the strip sees once the real read has given up.
defaultOptions: {
queries: { staleTime: Infinity, retry: 0, retryOnMount: false },
},
});
const [from, to] = window();
if (verdict !== undefined) {
if (verdict === FAILED) {
client
.getQueryCache()
.build(client, { queryKey: queryKeys.cycleCalendar(from, to) })
.setState({
status: "error",
error: new Error("calendar read failed"),
fetchStatus: "idle",
});
} else if (verdict !== undefined) {
client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict });
}
const html = renderToStaticMarkup(
@@ -66,6 +85,29 @@ describe("useCycleRingDial", () => {
it("is pending until the calendar read lands", () => {
expect(probe(undefined)).toBe("false:true");
});
// A failed read is not a day without a cycle: the strip keeps the ring's
// cell so the tile can say the read failed.
it("reports a failed read instead of settling it to no dial", () => {
expect(probe(FAILED)).toBe("false:false:failed");
expect(probe(FAILED, false)).toBe("false:false");
});
});
describe("the scores strip keeps the ring's cell on a failed read", () => {
it("mounts the tile on a dial or a failure, and only then drops Strain", () => {
const source = readFileSync(
join(process.cwd(), "src/app/insights/page-client.tsx"),
"utf8",
);
expect(source).toMatch(
/cycleRingCell = cycleRing\.dial \|\| cycleRing\.failed/,
);
expect(source).toMatch(
/extraTile=\{cycleRingCell \? <CycleRingTile \/> : undefined\}/,
);
expect(source).toMatch(/hideStrain=\{cycleRingCell\}/);
});
});
describe("the scores strip waits for the ring's decision", () => {
+20 -7
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { Droplets } from "lucide-react";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CycleRing } from "./cycle-ring";
@@ -31,9 +32,12 @@ import { useCycleRingCalendar } from "./use-cycle";
* `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the
* sidebar-nav entry + the summary card gate on), so the cycle read never fires
* for an account without the feature. The tile additionally renders nothing
* while the calendar read resolves, on a hard error, and when there is no
* active cycle phase today — never an empty-framed or half-painted dial in the
* strip (no gap, no placeholder).
* while the calendar read resolves and when there is no active cycle phase
* today — never an empty-framed or half-painted dial in the strip (no gap, no
* placeholder). A failed read takes the tile's place with a notice and a
* retry: silence there read as "no cycle phase today", and the summary card
* further down stays out on the same failure, so the tile is the one place
* the page says it.
*/
export function CycleRingTile({ className }: { className?: string }) {
@@ -61,11 +65,20 @@ export function CycleRingTile({ className }: { className?: string }) {
}
});
// Stay silent until the read resolves, on a hard error, and when today has
// no phase (no active cycle) — the strip must never show a half-painted or
// empty-framed cycle dial. The full cycle page owns the empty-state CTA.
// Stay silent until the read resolves and when today has no phase (no
// active cycle) — the strip must never show a half-painted or empty-framed
// cycle dial. The full cycle page owns the empty-state CTA.
if (calendar.isLoading && !calendar.data) return null;
if (calendar.isError && !calendar.data) return null;
if (calendar.isError && !calendar.data) {
return (
<QueryErrorRow
slot="wellness-cycle-tile-error"
message={t("cycle.wellnessRing.loadError")}
onRetry={() => void calendar.refetch()}
className={className}
/>
);
}
if (verdict?.phase == null || verdict.dayOfCycle == null) return null;
const hue = PHASE_HUE[verdict.phase];
+6 -1
View File
@@ -136,17 +136,22 @@ export function useCycleRingCalendar(enabled = true) {
* takes Strain's place for it): the ring renders nothing without an active
* cycle, and a cell reserved for it would sit empty in the row. While the
* calendar read is `pending` the strip holds its skeleton, so it never paints
* Strain and then swaps it for the ring. A failed read settles to no dial.
* Strain and then swaps it for the ring. A failed read draws no dial but is
* `failed`: the strip still gives the ring its cell, where the tile says the
* read failed and offers a retry. Settling it to "no dial" passed a failure
* off as a day without an active cycle.
*/
export function useCycleRingDial(enabled: boolean): {
dial: boolean;
pending: boolean;
failed: boolean;
} {
const calendar = useCycleRingCalendar(enabled);
const verdict = calendar.data?.verdict;
return {
dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null,
pending: enabled && calendar.isPending,
failed: enabled && calendar.isError && !calendar.data,
};
}
+97 -83
View File
@@ -17,7 +17,8 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { apiGet } from "@/lib/api/api-fetch";
import type {
@@ -52,6 +53,15 @@ import { useTodayKey } from "./use-today-key";
* today: the server lists every context change in it (a dose change always,
* however quiet the day around it) and every notable observation. Nothing is
* recomputed here; this only spells the rows.
*
* The component owns its card and draws nothing until it has a row to show:
* not while the window loads, not when it holds nothing worth listing. A card
* frame drawn by the caller around it stayed on screen empty in each of those
* cases (#1241), and a loading skeleton would open a card for a window that
* most often turns out empty, then collapse it again. Arriving late costs
* only the visit list below a shift, once per window; the read is cached for
* five minutes. A failed read is not a quiet window, so it says so in a
* compact row with a retry.
*/
const EVENT_ICON: Partial<Record<DayEventKind, LucideIcon>> = {
@@ -181,94 +191,98 @@ export function SinceLastVisit({
})
: [];
const loading = window.isPending && window.fetchStatus !== "idle";
const failed = window.isError;
if (!loading && !failed && rows.length === 0) return null;
// A preparation that could not be read is left out rather than shown half:
// the visit card stands on its own, and the day links stay everywhere else.
if (failed && rows.length === 0) return null;
if (window.isError && rows.length === 0) {
return (
<QueryErrorRow
slot="since-last-visit-error"
message={t("day.sinceLastVisit.loadError")}
onRetry={() => void window.refetch()}
/>
);
}
// Nothing to show is nothing drawn: still loading, or a quiet window. The
// visit card stands on its own, and the day links stay everywhere else.
if (rows.length === 0) return null;
const visible = showAll ? rows : rows.slice(0, VISIBLE_ROWS);
const lastLabel = fmt.dateShortSmartCalendar(lastDay);
const nextDay = dateKeyOfInstant(next.occurredAt, timeZone);
return (
<section
data-slot="since-last-visit"
aria-labelledby={`since-last-visit-${next.id}`}
className="space-y-2"
>
<div className="flex items-baseline justify-between gap-3">
<h3 id={`since-last-visit-${next.id}`} className={DAY_SECTION_LABEL}>
{t("day.sinceLastVisit.title", { date: lastLabel })}
</h3>
{rows.length > 0 ? (
<span className="text-muted-foreground shrink-0 text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
) : null}
</div>
{loading && rows.length === 0 ? (
<div className="space-y-2" aria-hidden="true">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
{visible.map((row) => (
<li
key={row.key}
data-slot="since-last-visit-row"
className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3 gap-y-0.5 py-2.5 sm:grid-cols-[7.5rem_1.25rem_minmax(0,1fr)]"
<Card className="gap-3 py-3 md:gap-3 md:py-4">
<CardContent>
<section
data-slot="since-last-visit"
aria-labelledby={`since-last-visit-${next.id}`}
className="space-y-2"
>
<div className="flex items-baseline justify-between gap-3">
<h3
id={`since-last-visit-${next.id}`}
className={DAY_SECTION_LABEL}
>
<span className="col-start-2 text-sm sm:col-start-1 sm:row-start-1">
<DayLink date={row.date}>
{fmt.dateShortSmartCalendar(row.date)}
</DayLink>
</span>
<row.Icon
className="text-muted-foreground row-span-2 mt-0.5 size-4 sm:row-span-1 sm:row-start-1"
aria-hidden="true"
/>
<div className="col-start-2 min-w-0 sm:col-start-3 sm:row-start-1">
{row.title ? <p className="text-sm">{row.title}</p> : null}
{row.meta ? (
<p className="text-muted-foreground text-xs">{row.meta}</p>
) : null}
</div>
</li>
))}
</ul>
)}
{rows.length > VISIBLE_ROWS ? (
<button
type="button"
aria-expanded={showAll}
onClick={() => setShowAll((value) => !value)}
className="focus-visible:ring-ring/50 min-h-11 rounded-md text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
>
{showAll
? t("day.sinceLastVisit.fewer")
: tCount("day.sinceLastVisit.all", rows.length)}
</button>
) : null}
<p className="text-muted-foreground text-xs">
{t("day.sinceLastVisit.note")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
>
<Link
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
data-slot="since-last-visit-report"
>
<FileText className="size-4" aria-hidden="true" />
{t("day.sinceLastVisit.report", { date: lastLabel })}
</Link>
</Button>
</section>
{t("day.sinceLastVisit.title", { date: lastLabel })}
</h3>
<span className="text-muted-foreground shrink-0 text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
</div>
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
{visible.map((row) => (
<li
key={row.key}
data-slot="since-last-visit-row"
className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3 gap-y-0.5 py-2.5 sm:grid-cols-[7.5rem_1.25rem_minmax(0,1fr)]"
>
<span className="col-start-2 text-sm sm:col-start-1 sm:row-start-1">
<DayLink date={row.date}>
{fmt.dateShortSmartCalendar(row.date)}
</DayLink>
</span>
<row.Icon
className="text-muted-foreground row-span-2 mt-0.5 size-4 sm:row-span-1 sm:row-start-1"
aria-hidden="true"
/>
<div className="col-start-2 min-w-0 sm:col-start-3 sm:row-start-1">
{row.title ? <p className="text-sm">{row.title}</p> : null}
{row.meta ? (
<p className="text-muted-foreground text-xs">{row.meta}</p>
) : null}
</div>
</li>
))}
</ul>
{rows.length > VISIBLE_ROWS ? (
<button
type="button"
aria-expanded={showAll}
onClick={() => setShowAll((value) => !value)}
className="focus-visible:ring-ring/50 min-h-11 rounded-md text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
>
{showAll
? t("day.sinceLastVisit.fewer")
: tCount("day.sinceLastVisit.all", rows.length)}
</button>
) : null}
<p className="text-muted-foreground text-xs">
{t("day.sinceLastVisit.note")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
>
<Link
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
data-slot="since-last-visit-report"
>
<FileText className="size-4" aria-hidden="true" />
{t("day.sinceLastVisit.report", { date: lastLabel })}
</Link>
</Button>
</section>
</CardContent>
</Card>
);
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -47,9 +47,12 @@ vi.mock("@/lib/api/api-fetch", async () => {
};
});
import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url";
import type { DayNotableWindowResponse } from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { VisitsSection } from "../visits-section";
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
@@ -69,10 +72,14 @@ function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
};
}
function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) {
function render(
list: { upcoming: EncounterDTO[]; past: EncounterDTO[] },
seed?: (queryClient: QueryClient) => void,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
seed?.(queryClient);
queryClient.setQueryData(
queryKeys.encounterList(null, null, undefined, undefined),
list,
@@ -160,3 +167,120 @@ describe("<VisitsSection>", () => {
expect(html).toContain('href="/checkups/practitioners"');
});
});
/**
* #1241 — "since the last visit" under the next appointment. The caller used
* to wrap it in a card, so whenever it had nothing to list (still loading, a
* quiet window, a failed read) an empty bordered card sat under the visit.
* It now draws its own card, and only once it has a row.
*/
describe("<VisitsSection> since the last visit", () => {
const next = visit({
id: "next",
status: "PLANNED",
occurredAt: "2026-09-20T09:00:00.000Z",
});
const last = visit({ id: "last", occurredAt: "2026-08-01T09:00:00.000Z" });
/** Cards painted inside the upcoming list. */
function upcomingCards(html: string): number {
const start = html.indexOf('data-slot="visits-upcoming"');
const end = html.indexOf('data-slot="visits-past"');
expect(start).toBeGreaterThan(-1);
return html.slice(start, end).split('data-slot="card"').length - 1;
}
function seedWindow(window: DayNotableWindowResponse | null) {
return (queryClient: QueryClient) => {
if (!window) return;
const tz = DEFAULT_TIMEZONE;
queryClient.setQueryData(
queryKeys.dayNotable(
dateKeyOfInstant(last.occurredAt, tz),
todayKeyInZone(tz),
),
window,
);
};
}
const emptyWindow = {
observations: [],
changes: [],
} as unknown as DayNotableWindowResponse;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-09-15T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("draws no card while the window is still loading", () => {
const withLast = render({ upcoming: [next], past: [last] });
const withoutLast = render({ upcoming: [next], past: [] });
expect(withLast).not.toContain('data-slot="since-last-visit"');
expect(upcomingCards(withLast)).toBe(upcomingCards(withoutLast));
});
it("draws no card when the window holds nothing worth listing", () => {
const html = render(
{ upcoming: [next], past: [last] },
seedWindow(emptyWindow),
);
const baseline = render({ upcoming: [next], past: [] });
expect(html).not.toContain('data-slot="since-last-visit"');
expect(upcomingCards(html)).toBe(upcomingCards(baseline));
});
it("says a failed read failed, rather than passing it off as a quiet window", () => {
const html = render({ upcoming: [next], past: [last] }, (queryClient) => {
const tz = DEFAULT_TIMEZONE;
const queryKey = queryKeys.dayNotable(
dateKeyOfInstant(last.occurredAt, tz),
todayKeyInZone(tz),
);
// The state the section sees once the real read has given up: a mount
// does not retry it.
queryClient.setQueryDefaults(queryKey, { retryOnMount: false });
queryClient
.getQueryCache()
.build(queryClient, { queryKey })
.setState({
status: "error",
error: new Error("window read failed"),
fetchStatus: "idle",
});
});
const baseline = render({ upcoming: [next], past: [] });
expect(html).toContain('data-slot="since-last-visit-error"');
expect(html).toContain('role="alert"');
expect(html).not.toContain('data-slot="since-last-visit"');
expect(upcomingCards(html)).toBe(upcomingCards(baseline));
});
it("draws one card with the rows once there is something to list", () => {
const html = render(
{ upcoming: [next], past: [last] },
seedWindow({
...emptyWindow,
changes: [
{
date: "2026-08-03",
kind: "doseChange",
section: "medications",
id: "d1",
title: "Example 5 mg",
count: 1,
href: null,
},
],
} as unknown as DayNotableWindowResponse),
);
const baseline = render({ upcoming: [next], past: [] });
expect(html).toContain('data-slot="since-last-visit"');
expect(html).toContain('data-slot="since-last-visit-row"');
expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1);
});
});
+4 -7
View File
@@ -18,7 +18,6 @@ import { Fragment, useState } from "react";
import { SinceLastVisit } from "@/components/day/since-last-visit";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -142,13 +141,11 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
onOpen={canManageVisits ? openSheet : undefined}
/>
{/* v1.42 — the next appointment carries what happened
since the last one, each date opening its day. */}
since the last one, each date opening its day. The
component draws its own card, and none when it has
nothing to list. */}
{i === 0 && lastDone ? (
<Card className="gap-3 py-3 md:gap-3 md:py-4">
<CardContent>
<SinceLastVisit next={encounter} last={lastDone} />
</CardContent>
</Card>
<SinceLastVisit next={encounter} last={lastDone} />
) : null}
</Fragment>
))}
@@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { apiGet } from "@/lib/api/api-fetch";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
@@ -79,7 +80,7 @@ export function CoachReadStrip({
const transformed = unitDisplay.isTransformed(metricType);
const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit;
const { data, isError } = useQuery({
const { data, isError, refetch } = useQuery({
queryKey: queryKeys.insightsCoachRead(metricType, locale),
queryFn: () =>
apiGet<CoachReadStripData>(
@@ -94,9 +95,20 @@ export function CoachReadStrip({
// shape: it lands above the chart, and popping in later pushed the chart
// and everything under it down the page. A landed read always paints (the
// baseline line degrades to the "learning" copy), so the placeholder only
// ever gives way to a card of its own height; a failed read drops it.
// ever gives way to a card of its own height. A failed read gives way to a
// notice with a retry: dropping the strip read as "nothing to say about
// today", which is not what happened.
if (!mounted || !data) {
if (!isAuthenticated || isError) return null;
if (!isAuthenticated) return null;
if (isError) {
return (
<QueryErrorRow
slot="coach-read-strip-error"
message={t("insights.coach.readStrip.loadError")}
onRetry={() => void refetch()}
/>
);
}
return (
<Card
data-slot="coach-read-strip"
@@ -32,7 +32,7 @@ import { Compass } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/date-format";
@@ -52,18 +52,31 @@ function formatValue(value: number, locale: string): string {
}).format(value);
}
/**
* The card over its read. Nothing is drawn while the read is on its way:
* below the first rung the card draws nothing at all, so a loading
* placeholder was a frame that often collapsed again. A failed read says so
* with a retry rather than passing for "nothing to say yet".
*/
export function MoodPrognosisCard() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
const { data, isError, refetch } = useQuery({
queryKey: queryKeys.moodPrognosis(),
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
enabled: isAuthenticated,
staleTime: 60_000,
});
if (isLoading) {
return <Skeleton className="min-h-24 w-full rounded-xl" />;
if (isError && !data) {
return (
<QueryErrorRow
slot="mood-prognosis-error"
message={t("insights.mood.prognosis.loadError")}
onRetry={() => void refetch()}
/>
);
}
if (!data) return null;
return <MoodPrognosisView reading={data} />;
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Coffee } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { TileHeader } from "@/components/insights/tile-header";
import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -16,39 +16,38 @@ const WINDOW_DAYS = 30;
/**
* v1.29 — caffeine card on `/insights/nutrients`. Rendered ONLY when the
* window carries at least one logged day (a query-error also collapses
* to nothing rather than a card the user can do nothing with — the
* hydration card above already carries the surface's error affordance).
* The EFSA safe-level value is phrased as a ceiling, never "limit
* exceeded" colouring: a day above the dashed line is just a bar above
* a dashed line.
* window carries at least one logged day. The EFSA safe-level value is
* phrased as a ceiling, never "limit exceeded" colouring: a day above the
* dashed line is just a bar above a dashed line.
*
* Nothing is drawn while the read is on its way. Most accounts never log
* caffeine, so a loading card was most often a card that collapsed again a
* moment later. A failed read says so with a retry, instead of passing for
* a month without caffeine; it is its own read, so the hydration card's
* notice does not cover it.
*/
export function CaffeineCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading } = useQuery({
const { data, isError, refetch } = useQuery({
queryKey: queryKeys.nutrientDaily("caffeine", WINDOW_DAYS),
queryFn: () =>
apiGet<NutrientDailySeries>(
`/api/nutrients/daily?nutrient=caffeine&days=${WINDOW_DAYS}`,
),
// v1.30 UI audit (M7) — this card unmounts entirely on a no-data window
// (`hasData` gate below), so a self-hoster who never logs caffeine saw
// the 280px skeleton collapse on every `/insights/nutrients` visit. A
// 60s `staleTime` (the "route swing within a minute is a free cache
// hit" convention used across the insights queries) keeps the flash to
// once per session rather than once per mount.
// The "route swing within a minute is a free cache hit" convention used
// across the insights queries.
staleTime: 60_000,
});
if (isLoading) {
if (isError && !data) {
return (
<Card data-slot="nutrients-caffeine-card">
<CardContent className="py-6" aria-hidden="true">
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
<QueryErrorRow
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
onRetry={() => void refetch()}
/>
);
}
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { ListRow } from "@/components/ui/list-row";
import {
@@ -1894,7 +1895,7 @@ function DayDrillDown({
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.measurementDrilldown(type, dayKey),
queryFn: async () => {
const params = new URLSearchParams();
@@ -1923,9 +1924,12 @@ function DayDrillDown({
}
if (error) {
return (
<div className="text-destructive py-2 text-sm">
{t("measurements.loadError")}
</div>
<QueryErrorRow
slot="measurement-drilldown-error"
message={t("measurements.loadError")}
onRetry={() => void refetch()}
className={layout === "desktop" ? "m-2" : "mt-2"}
/>
);
}
const rows = data?.measurements ?? [];
@@ -175,18 +175,20 @@ export function InstrumentCard({
</div>
)}
<CardContent className="flex h-full flex-col">
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
info control, so the Start button stands alone with clean space. */}
{canManageMind && (
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
info control, so the Start button stands alone with clean space.
Without the right to start, the slot is not drawn at all: an empty
one still took the card's gap at its foot. */}
{canManageMind && (
<CardContent className="flex h-full flex-col">
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
</div>
)}
</CardContent>
</CardContent>
)}
</Card>
);
}
+15 -2
View File
@@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -21,7 +22,10 @@ import { selectQuickTagKeys } from "./recent-tags";
*
* Reads the same `/api/mood/tags` catalogue as `MoodTagPicker` (shared query
* key → one network round-trip). Renders nothing while the catalogue is
* empty so a cleared taxonomy degrades gracefully.
* empty so a cleared taxonomy degrades gracefully. The loading placeholder
* stays because the catalogue ships seeded and the row nearly always fills
* it; a failed read says so with a retry rather than leaving the sheet
* without its tags and without a word.
*/
interface CatalogTag {
@@ -59,7 +63,7 @@ export function MoodQuickTags({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
queryFn: async () => apiGet<CatalogResponse>("/api/mood/tags"),
enabled: isAuthenticated,
@@ -69,6 +73,15 @@ export function MoodQuickTags({
if (isLoading) {
return <Skeleton className="h-11 w-full rounded-lg" />;
}
if (isError && !data) {
return (
<QueryErrorRow
slot="mood-quick-tags-error"
message={t("mood.tagCatalogLoadError")}
onRetry={() => void refetch()}
/>
);
}
if (!data || data.categories.length === 0) return null;
// Flatten the catalogue to binary tags only; the Quick row never carries
+15 -1
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Plus, Tag } from "lucide-react";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -104,7 +105,7 @@ export function MoodTagPicker({
// tile belongs to, or null when closed.
const [createGroupKey, setCreateGroupKey] = useState<string | null>(null);
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
queryFn: async () => {
return apiGet<CatalogResponse>("/api/mood/tags");
@@ -114,10 +115,23 @@ export function MoodTagPicker({
staleTime: 60 * 60 * 1000,
});
// The placeholder stays: the catalogue ships seeded, so the picker nearly
// always fills it. A failed read says so with a retry; it used to leave the
// field without its tags and without a word.
if (isLoading) {
return <Skeleton className="h-24 w-full rounded-lg" />;
}
if (isError && !data) {
return (
<QueryErrorRow
slot="mood-tag-picker-error"
message={t("mood.tagCatalogLoadError")}
onRetry={() => void refetch()}
/>
);
}
if (!data || data.categories.length === 0) {
return null;
}