refactor(ui): move 62 hand-written empty states onto EmptyState

The admin sections, the cycle, illness, medication, mood, records and
settings cards, the pickers and dialogs said nothing-here-yet in a
hand-written muted paragraph in five sizes and three alignments. List
and section states now render through EmptyState (plain, compact; the
three with a next step carry it as the action), and the six chart
bodies through ChartEmptyState at their former height. The thirteen
that stay are a field without a value or a hint inside an editor, not
a list state. The ratchet ceiling drops from 75 to 13.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:31:02 +02:00
1 parent fe021c0ffd
commit e7ee416e19
56 files changed
+353 -224

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ const SRC = join(__dirname, "..");
* The hand-written empty states that remain. Lower it when a site moves to
* `EmptyState`; never raise it.
*/
const CEILING = 75;
const CEILING = 13;
const ELEMENT_RE =
/<(p|div|span)\b([^>]*?)>\s*\{t\(\s*"([a-zA-Z0-9_.]+)"[^)]*\)\}\s*<\/\1>/g;
+1 -5
View File
@@ -394,11 +394,7 @@ function MedicationComplianceCalendar({
}
if (!data?.dailyCompliance) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
{t("insights.complianceNoData")}
</div>
);
return <EmptyState size="compact" title={t("insights.complianceNoData")} />;
}
return (
+7 -5
View File
@@ -20,6 +20,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { canLogWorkout } from "@/lib/workouts/manual-entry";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.4.32 — `/insights/workouts`.
@@ -187,12 +188,13 @@ export default function InsightsWorkoutsPageClient() {
/>
</>
) : (
<p
<EmptyState
variant="plain"
size="compact"
title={t("insights.workouts.filterEmpty")}
className="px-4 md:px-6"
data-slot="workout-list-filter-empty"
className="text-muted-foreground px-4 text-sm md:px-6"
>
{t("insights.workouts.filterEmpty")}
</p>
/>
)}
</>
)}
+6 -3
View File
@@ -28,6 +28,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { helpfulRateColour } from "./_shared";
import { EmptyState } from "@/components/ui/empty-state";
interface FeedbackBucket {
severity: string;
@@ -110,9 +111,11 @@ export function AiQualitySection() {
onRetry={() => void query.refetch()}
/>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.aiQuality.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.aiQuality.empty")}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
@@ -28,6 +28,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { helpfulRateColour } from "./_shared";
import { EmptyState } from "@/components/ui/empty-state";
interface CoachFeedbackBucket {
promptVersion: string;
@@ -105,9 +106,11 @@ export function CoachFeedbackSection() {
onRetry={() => void query.refetch()}
/>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.coachFeedback.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.coachFeedback.empty")}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="coach-feedback-table">
+6 -3
View File
@@ -53,6 +53,7 @@ import {
import { KeyBackupCard } from "./key-backup-card";
import { useKeyBackupStatus } from "./use-key-backup-status";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
interface ColumnScan {
model: string;
@@ -446,9 +447,11 @@ export function ColumnCoverage({
{open ? (
<>
{shown.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.section.encryption.columnsNoneWithRows")}
/>
) : (
/* Focusable, named scroll region: on a phone the table scrolls
sideways, and a keyboard user has to be able to reach it
+6 -3
View File
@@ -48,6 +48,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiGet } from "@/lib/api/api-fetch";
import { readStoredTimezone } from "@/lib/timezone-mirror";
import { resolveIntlLocale } from "@/lib/format-locale";
import { EmptyState } from "@/components/ui/empty-state";
interface HostMetricsApiSample {
capturedAt: string;
@@ -227,9 +228,11 @@ export function HostMetricsChart() {
</span>
}
/>
<p className="text-muted-foreground text-xs">
{t("admin.hostMetrics.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.hostMetrics.empty")}
/>
</SettingsCard>
);
}
@@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.37.31 — the delivery-health readout the `provider_health` ledger never
@@ -143,9 +144,11 @@ export function ProviderHealthSection() {
onRetry={() => void refetch()}
/>
) : providers.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.providerHealth.empty")}
/>
) : (
<ul className="flex flex-col gap-3" data-testid="provider-health-list">
{providers.map((p) => (
+6 -3
View File
@@ -27,6 +27,7 @@ import {
describeChannelTestFailures,
type ChannelTestResult,
} from "./notification-test-failures";
import { EmptyState } from "@/components/ui/empty-state";
interface NotificationHealth {
windowHours: number;
@@ -73,9 +74,11 @@ function NotificationHealthPanel() {
)}
{data && data.channels.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("admin.notificationHealth.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.notificationHealth.empty")}
/>
)}
{data && data.channels.length > 0 && (
@@ -29,6 +29,7 @@ import {
useSystemStatus,
type FailingQueue,
} from "./_shared";
import { EmptyState } from "@/components/ui/empty-state";
// v1.4.16: Recharts is ~108 KiB Brotli — defer-load the host
// metrics chart so the system-status page only ships it when an admin
@@ -89,12 +90,14 @@ export function FailingJobsCard({
description={t("admin.failingJobs.description")}
/>
{failingJobs.queues.length === 0 ? (
<p
<EmptyState
variant="plain"
size="compact"
title={t("admin.failingJobs.none", {
hours: failingJobs.windowHours,
})}
data-testid="failing-jobs-none"
className="text-muted-foreground text-sm"
>
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
</p>
/>
) : (
<ul data-testid="failing-jobs-list" className="space-y-3">
{failingJobs.queues.map((queue) => (
@@ -573,9 +573,7 @@ export function MedicationComplianceChart({
}
/>
) : !hasData ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
</div>
<ChartEmptyState title={t("charts.noData")} height={192} />
) : chartData.length < 3 ? (
// v1.4.16 — sparse-data placeholder consistent with the
// BP/weight/pulse/mood charts.
+1 -3
View File
@@ -732,9 +732,7 @@ export function MoodChart({
height={CHART_HEIGHT_PX}
/>
) : !chartData?.length ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
</div>
<ChartEmptyState title={t("charts.noData")} height={192} />
) : chartData.length < 3 ? (
// v1.4.16 — sparse-data placeholder consistent with the
// BP/weight/pulse charts. Height tracks the chart strip's
@@ -22,6 +22,7 @@ import {
type LeafScope,
type Translate,
} from "./report-sections";
import { EmptyState } from "@/components/ui/empty-state";
/**
* Only the three stored gender values get a label. A row on a clinical
@@ -240,9 +241,11 @@ export function EmergencySection({
{t("clinicianView.sectionUnavailable")}
</p>
) : rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("clinicianView.sectionEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("clinicianView.sectionEmpty")}
/>
) : (
rows.map((row) => (
<StatRow
+6 -3
View File
@@ -34,6 +34,7 @@ import {
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
import { EmptyState } from "@/components/ui/empty-state";
export type Translate = (
key: string,
@@ -185,9 +186,11 @@ export function LeafSection({
{t("clinicianView.sectionUnavailable")}
</p>
) : empty ? (
<p className="text-muted-foreground text-sm">
{t("clinicianView.sectionEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("clinicianView.sectionEmpty")}
/>
) : (
children
)}
@@ -32,6 +32,7 @@ import { dateKeyOfInstant } from "../day/day-url";
import { formatMetricValue } from "./format-value";
import type { CustomMetricEntryDto } from "./types";
import { Segmented } from "@/components/ui/segmented";
import { ChartEmptyState } from "@/components/charts/chart-empty-state";
/**
* v1.25.5 — per-custom-metric trend chart, in the dashboard's visual language.
@@ -151,9 +152,7 @@ export function CustomMetricChart({
</div>
{points.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("customMetrics.chart.empty")}
</p>
<ChartEmptyState title={t("customMetrics.chart.empty")} height={160} />
) : (
<div>
<div
@@ -21,6 +21,7 @@ import { queryKeys } from "@/lib/query-keys";
import { CustomMetricForm } from "./custom-metric-form";
import { formatMetricValue } from "./format-value";
import type { CustomMetricDto, CustomMetricListResponse } from "./types";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.25.5 — inline list of the user's custom metrics, mounted ON the
@@ -99,12 +100,10 @@ export function CustomMetricList() {
onRetry={() => refetch()}
/>
) : metrics.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
<Gauge className="text-muted-foreground size-6" />
<p className="text-muted-foreground text-sm">
{t("customMetrics.emptyDescription")}
</p>
<EmptyState
icon={<Gauge className="size-6" />}
title={t("customMetrics.emptyDescription")}
action={
<Button
size="sm"
onClick={() => setAddOpen(true)}
@@ -112,8 +111,8 @@ export function CustomMetricList() {
>
{t("customMetrics.add")}
</Button>
</CardContent>
</Card>
}
/>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
@@ -17,6 +17,7 @@ import type {
CustomMetricEntryDto,
CustomMetricEntryListResponse,
} from "./types";
import { EmptyState } from "@/components/ui/empty-state";
const ENTRIES_PAGE_SIZE = 200;
@@ -90,9 +91,11 @@ export function CustomMetricValuesList({
if (entries.length === 0) {
return (
<p className="text-muted-foreground py-8 text-center text-sm">
{t("customMetrics.detail.emptyTitle")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("customMetrics.detail.emptyTitle")}
/>
);
}
+6 -3
View File
@@ -20,6 +20,7 @@ import {
HISTORY_REST_OPACITY,
HISTORY_PERIOD_OPACITY,
} from "./phase-tokens";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.15.1 — the cycle-length history chart.
@@ -136,9 +137,11 @@ export function CycleHistoryChart({
</CardHeader>
<CardContent className="space-y-4">
{!hasData ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.history.none")}
/>
) : (
<>
{/* Stat chips — avg / variability / regularity classification. */}
@@ -10,6 +10,7 @@ import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { PHASE_HUE } from "./phase-tokens";
import type { CyclePhase } from "./types";
import type { SymptomPhaseRow } from "./use-cycle";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.15.0 — symptom patterns by phase (the cycle-NATIVE insight).
@@ -64,9 +65,11 @@ export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) {
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("cycle.insights.symptomPatternsEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.insights.symptomPatternsEmpty")}
/>
) : (
<ul className="space-y-3" data-slot="cycle-symptom-pattern-list">
{rows.map((row) => {
+11 -6
View File
@@ -15,6 +15,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.15.0 — the predictions panel.
@@ -81,9 +82,11 @@ export function PredictionsPanel({
body={t("cycle.predictions.rawModeBody")}
/>
) : prediction == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.predictions.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.predictions.none")}
/>
) : prediction.stillLearning ? (
<>
<Learning
@@ -215,9 +218,11 @@ function HistoryCard({
</CardHeader>
<CardContent className="space-y-4">
{!stats || stats.avgLengthDays == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.history.none")}
/>
) : (
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Stat
+7 -3
View File
@@ -44,6 +44,7 @@ import { tileKeyOf } from "./day-values-model";
import { shiftDateKey } from "./day-url";
import { useDay, usePrefetchDay } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
import { EmptyState } from "@/components/ui/empty-state";
/**
* One day, as the layer shows it in every shell: a header with the date and
@@ -282,9 +283,12 @@ export function DayView({
) : data === undefined ? (
<DaySkeleton />
) : isEmpty ? (
<p className="text-muted-foreground text-sm" data-slot="day-empty">
{t("day.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("day.empty")}
data-slot="day-empty"
/>
) : (
<>
<DayRunning items={data.running} date={date} />
@@ -30,6 +30,7 @@ import {
} from "@/lib/validations/inbound-documents";
import type { DocumentDescribeResult } from "./use-document-assist";
import { EmptyState } from "@/components/ui/empty-state";
/**
* The line under a document AI action while its read runs in the background,
@@ -220,9 +221,11 @@ export function AssistSuggestionReview({
) : null}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("documents.assist.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("documents.assist.empty")}
/>
)}
</div>
);
@@ -31,6 +31,7 @@ import type {
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { EmptyState } from "@/components/ui/empty-state";
interface ListPage {
documents: InboundDocumentDto[];
@@ -140,9 +141,11 @@ export function DocumentLinkPicker({
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("documents.linkPicker.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("documents.linkPicker.empty")}
/>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
@@ -27,6 +27,7 @@ import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import type { AchievementProgress } from "@/lib/gamification/achievements";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { EmptyState } from "@/components/ui/empty-state";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -168,9 +169,11 @@ export function RecentAchievementsCard() {
))}
</ul>
) : recent.length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">
{t("achievements.dashboardCard.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("achievements.dashboardCard.empty")}
/>
) : (
<ul className="space-y-2">
{recent.map((achievement) => {
+18 -17
View File
@@ -26,6 +26,7 @@ import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLogList } from "./use-illness";
import type { IllnessDayLogDTO } from "./types";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
function symptomLabelKey(key: string): string | null {
return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null;
@@ -64,9 +65,11 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
})}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("illness.timeline.noSymptoms")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("illness.timeline.noSymptoms")}
/>
)}
{log.functionalImpact !== null ? (
@@ -134,20 +137,18 @@ export function IllnessDayTimeline({
onRetry={() => void refetch()}
/>
) : dayLogs.length === 0 ? (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("illness.timeline.empty")}
</p>
{canManageIllness && (
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={onLogDay}
>
{t("illness.logDay")}
</Button>
)}
</div>
<EmptyState
variant="plain"
size="compact"
title={t("illness.timeline.empty")}
action={
canManageIllness ? (
<Button variant="outline" onClick={onLogDay}>
{t("illness.logDay")}
</Button>
) : undefined
}
/>
) : (
<div className="space-y-4">
{dayLogs.map((log) => (
@@ -21,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useIllnessInsights } from "./use-illness";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
export function IllnessInsightsCard({
windowDays = 365,
@@ -64,9 +65,11 @@ export function IllnessInsightsCard({
onRetry={() => void counts.refetch()}
/>
) : !hasEpisodes ? (
<p className="text-muted-foreground text-sm">
{t("illness.insights.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("illness.insights.none")}
/>
) : (
<div className="space-y-4 text-sm">
<p className="text-foreground">
@@ -16,6 +16,7 @@ import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.29.x (S7) — the "Choose from documents" picker for attaching stored vault
@@ -190,9 +191,11 @@ export function AttachmentPicker({
onRetry={() => void list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("insights.coach.attach.pickerEmpty")}
/>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
@@ -30,6 +30,7 @@ import {
useDeleteCoachConversationWithUndo,
} from "./use-coach";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
/**
* The Coach's conversation list: the full history grouped by recency
@@ -169,12 +170,13 @@ export function HistoryRail({
className="px-2 py-3"
/>
) : groups.length === 0 ? (
<p
<EmptyState
variant="plain"
size="compact"
title={t("insights.coach.historyEmpty")}
className="px-2"
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-sm leading-relaxed"
>
{t("insights.coach.historyEmpty")}
</p>
/>
) : (
groups.map((group) => (
<section
@@ -32,6 +32,7 @@ import {
type IntradayResolution,
type TensionWindow,
} from "@/lib/analytics/intraday-pulse";
import { ChartEmptyState } from "@/components/charts/chart-empty-state";
/** Slim DTO the intraday route returns (type-only — no server code bundled). */
interface IntradayPulseDto {
@@ -264,9 +265,10 @@ export function IntradayPulseChart({
// A lone reading or two on a 24-hour axis drew a dot in an empty
// field and called it the course of the day. Below a handful of
// buckets the card says there is not enough for a course.
<div className="text-muted-foreground flex h-44 items-center justify-center rounded-lg border border-dashed text-sm">
{t("insights.intradayPulse.empty")}
</div>
<ChartEmptyState
title={t("insights.intradayPulse.empty")}
height={176}
/>
) : (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={180}>
@@ -25,6 +25,7 @@ import type {
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
import { InlineAction } from "@/components/ui/inline-action";
import { EmptyState } from "@/components/ui/empty-state";
function primaryWithDelta(
dto: MedicationEfficacyDTO,
@@ -79,9 +80,11 @@ export function MedicationEfficacySummary({
</CardHeader>
<CardContent className="space-y-3">
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.insights.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.efficacy.insights.empty")}
/>
) : (
<ul className="divide-border/60 divide-y">
{rows.map(({ med, target }) => {
@@ -31,6 +31,7 @@ import {
} from "@/lib/mood/dimensions";
import { cn } from "@/lib/utils";
import { Segmented } from "@/components/ui/segmented";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.37 — the five level-A dimensions over time.
@@ -129,9 +130,11 @@ export function MoodDimensionTrends({
if (present.length === 0) {
return (
<p className="text-muted-foreground text-sm">
{t("insights.mood.dimensions.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("insights.mood.dimensions.none")}
/>
);
}
@@ -21,6 +21,7 @@ import {
moodPairsOf,
type CorrelationDiscoveryResponse,
} from "./mood-discovered-relations";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.12.7 — the single "What stands out" card.
@@ -96,12 +97,12 @@ export function MoodWhatStandsOut({
/>
) : null}
{!isLoading && !isError && data && !hasDiscovered ? (
<p
<EmptyState
variant="plain"
size="compact"
title={t("insights.pattern.empty")}
data-slot="mood-discovery-empty"
className="text-muted-foreground text-sm"
>
{t("insights.pattern.empty")}
</p>
/>
) : null}
{hasDiscovered && (
<MoodDiscoveredRelations
+2 -3
View File
@@ -34,6 +34,7 @@ import { dateKeyOfInstant } from "../day/day-url";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
import { Segmented } from "@/components/ui/segmented";
import { ChartEmptyState } from "@/components/charts/chart-empty-state";
/**
* v1.18.1 — per-biomarker trend chart, in the dashboard's visual language.
@@ -265,9 +266,7 @@ export function LabBiomarkerChart({
</div>
{points.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("labs.chart.empty")}
</p>
<ChartEmptyState title={t("labs.chart.empty")} height={160} />
) : (
<div>
<div
+6 -3
View File
@@ -13,6 +13,7 @@ import { queryKeys } from "@/lib/query-keys";
import { LabHistoryList } from "./lab-history-list";
import type { LabResultDto, LabResultListResponse } from "./types";
import { EmptyState } from "@/components/ui/empty-state";
// Page size for the offset-paginated reading feed. The server caps a single
// read at 500 (`listLabResultsSchema`); this stays well under that and keeps
@@ -77,9 +78,11 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
if (readings.length === 0) {
return (
<p className="text-muted-foreground py-8 text-center text-sm">
{t("labs.detail.emptyTitle")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("labs.detail.emptyTitle")}
/>
);
}
@@ -45,6 +45,7 @@ import {
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { EmptyState } from "@/components/ui/empty-state";
const SUMMARY_LIMIT = 3;
@@ -168,16 +169,18 @@ export function VorsorgeDashboardCard() {
// checkup is the harm behind that fall-through (§6).
<QueryErrorRow inset error={error} onRetry={() => refetch()} />
) : upcoming.length === 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
{t("measurementReminders.dashboard.empty")}
</p>
<Button asChild variant="outline" size="sm">
<Link href="/checkups">
{t("measurementReminders.dashboard.openLink")}
</Link>
</Button>
</div>
<EmptyState
variant="plain"
size="compact"
title={t("measurementReminders.dashboard.empty")}
action={
<Button asChild variant="outline" size="sm">
<Link href="/checkups">
{t("measurementReminders.dashboard.openLink")}
</Link>
</Button>
}
/>
) : (
<ul className="space-y-2">
{upcoming.map((reminder) => {
@@ -30,6 +30,7 @@ import {
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { EmptyState } from "@/components/ui/empty-state";
function resolveLabel(
reminder: MeasurementReminder,
@@ -104,9 +105,11 @@ export function VorsorgeSettings() {
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.manage.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("measurementReminders.manage.empty")}
/>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
@@ -1936,9 +1936,11 @@ function DayDrillDown({
const rows = data?.measurements ?? [];
if (rows.length === 0) {
return (
<div className="text-muted-foreground py-2 text-xs">
{t("measurements.emptyDescription")}
</div>
<EmptyState
variant="plain"
size="compact"
title={t("measurements.emptyDescription")}
/>
);
}
// v1.11.5 — for SLEEP_DURATION the drill-down rows are stage segments;
@@ -42,6 +42,7 @@ import type {
MedicationEfficacyDTO,
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
import { EmptyState } from "@/components/ui/empty-state";
// v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own
// `<Card>` as a bare 220px `ResponsiveContainer` with no chrome of its own.
@@ -299,9 +300,11 @@ function TargetCard({
/>
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.noSeries")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.efficacy.noSeries")}
/>
)}
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
@@ -105,6 +105,7 @@ import { IntakeTrackingBody } from "@/components/medications/sections/intake-tra
import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section";
import type { RelatedLink } from "@/lib/links/related";
import { InlineAction } from "@/components/ui/inline-action";
import { EmptyState } from "@/components/ui/empty-state";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
// both chart bodies load through `next/dynamic` (client-only) with the
@@ -677,9 +678,11 @@ export function MedicationDetailTabs({
longDays={longDays}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.detail.uebersicht.complianceEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.detail.uebersicht.complianceEmpty")}
/>
)}
</MedicationDetailSection>
)}
@@ -115,6 +115,7 @@ import {
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -477,9 +478,11 @@ export function DoseHistoryLedger({
)}
{data && !isLoading && groups.length === 0 && (
<p className="text-muted-foreground py-6 text-sm">
{t("medications.detail.verlauf.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.detail.verlauf.empty")}
/>
)}
{data &&
@@ -12,6 +12,7 @@ import {
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.8.5 — post-dose injection-site capture.
@@ -111,9 +112,11 @@ export function LogInjectionSiteDialog({
}
>
{allowed.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("medications.logInjectionSiteNoneAvailable")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.logInjectionSiteNoneAvailable")}
/>
) : (
<>
<InjectionSitePicker
@@ -46,6 +46,7 @@ import {
} from "@/components/medications/use-medication-intake";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds";
import { EmptyState } from "@/components/ui/empty-state";
interface LogIntakeSchedule extends ScheduleWindowInput {
label: string | null;
@@ -276,9 +277,11 @@ export function LogIntakeDialog({
className="space-y-4"
>
{medications.length === 0 ? (
<p className="text-muted-foreground py-4 text-sm">
{t("medications.logIntake.noMedications")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.logIntake.noMedications")}
/>
) : (
<div className="space-y-3">
<div className="space-y-2">
@@ -26,6 +26,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { formatDose } from "@/lib/medications/format-dose";
import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-layout";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.16.10 — manual medication ordering, shared by BOTH /medications
@@ -153,9 +154,11 @@ export function MedicationOrderEditor({
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
<div className="relative space-y-4" data-slot="medication-order-editor">
{medications.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("medications.emptyTitle")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.emptyTitle")}
/>
)}
{activeIds.length > 0 && (
<ReorderSection
@@ -40,6 +40,7 @@ import {
type NutrientCatalogEntryDto,
type SupplementLabelProposal,
} from "./use-composition";
import { EmptyState } from "@/components/ui/empty-state";
/** The categories the composition is offered for without being asked. */
export const COMPOSITION_CATEGORIES: ReadonlySet<string> = new Set([
@@ -520,9 +521,11 @@ export function CompositionSection({
}
>
{ingredients.length === 0 ? (
<p className="text-muted-foreground">
{t("medications.composition.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.composition.empty")}
/>
) : (
<div className="space-y-3">
<ul className="divide-border/60 divide-y" role="list">
@@ -37,6 +37,7 @@ import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/date-format";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { EmptyState } from "@/components/ui/empty-state";
/** The refusal codes the course routes answer, mapped to their copy. */
const REFUSAL_KEYS: Record<string, string> = {
@@ -262,9 +263,11 @@ export function CoursesRow({
</div>
{courses.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("medications.course.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.course.none")}
/>
) : (
<ul className="space-y-2">
{courses.map((course, index) => (
@@ -50,6 +50,7 @@ import {
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.4.25 — GLP-1 side-effect section for the medication detail
@@ -339,9 +340,11 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
)}
{!isLoading && !isError && items.length === 0 && (
<p className="text-muted-foreground py-1">
{t("medications.sideEffects.emptyState")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("medications.sideEffects.emptyState")}
/>
)}
{!isLoading && !isError && items.length > 0 && (
@@ -31,6 +31,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { EmptyState } from "@/components/ui/empty-state";
interface DoseChange {
id: string;
@@ -141,12 +142,12 @@ export function TitrationTimeline({
dataSlot="titration-timeline"
>
{!hasPlan ? (
<p
className="text-muted-foreground bg-muted/40 rounded-md p-4 text-sm"
<EmptyState
variant="plain"
size="compact"
title={t("medications.titration.empty")}
data-slot="titration-timeline-empty"
>
{t("medications.titration.empty")}
</p>
/>
) : (
<ol className="relative space-y-0" data-slot="titration-timeline-list">
{steps.map((step, i) => (
@@ -49,6 +49,7 @@ import { dateKeyOfInstant } from "../day/day-url";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import type { AssessmentRow, InstrumentId } from "./types";
import { ChartEmptyState } from "@/components/charts/chart-empty-state";
interface ChartPoint {
timestamp: number;
@@ -106,9 +107,7 @@ export function AssessmentHistoryChart({
if (points.length === 0) {
return (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("mentalHealth.history.empty")}
</p>
<ChartEmptyState title={t("mentalHealth.history.empty")} height={160} />
);
}
@@ -23,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.18.6 — generic, controlled reorder list shared by the
@@ -97,9 +98,11 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) {
if (items.length === 0) {
return (
<p className="text-muted-foreground text-sm">
{t("moduleList.reorder.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("moduleList.reorder.empty")}
/>
);
}
@@ -31,6 +31,7 @@ import {
type ManageCatalog,
type ManageTag,
} from "./use-mood-tag-manage";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.17 — archived custom tags for `/settings/mood`. Archiving keeps
@@ -109,12 +110,12 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
</p>
{archived.length === 0 ? (
<p
className="text-muted-foreground text-sm"
<EmptyState
variant="plain"
size="compact"
title={t("mood.manage.archivedEmpty")}
data-slot="mood-archived-empty"
>
{t("mood.manage.archivedEmpty")}
</p>
/>
) : (
<div className="space-y-2">
{archived.map((tag) => {
@@ -81,6 +81,7 @@ import {
type ManageCatalog,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.17 — group management card for `/settings/mood`. Lists every
@@ -245,12 +246,12 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
</div>
{customGroupCount === 0 && (
<p
className="text-muted-foreground text-sm"
<EmptyState
variant="plain"
size="compact"
title={t("mood.manage.groupEmpty")}
data-slot="mood-groups-empty"
>
{t("mood.manage.groupEmpty")}
</p>
/>
)}
<DndContext
@@ -74,6 +74,7 @@ import {
type ManageTag,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.17 — per-group tag management for `/settings/mood`. Mirrors the
@@ -317,7 +318,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
</div>
{visible.categories.length === 0 && (
<p className="text-muted-foreground text-sm">{t("common.noData")}</p>
<EmptyState variant="plain" size="compact" title={t("common.noData")} />
)}
{visible.categories.map((category) => {
+16 -9
View File
@@ -9,6 +9,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { ProfileSummaryPayload } from "@/lib/records/profile-summary";
import { EmptyState } from "@/components/ui/empty-state";
function profileFactLabel(label: string, t: (key: string) => string): string {
const keys: Record<string, string> = {
@@ -61,9 +62,11 @@ export function ProfileSummary({
))}
</ul>
) : (
<p className="text-muted-foreground text-sm">
{t("records.allergies.emptyTitle")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("records.allergies.emptyTitle")}
/>
)}
</CardContent>
</Card>
@@ -88,9 +91,11 @@ export function ProfileSummary({
))}
</ul>
) : (
<p className="text-muted-foreground text-sm">
{t("records.family.emptyTitle")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("records.family.emptyTitle")}
/>
)}
</CardContent>
</Card>
@@ -112,9 +117,11 @@ export function ProfileSummary({
))}
</dl>
) : (
<p className="text-muted-foreground text-sm">
{t("records.profileFacts.notRecorded")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("records.profileFacts.notRecorded")}
/>
)}
</CardContent>
</Card>
+21 -12
View File
@@ -49,6 +49,7 @@ import {
userDayKey,
validTimezoneOr,
} from "@/lib/tz/format";
import { EmptyState } from "@/components/ui/empty-state";
interface GeocodeResult {
lat: number;
@@ -343,9 +344,11 @@ export function EnvironmentSection() {
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.home.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.environment.home.none")}
/>
)}
<form
className="flex gap-2"
@@ -378,9 +381,11 @@ export function EnvironmentSection() {
{homeSearched &&
!searchHome.isPending &&
homeResults.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.noResults")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.environment.noResults")}
/>
)}
{homeResults.length > 0 && (
<ul className="divide-border divide-y rounded-md border">
@@ -467,9 +472,11 @@ export function EnvironmentSection() {
))}
</ul>
) : (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.travel.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.environment.travel.none")}
/>
)}
<form
@@ -505,9 +512,11 @@ export function EnvironmentSection() {
{travelSearched &&
!searchTravel.isPending &&
travelResults.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.noResults")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.environment.noResults")}
/>
)}
{travelResults.length > 0 && (
<ul className="divide-border divide-y rounded-md border">
@@ -29,6 +29,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { EmptyState } from "@/components/ui/empty-state";
/** A picked document, carried by id + a display title (for the owner chips). */
export interface PickedDocument {
@@ -159,9 +160,11 @@ export function ShareDocumentPicker({
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("settings.sharing.pickerEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.sharing.pickerEmpty")}
/>
) : (
<ul
className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain"
+6 -3
View File
@@ -68,6 +68,7 @@ import {
useSymptomEvents,
useUpdateSymptomDefinition,
} from "./use-symptoms";
import { EmptyState } from "@/components/ui/empty-state";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -489,9 +490,11 @@ export function SymptomsSection() {
{t("symptoms.section.recent")}
</p>
{days.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("symptoms.section.recentEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("symptoms.section.recentEmpty")}
/>
) : (
<div className="space-y-3">
{days.map((group) => (