mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(dashboard): open the day with a verdict
The dashboard now leads with a daily band: the greeting, the one item that matters right now — an overdue or upcoming dose, a very high blood-pressure reading, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring on the right. The verdict resolves from a fixed nine-rung ladder on the client with the thresholds shared from the nudge engine through a dependency-free module, the blood-pressure rung uses fixed crisis floors rather than personal target ranges, and a cached next-due in the past never renders as overdue. The band reserves its exact footprint from the first paint, swaps in with the tiles in one pass, respects the hour-format preference, and can be turned off in the dashboard settings; the greeting moves out of the page header into the band.
This commit is contained in:
1 parent
ad33fa46ed
commit
ea29e9cf1b
20 files changed
+1451
-94
No files matched your search
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard page header: title + greeting line, the customize shortcut,
|
||||
* and the quick-add dropdown that opens the quick-entry sheets.
|
||||
* Dashboard page header: title, the customize shortcut, and the
|
||||
* quick-add dropdown that opens the quick-entry sheets. The greeting
|
||||
* line moved into the hero band (`<DashboardHero>`), which derives it
|
||||
* from the snapshot's server-computed `greetingHour`.
|
||||
*
|
||||
* Extracted from the dashboard page; the page owns the open-state the
|
||||
* dropdown items set via `onQuickEntry`.
|
||||
@@ -20,35 +22,16 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
|
||||
export function DashboardHeader({
|
||||
welcomeText,
|
||||
onQuickEntry,
|
||||
}: {
|
||||
welcomeText: string;
|
||||
onQuickEntry: (dialog: NonNullable<QuickEntryDialog>) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
/* v1.4.37 W4a item 7 — centre-align the Hinzufügen button
|
||||
against the 2-line title block on mobile (< sm). The
|
||||
`welcomeText` line wraps under the title at < 380 px so the
|
||||
button used to float at the top of the row without a
|
||||
baseline anchor; `items-center sm:items-start` keeps the
|
||||
mobile vertical centre while preserving the original
|
||||
top-aligned posture on sm+ (where the title is one line). */
|
||||
<div className="flex items-center justify-between gap-4 sm:items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("dashboard.title")}
|
||||
</h1>
|
||||
{/* v1.16.8 — `min-h-5` reserves the greeting's line box from the
|
||||
SSR pass on: the text personalises (name appended) on the
|
||||
first client re-render after hydration, and the reserved
|
||||
line keeps the header from collapsing/growing around that
|
||||
swap. */}
|
||||
<p className="text-muted-foreground mt-1 min-h-5 text-sm">
|
||||
{welcomeText}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("dashboard.title")}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Customize shortcut to the dashboard-customization settings
|
||||
(the tile/layout editor at /settings/dashboard). Sits to
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { DashboardHeroSkeleton } from "../dashboard-hero-skeleton";
|
||||
|
||||
/**
|
||||
* Structural pin for the hero loading silhouette: the skeleton must
|
||||
* occupy the EXACT final footprint of `<DashboardHero>` (same min-h
|
||||
* floors, same md two-column split, same fixed 120 px ring circle) so
|
||||
* the loaded band swaps in place with zero layout shift — and it must
|
||||
* stay invisible to assistive tech with no focusable content.
|
||||
*/
|
||||
describe("<DashboardHeroSkeleton>", () => {
|
||||
const html = renderToStaticMarkup(<DashboardHeroSkeleton />);
|
||||
|
||||
it("carries the data-slot and aria-hidden contract", () => {
|
||||
expect(html).toContain('data-slot="dashboard-hero-skeleton"');
|
||||
const root = html.match(
|
||||
/<div[^>]*data-slot="dashboard-hero-skeleton"[^>]*>/,
|
||||
);
|
||||
expect(root).not.toBeNull();
|
||||
expect(root![0]).toContain('aria-hidden="true"');
|
||||
});
|
||||
|
||||
it("reserves the hero's exact footprint classes", () => {
|
||||
for (const cls of [
|
||||
"min-h-[8.75rem]",
|
||||
"md:min-h-[9.5rem]",
|
||||
"hero-gradient",
|
||||
"rounded-xl",
|
||||
]) {
|
||||
expect(html).toContain(cls);
|
||||
}
|
||||
});
|
||||
|
||||
it("mirrors the two-column md:flex-row split with the fixed ring circle", () => {
|
||||
expect(html).toContain("md:flex-row");
|
||||
expect(html).toContain("size-[120px]");
|
||||
expect(html).toContain("rounded-full");
|
||||
});
|
||||
|
||||
it("contains no focusable elements", () => {
|
||||
expect(html).not.toContain("<button");
|
||||
expect(html).not.toContain("<a ");
|
||||
expect(html).not.toContain("<input");
|
||||
expect(html).not.toContain("tabindex");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { DashboardHero } from "../dashboard-hero";
|
||||
|
||||
/**
|
||||
* `<DashboardHero>` contract:
|
||||
*
|
||||
* - every verdict variant renders its sentence + the single CTA the
|
||||
* ladder assigns (link variants carry an href, quick-entry variants
|
||||
* a button; allQuiet carries none);
|
||||
* - the briefing rung renders the model headline VERBATIM as plain
|
||||
* text (no i18n key);
|
||||
* - the dose row prints the day tally with next-at / all-done / none
|
||||
* detail, and the documented stale-cache state (past `nextDueAt`,
|
||||
* `nextDueOverdue: false`) renders as the plain summary — never as
|
||||
* overdue;
|
||||
* - the greeting derives from the snapshot's server-computed
|
||||
* `greetingHour` and personalises from the snapshot username;
|
||||
* - the right column always paints the `sm` ScoreRing — provisional
|
||||
* (never "0") when the score is null.
|
||||
*
|
||||
* The component reads the wall clock (`new Date()`) to age the cached
|
||||
* snapshot honestly, so the suite pins the system time; the snapshot's
|
||||
* own timezone (`Europe/Berlin`) keeps every assertion independent of
|
||||
* the host zone.
|
||||
*/
|
||||
|
||||
const NOW = new Date("2026-06-10T10:00:00.000Z"); // 12:00 Europe/Berlin
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function summary(partial: Partial<DataSummary>): DataSummary {
|
||||
return {
|
||||
count: 0,
|
||||
latest: null,
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
median: null,
|
||||
avg7: null,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function medsToday(partial: Partial<MedsTodayBlock> = {}): MedsTodayBlock {
|
||||
return {
|
||||
activeCount: 0,
|
||||
scheduledToday: 0,
|
||||
takenToday: 0,
|
||||
skippedToday: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function baseSnapshot(
|
||||
overrides: Partial<DashboardSnapshot> = {},
|
||||
): DashboardSnapshot {
|
||||
return {
|
||||
user: {
|
||||
username: "tester",
|
||||
timezone: "Europe/Berlin",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
glucoseUnit: null,
|
||||
onboardingTourCompleted: true,
|
||||
greetingHour: 12,
|
||||
},
|
||||
layout: { version: 1, widgets: [] },
|
||||
layoutCatalogue: [],
|
||||
metricStates: {},
|
||||
tiles: {
|
||||
summaries: {},
|
||||
lastSeenByType: {},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
extras: null,
|
||||
medsToday: medsToday(),
|
||||
healthScore: null,
|
||||
briefing: null,
|
||||
briefingState: "preparing",
|
||||
briefingUpdatedAt: null,
|
||||
briefingStale: false,
|
||||
generatedAt: NOW.toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function isoHoursFromNow(hours: number): string {
|
||||
return new Date(NOW.getTime() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function render(
|
||||
snapshot: DashboardSnapshot,
|
||||
opts: { locale?: "en" | "de"; onQuickEntry?: () => void } = {},
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={opts.locale ?? "de"}>
|
||||
<DashboardHero
|
||||
snapshot={snapshot}
|
||||
onQuickEntry={opts.onQuickEntry ?? (() => undefined)}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DashboardHero> — verdict variants", () => {
|
||||
it("doseOverdue: sentence with the medication name + quick-entry CTA button", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
medsToday: medsToday({
|
||||
scheduledToday: 2,
|
||||
nextDueAt: isoHoursFromNow(-1),
|
||||
nextDueOverdue: true,
|
||||
nextDueMedicationName: "Metformin",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="doseOverdue"');
|
||||
expect(html).toContain("Eine Dosis Metformin ist überfällig.");
|
||||
expect(html).toContain('data-slot="dashboard-hero-cta"');
|
||||
expect(html).toContain("Jetzt eintragen");
|
||||
// Quick-entry CTA is a real button, not a link.
|
||||
expect(html).toMatch(/<button[^>]*data-slot="dashboard-hero-cta"/);
|
||||
});
|
||||
|
||||
it("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
tiles: {
|
||||
summaries: {
|
||||
BLOOD_PRESSURE_SYS: summary({ latest: 184, count: 5 }),
|
||||
BLOOD_PRESSURE_DIA: summary({ latest: 96, count: 5 }),
|
||||
},
|
||||
lastSeenByType: {
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
lastSeenAt: isoHoursFromNow(-2),
|
||||
daysAgo: 0,
|
||||
},
|
||||
},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="bpCritical"');
|
||||
expect(html).toContain("sehr hoch (184/96)");
|
||||
expect(html).toContain('href="/insights/blood-pressure"');
|
||||
expect(html).toContain("Blutdruck ansehen");
|
||||
});
|
||||
|
||||
it("doseUpcoming: preference-aware wall-clock time + name + quick-entry CTA", () => {
|
||||
const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
medsToday: medsToday({
|
||||
scheduledToday: 2,
|
||||
takenToday: 1,
|
||||
nextDueAt,
|
||||
nextDueMedicationName: "Jardiance",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time(
|
||||
nextDueAt,
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="doseUpcoming"');
|
||||
expect(html).toContain(`Um ${expectedTime} steht Jardiance an.`);
|
||||
expect(html).toContain("Jetzt eintragen");
|
||||
// The dose row repeats the same instant as "Nächste um …".
|
||||
expect(html).toContain(`Nächste um ${expectedTime}`);
|
||||
});
|
||||
|
||||
it("weightDrift: sentence + link to the weight insight", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
user: { ...baseSnapshot().user, heightCm: 180 },
|
||||
tiles: {
|
||||
summaries: {
|
||||
// Green range for 180 cm ≈ 59.9–80.7 kg; 7d mean sits 1 kg
|
||||
// further outside than the 30d mean.
|
||||
WEIGHT: summary({ avg7: 86, avg30: 85, count: 10 }),
|
||||
},
|
||||
lastSeenByType: {
|
||||
WEIGHT: { lastSeenAt: isoHoursFromNow(-3), daysAgo: 0 },
|
||||
},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="weightDrift"');
|
||||
expect(html).toContain(
|
||||
"Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.",
|
||||
);
|
||||
expect(html).toContain('href="/insights/weight"');
|
||||
expect(html).toContain("Gewicht ansehen");
|
||||
});
|
||||
|
||||
it("shortNights: locale-formatted hours + link to the sleep insight", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
tiles: {
|
||||
summaries: {
|
||||
// 372 min = 6.2 h — clearly under the 6.5 h fire line.
|
||||
SLEEP_DURATION: summary({ avg7: 372, count: 7 }),
|
||||
},
|
||||
lastSeenByType: {
|
||||
SLEEP_DURATION: { lastSeenAt: isoHoursFromNow(-5), daysAgo: 0 },
|
||||
},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="shortNights"');
|
||||
// German decimal comma via the locale number formatter.
|
||||
expect(html).toContain("im Schnitt 6,2 h.");
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
expect(html).toContain("Schlaf ansehen");
|
||||
});
|
||||
|
||||
it("silence: day count + quick-entry measurement CTA", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
tiles: {
|
||||
summaries: {},
|
||||
lastSeenByType: {
|
||||
WEIGHT: { lastSeenAt: isoHoursFromNow(-9 * 24), daysAgo: 9 },
|
||||
},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="silence"');
|
||||
expect(html).toContain("Seit 9 Tagen keine Messung.");
|
||||
expect(html).toContain("Messung erfassen");
|
||||
expect(html).toMatch(/<button[^>]*data-slot="dashboard-hero-cta"/);
|
||||
});
|
||||
|
||||
it("scoreDrop: point delta + link to /insights", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
healthScore: { score: 58, band: "yellow", delta: -14 },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="scoreDrop"');
|
||||
expect(html).toContain("liegt 14 Punkte unter der Vorwoche.");
|
||||
expect(html).toContain('href="/insights"');
|
||||
expect(html).toContain("Insights öffnen");
|
||||
});
|
||||
|
||||
it("briefing: renders the model headline verbatim as plain text + Insights link", () => {
|
||||
const headline = "Systolic crept up over seven days.";
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
briefingState: "ready",
|
||||
briefingStale: false,
|
||||
briefingUpdatedAt: NOW.toISOString(),
|
||||
briefing: {
|
||||
paragraph: "p",
|
||||
keyFindings: [
|
||||
{
|
||||
tone: "watch",
|
||||
headline,
|
||||
detail: "detail",
|
||||
delta: null,
|
||||
sourceWindow: "7d",
|
||||
sourceMetric: "bp",
|
||||
},
|
||||
],
|
||||
} as DashboardSnapshot["briefing"],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-verdict-variant="briefing"');
|
||||
expect(html).toContain(headline);
|
||||
expect(html).toContain('href="/insights"');
|
||||
expect(html).toContain("Insights öffnen");
|
||||
});
|
||||
|
||||
it("allQuiet: sentence with NO CTA in the band", () => {
|
||||
const html = render(baseSnapshot());
|
||||
expect(html).toContain('data-verdict-variant="allQuiet"');
|
||||
expect(html).toContain("Alles ruhig");
|
||||
expect(html).not.toContain('data-slot="dashboard-hero-cta"');
|
||||
});
|
||||
|
||||
it("exactly one CTA renders for an actionable verdict", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
medsToday: medsToday({
|
||||
scheduledToday: 1,
|
||||
nextDueOverdue: true,
|
||||
nextDueAt: isoHoursFromNow(-1),
|
||||
nextDueMedicationName: "Metformin",
|
||||
}),
|
||||
healthScore: { score: 50, band: "yellow", delta: -20 },
|
||||
}),
|
||||
);
|
||||
const ctas = html.match(/data-slot="dashboard-hero-cta"/g) ?? [];
|
||||
expect(ctas).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — dose row", () => {
|
||||
it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("1 von 3 Dosen genommen");
|
||||
const doseRow = html.match(
|
||||
/<div[^>]*data-slot="dashboard-hero-doses"[^>]*>/,
|
||||
);
|
||||
expect(doseRow).not.toBeNull();
|
||||
for (const cls of [
|
||||
"bg-card/65",
|
||||
"border-border/60",
|
||||
"rounded-xl",
|
||||
"shadow-sm",
|
||||
"backdrop-blur-sm",
|
||||
]) {
|
||||
expect(doseRow![0]).toContain(cls);
|
||||
}
|
||||
});
|
||||
|
||||
it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
medsToday: medsToday({
|
||||
scheduledToday: 2,
|
||||
takenToday: 1,
|
||||
skippedToday: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("1 von 2 Dosen genommen");
|
||||
expect(html).toContain("Alle Dosen für heute erledigt");
|
||||
expect(html).not.toContain("Nächste um");
|
||||
});
|
||||
|
||||
it("no doses scheduled → 'Heute keine Dosen geplant'", () => {
|
||||
const html = render(baseSnapshot());
|
||||
expect(html).toContain("Heute keine Dosen geplant");
|
||||
expect(html).not.toContain("Dosen genommen");
|
||||
});
|
||||
|
||||
it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
medsToday: medsToday({
|
||||
scheduledToday: 2,
|
||||
takenToday: 1,
|
||||
nextDueAt: isoHoursFromNow(-0.5), // anchor passed AFTER build
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: "Metformin",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("1 von 2 Dosen genommen");
|
||||
expect(html).not.toContain("Nächste um");
|
||||
expect(html).not.toContain("überfällig");
|
||||
expect(html).toContain('data-verdict-variant="allQuiet"');
|
||||
});
|
||||
|
||||
it("a next-due anchor on TOMORROW's local day stays off the dose row", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
medsToday: medsToday({
|
||||
scheduledToday: 2,
|
||||
takenToday: 1,
|
||||
nextDueAt: isoHoursFromNow(20), // 08:00 Berlin tomorrow
|
||||
nextDueMedicationName: "Metformin",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("1 von 2 Dosen genommen");
|
||||
expect(html).not.toContain("Nächste um");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — greeting", () => {
|
||||
it("personalises from the snapshot username with the server greeting hour", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
user: { ...baseSnapshot().user, username: "Sam", greetingHour: 8 },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Guten Morgen Sam, willkommen zurück.");
|
||||
});
|
||||
|
||||
it("falls back to the name-less line when the username is blank", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
user: { ...baseSnapshot().user, username: " ", greetingHour: 20 },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Guten Abend, willkommen zurück.");
|
||||
});
|
||||
|
||||
it("derives the daypart from greetingHour without any client Intl walk", () => {
|
||||
const day = render(
|
||||
baseSnapshot({
|
||||
user: { ...baseSnapshot().user, greetingHour: 13 },
|
||||
}),
|
||||
);
|
||||
expect(day).toContain("Hallo tester, willkommen zurück.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — score column", () => {
|
||||
it("renders the sm ScoreRing with the score + band", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
healthScore: { score: 82, band: "green", delta: 2 },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-slot="score-ring"');
|
||||
expect(html).toContain('data-band="green"');
|
||||
expect(html).toContain("Health Score");
|
||||
});
|
||||
|
||||
it("null score renders the provisional ring (not 0) at identical geometry", () => {
|
||||
const html = render(baseSnapshot({ healthScore: null }));
|
||||
expect(html).toContain('data-slot="score-ring"');
|
||||
expect(html).toContain('data-provisional="true"');
|
||||
expect(html).toContain("Noch nicht genug Daten");
|
||||
// The ring announces "not enough data", never a zero value.
|
||||
const aria = html.match(
|
||||
/<div[^>]*data-slot="score-ring"[^>]*aria-label="([^"]+)"/,
|
||||
);
|
||||
expect(aria).not.toBeNull();
|
||||
expect(aria![1]).not.toMatch(/\b0\b/);
|
||||
// Same fixed footprint as the scored state — the column never
|
||||
// collapses (the 120 px sm geometry rides an inline style).
|
||||
expect(html).toContain("width:120px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — source pins", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/dashboard/hero/dashboard-hero.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("quick-entry CTA wires the verdict's target into onQuickEntry", () => {
|
||||
expect(src).toMatch(/onClick=\{\(\) => onQuickEntry\(cta\.target\)\}/);
|
||||
});
|
||||
|
||||
it("re-derives the verdict with a fresh now per snapshot", () => {
|
||||
expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/);
|
||||
});
|
||||
|
||||
it("uses the hero-gradient chrome WITHOUT glow-purple", () => {
|
||||
expect(src).toContain("hero-gradient");
|
||||
expect(src).not.toContain("glow-purple");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading silhouette for the dashboard hero band.
|
||||
*
|
||||
* Mirrors the real `<DashboardHero>` footprint exactly — same
|
||||
* `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]`
|
||||
* floor, same two-column `md:flex-row` split with the fixed 120 px ring
|
||||
* circle on the right — so the swap to the loaded hero happens in place
|
||||
* with zero layout shift. The left column reserves the greeting line,
|
||||
* the verdict sentence + CTA row, and the dose-row pill.
|
||||
*
|
||||
* Always `aria-hidden` with no focusable content: the tile-strip
|
||||
* skeleton alongside carries the page's loading semantics, and a second
|
||||
* announcement here would double up for screen readers. Reduced motion
|
||||
* is honoured inside the `<Skeleton>` primitive
|
||||
* (`motion-reduce:animate-none`).
|
||||
*/
|
||||
export function DashboardHeroSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="dashboard-hero-skeleton"
|
||||
className="hero-gradient relative isolate min-h-[8.75rem] overflow-hidden rounded-xl px-4 py-5 sm:px-6 md:min-h-[9.5rem]"
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
{/* Greeting line. */}
|
||||
<Skeleton className="h-4 w-48 max-w-full" />
|
||||
{/* Verdict sentence + CTA slot. */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<Skeleton className="h-5 w-72 max-w-full" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
{/* Dose-row pill. */}
|
||||
<Skeleton className="h-9 w-64 max-w-full rounded-xl" />
|
||||
</div>
|
||||
{/* Fixed right column — the ScoreRing's `sm` circle (120 px). */}
|
||||
<div className="flex shrink-0 items-center justify-center md:justify-end">
|
||||
<Skeleton className="size-[120px] rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard hero — the daily-verdict band between the page header and
|
||||
* the tile strip.
|
||||
*
|
||||
* Purely presentational: every input rides in on the already-resolved
|
||||
* `DashboardSnapshot`, the verdict is re-derived on render with a fresh
|
||||
* `now` (so a cached snapshot ages honestly), and the only callback is
|
||||
* the quick-entry opener the page owns. Three blocks:
|
||||
*
|
||||
* - left column: greeting line (absorbed from the old header
|
||||
* paragraph — the snapshot's server-computed `greetingHour` keeps
|
||||
* the daypart free of any client `Intl` work), the verdict sentence
|
||||
* with its single CTA button, and the dose row;
|
||||
* - right column: the shared `<ScoreRing>` at `sm` geometry. A null
|
||||
* score renders the ring's provisional state at the identical
|
||||
* 120 px footprint, so the column never collapses.
|
||||
*
|
||||
* Defensive contract (cached snapshot): `medsToday.nextDueAt` in the
|
||||
* past with `nextDueOverdue: false` means the slot's anchor passed
|
||||
* after the snapshot was built. The verdict resolver already falls
|
||||
* through (rung 1 keys on the flag, rung 3 requires a future anchor),
|
||||
* and the dose row below only prints "next at" for a FUTURE anchor on
|
||||
* the user's local today — the stale state renders as the plain day
|
||||
* summary, never as overdue.
|
||||
*
|
||||
* Rung 8 (`briefing`) renders the model-authored headline VERBATIM as a
|
||||
* plain React text child — no i18n key, no HTML, no markdown — per the
|
||||
* repo-wide no-markdown-renderer rule.
|
||||
*
|
||||
* The page h1 stays in `<DashboardHeader>`; this band is supporting
|
||||
* content, so it renders non-heading text only (matching the Insights
|
||||
* hero strip, which also carries no heading element).
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { Pill } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
import {
|
||||
resolveDashboardVerdict,
|
||||
type DashboardVerdictVariant,
|
||||
} from "@/lib/dashboard/verdict";
|
||||
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Variant → message key. `briefing` is deliberately absent — its
|
||||
* sentence is the model headline rendered verbatim, not a translation.
|
||||
* The bpCritical rung surfaces under the `bpHigh` copy key.
|
||||
*/
|
||||
const VERDICT_MESSAGE_KEY: Record<
|
||||
Exclude<DashboardVerdictVariant, "briefing">,
|
||||
string
|
||||
> = {
|
||||
doseOverdue: "dashboard.hero.verdict.doseOverdue",
|
||||
bpCritical: "dashboard.hero.verdict.bpHigh",
|
||||
doseUpcoming: "dashboard.hero.verdict.doseUpcoming",
|
||||
weightDrift: "dashboard.hero.verdict.weightDrift",
|
||||
shortNights: "dashboard.hero.verdict.shortNights",
|
||||
silence: "dashboard.hero.verdict.silence",
|
||||
scoreDrop: "dashboard.hero.verdict.scoreDrop",
|
||||
allQuiet: "dashboard.hero.verdict.allQuiet",
|
||||
};
|
||||
|
||||
/** Variant → CTA label key. `allQuiet` carries no CTA (cta: null). */
|
||||
const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
|
||||
doseOverdue: "dashboard.hero.action.takeDose",
|
||||
doseUpcoming: "dashboard.hero.action.takeDose",
|
||||
bpCritical: "dashboard.hero.action.viewBp",
|
||||
weightDrift: "dashboard.hero.action.viewWeight",
|
||||
shortNights: "dashboard.hero.action.viewSleep",
|
||||
silence: "dashboard.hero.action.logMeasurement",
|
||||
scoreDrop: "dashboard.hero.action.viewInsights",
|
||||
briefing: "dashboard.hero.action.viewInsights",
|
||||
};
|
||||
|
||||
/** YYYY-MM-DD key of `d`'s calendar day in `tz` (mirrors the resolver). */
|
||||
function dayKeyInTz(d: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function DashboardHero({
|
||||
snapshot,
|
||||
onQuickEntry,
|
||||
}: {
|
||||
snapshot: DashboardSnapshot;
|
||||
onQuickEntry: (dialog: NonNullable<QuickEntryDialog>) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
// Times format in the USER's timezone from the snapshot (the shared
|
||||
// `useFormatters()` hook falls back to the display default), honouring
|
||||
// the per-user hour-cycle preference like the medication cards do.
|
||||
// Only ever invoked post-snapshot, so no pre-paint Intl work.
|
||||
const fmt = useMemo(
|
||||
() => makeFormatters(locale, snapshot.user.timezone, timeFormat),
|
||||
[locale, snapshot.user.timezone, timeFormat],
|
||||
);
|
||||
|
||||
// Fresh `now` per snapshot evaluation: the resolver re-derives every
|
||||
// freshness window against the current instant, so a snapshot served
|
||||
// from cache ages honestly between refetches.
|
||||
const verdict = useMemo(
|
||||
() => resolveDashboardVerdict(snapshot, new Date()),
|
||||
[snapshot],
|
||||
);
|
||||
|
||||
// ── Greeting — absorbed from the old header paragraph ──────────────
|
||||
// The server-computed wall-clock hour keeps the daypart stable and
|
||||
// Intl-free; the hero only mounts once the snapshot resolved (the
|
||||
// skeleton carries the footprint before that), so the personalised
|
||||
// line can never disagree with SSR output.
|
||||
const hour = snapshot.user.greetingHour;
|
||||
const timeGreeting =
|
||||
hour >= 5 && hour < 12
|
||||
? t("dashboard.greeting.morning")
|
||||
: hour >= 12 && hour < 18
|
||||
? t("dashboard.greeting.day")
|
||||
: t("dashboard.greeting.evening");
|
||||
const name = snapshot.user.username.trim();
|
||||
const welcomeText =
|
||||
name.length > 0
|
||||
? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name })
|
||||
: t("dashboard.welcomeBack", { greeting: timeGreeting });
|
||||
|
||||
// ── Verdict sentence + CTA ──────────────────────────────────────────
|
||||
const sentence =
|
||||
verdict.variant === "briefing"
|
||||
? // Model headline VERBATIM as a plain text child — no key.
|
||||
String(verdict.values.headline ?? "")
|
||||
: t(VERDICT_MESSAGE_KEY[verdict.variant], {
|
||||
...verdict.values,
|
||||
// The resolver emits a fixed 24 h wall-clock string; re-format
|
||||
// the underlying instant through the user's hour-cycle
|
||||
// preference when we still hold it.
|
||||
...(verdict.variant === "doseUpcoming" &&
|
||||
snapshot.medsToday.nextDueAt !== null
|
||||
? { time: fmt.time(snapshot.medsToday.nextDueAt) }
|
||||
: {}),
|
||||
...(verdict.variant === "shortNights" &&
|
||||
typeof verdict.values.hours === "number"
|
||||
? { hours: fmt.number(verdict.values.hours, 1) }
|
||||
: {}),
|
||||
});
|
||||
const cta = verdict.cta;
|
||||
const ctaLabelKey = CTA_LABEL_KEY[verdict.variant];
|
||||
|
||||
// ── Dose row ────────────────────────────────────────────────────────
|
||||
const meds = snapshot.medsToday;
|
||||
const now = new Date();
|
||||
const tz = snapshot.user.timezone;
|
||||
const unresolvedRemain =
|
||||
meds.scheduledToday > meds.takenToday + meds.skippedToday;
|
||||
const nextDueMs = meds.nextDueAt !== null ? Date.parse(meds.nextDueAt) : NaN;
|
||||
// "Next at" only for a FUTURE anchor on the user's local today. A past
|
||||
// anchor with `nextDueOverdue: false` is the documented stale-cache
|
||||
// state and must render as the plain summary — never as overdue.
|
||||
const showNextAt =
|
||||
unresolvedRemain &&
|
||||
Number.isFinite(nextDueMs) &&
|
||||
nextDueMs >= now.getTime() &&
|
||||
dayKeyInTz(new Date(nextDueMs), tz) === dayKeyInTz(now, tz);
|
||||
const doseDetail =
|
||||
meds.scheduledToday > 0
|
||||
? !unresolvedRemain
|
||||
? t("dashboard.hero.doses.allDone")
|
||||
: showNextAt
|
||||
? t("dashboard.hero.doses.nextAt", {
|
||||
time: fmt.time(new Date(nextDueMs)),
|
||||
})
|
||||
: null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="dashboard-hero"
|
||||
className={cn(
|
||||
// `.hero-gradient` carries its own border tint; deliberately
|
||||
// no glow treatment — the band sits above the tile strip and
|
||||
// must read calmer than the /insights hero.
|
||||
"hero-gradient relative isolate overflow-hidden rounded-xl",
|
||||
"min-h-[8.75rem] px-4 py-5 sm:px-6 md:min-h-[9.5rem]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<p
|
||||
data-slot="dashboard-hero-greeting"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{welcomeText}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p
|
||||
data-slot="dashboard-hero-verdict"
|
||||
data-verdict-variant={verdict.variant}
|
||||
className="text-foreground text-base font-medium"
|
||||
>
|
||||
{sentence}
|
||||
</p>
|
||||
{cta !== null && ctaLabelKey ? (
|
||||
cta.kind === "link" ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-slot="dashboard-hero-cta"
|
||||
>
|
||||
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
data-slot="dashboard-hero-cta"
|
||||
onClick={() => onQuickEntry(cta.target)}
|
||||
>
|
||||
{t(ctaLabelKey)}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
data-slot="dashboard-hero-doses"
|
||||
className="bg-card/65 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2 shadow-sm backdrop-blur-sm"
|
||||
>
|
||||
<Pill
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-foreground truncate text-sm">
|
||||
{meds.scheduledToday > 0
|
||||
? t("dashboard.hero.doses.summary", {
|
||||
taken: meds.takenToday,
|
||||
scheduled: meds.scheduledToday,
|
||||
})
|
||||
: t("dashboard.hero.doses.none")}
|
||||
</span>
|
||||
{doseDetail !== null ? (
|
||||
<span className="text-muted-foreground shrink-0 text-sm">
|
||||
· {doseDetail}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right column — fixed 120 px ring footprint. A null score
|
||||
renders the ring's provisional state (aria announces "not
|
||||
enough data", never 0) at identical geometry, so the column
|
||||
NEVER collapses. */}
|
||||
<div className="flex shrink-0 items-center justify-center md:justify-end">
|
||||
<ScoreRing
|
||||
score={snapshot.healthScore?.score ?? null}
|
||||
band={snapshot.healthScore?.band}
|
||||
size="sm"
|
||||
label={
|
||||
snapshot.healthScore
|
||||
? t("dashboard.hero.scoreLabel")
|
||||
: t("dashboard.hero.scoreProvisional")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
@@ -269,6 +271,76 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dashboard hero (Tagesüberblick) visibility switch — one toggle above
|
||||
* the widget list, persisted as `heroVisible` on the layout blob through
|
||||
* the SAME PUT mutation the widget rows use (the Save button flushes the
|
||||
* draft). SSR smoke assertions + source pins, matching the rest of this
|
||||
* suite (no DOM runtime for state mutation).
|
||||
*/
|
||||
describe("<DashboardLayoutSection> — hero visibility switch", () => {
|
||||
it("renders one hero switch above the widget list", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const switches = html.match(/data-slot="hero-visible-switch"/g) ?? [];
|
||||
expect(switches).toHaveLength(1);
|
||||
// Above the list: the hero switch markup precedes the first widget row.
|
||||
expect(html.indexOf('data-slot="hero-visible-switch"')).toBeLessThan(
|
||||
html.indexOf('data-slot="widget-row"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels the switch with the localised copy + description", () => {
|
||||
const de = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
|
||||
expect(de).toContain("Tagesüberblick");
|
||||
expect(de).toContain(
|
||||
"Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
|
||||
);
|
||||
const en = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
expect(en).toContain("Daily overview");
|
||||
});
|
||||
|
||||
it("reflects heroVisible: default layout → checked, false → unchecked", () => {
|
||||
const checkedHtml = render(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
);
|
||||
const checked = checkedHtml.match(
|
||||
/<button[^>]*data-slot="hero-visible-switch"[^>]*>/,
|
||||
);
|
||||
expect(checked).not.toBeNull();
|
||||
expect(checked![0]).toContain('data-state="checked"');
|
||||
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false };
|
||||
const uncheckedHtml = render(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
);
|
||||
const unchecked = uncheckedHtml.match(
|
||||
/<button[^>]*data-slot="hero-visible-switch"[^>]*>/,
|
||||
);
|
||||
expect(unchecked).not.toBeNull();
|
||||
expect(unchecked![0]).toContain('data-state="unchecked"');
|
||||
});
|
||||
|
||||
it("persists through the existing PUT draft flow (source pins)", () => {
|
||||
const src = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/settings/dashboard-layout-section.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
// The toggle writes the flag onto the layout draft…
|
||||
expect(src).toMatch(
|
||||
/setDraft\(\{\s*\.\.\.layout,\s*heroVisible:\s*value\s*\}\)/,
|
||||
);
|
||||
// …which the Save button flushes via the one save mutation
|
||||
// (`apiPut("/api/dashboard/widgets", …)`) every other control uses.
|
||||
expect(src).toMatch(/onCheckedChange=\{\(v\) => setHeroVisible\(v\)\}/);
|
||||
expect(src).toMatch(
|
||||
/apiPut<DashboardLayout>\("\/api\/dashboard\/widgets", next\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the
|
||||
* iOS pin PUT validates them) but have NO web render path, so the web
|
||||
|
||||
@@ -153,6 +153,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
// tooltip. One hint paragraph is rendered once at the bottom of the list
|
||||
// and referenced by every drag handle in this section.
|
||||
const dragHintId = useId();
|
||||
// Label ↔ switch hookup for the hero (Tagesüberblick) toggle below.
|
||||
const heroToggleId = useId();
|
||||
|
||||
// v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space
|
||||
// + arrow-key reordering. The KeyboardSensor still works for users who
|
||||
@@ -243,6 +245,16 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
setDraft({ ...layout, comparisonBaseline: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard hero (daily verdict) visibility. Rides the layout blob
|
||||
* (`heroVisible`, default on) and persists through the same PUT the
|
||||
* widget toggles use — the Save button flushes it.
|
||||
*/
|
||||
function setHeroVisible(value: boolean) {
|
||||
if (!layout) return;
|
||||
setDraft({ ...layout, heroVisible: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
|
||||
* so the swap-and-renumber logic lives in exactly one place. The
|
||||
@@ -377,6 +389,33 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero (Tagesüberblick) visibility — one switch above the widget
|
||||
list because the band sits above every widget on the dashboard.
|
||||
Persists as `heroVisible` on the layout blob through the same
|
||||
PUT mutation the rows below use (Save flushes the draft). */}
|
||||
{layout && (
|
||||
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
htmlFor={heroToggleId}
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("dashboard.heroToggleLabel")}
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("dashboard.heroToggleDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={heroToggleId}
|
||||
checked={layout.heroVisible !== false}
|
||||
onCheckedChange={(v) => setHeroVisible(v)}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="hero-visible-switch"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading || !layout ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
Reference in new issue
Block a user