feat(ux): silhouettes from the first paint and one filter language

The route-level loading state now renders the same structured tile and
chart silhouettes the page uses, so the first paint no longer flashes a
grid of bare cards before the skeletons arrive. The chart reveal cap
drops to 1.2 seconds, the QR library loads only when an invite is
minted, and list filtering moves onto a shared pill-rail filter bar —
active filters read as removable chips, date ranges live in a popover,
and the measurements and mood lists speak it first.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 20:48:46 +02:00
1 parent 258e2fd365
commit 1e34671d92
18 files changed
+953 -269

No files matched your search

+22 -2
View File
@@ -198,7 +198,27 @@ admin views. Default to `<Card>` + list rows for everything else;
tables on mobile collapse poorly. When you must use a table on mobile,
swap to a vertical list at `sm:` breakpoint.
### 2.6 Status indicators
### 2.6 Filter bars over data lists
Every list surface with filters (measurements, mood, future admin
lists) uses the unified `<FilterBar>` rail from
`src/components/ui/filter-bar.tsx` — never a per-page row of large
labelled `<Select>`s and date inputs.
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
grow a removable ✕. No separate chip row.
- **Reset** renders only while at least one dimension is active.
- **Mobile** (< `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge-bleed); the result count wraps to its own line.
- The component is presentation-only — filter state, query keys and
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
### 2.7 Status indicators
Use the same color taxonomy across the app:
@@ -277,7 +297,7 @@ Same shape as Settings, with the right-hand sidebar listing:
- Maintenance
- Audit log
Status badges use the §2.6 color taxonomy. Each section opens to a card
Status badges use the §2.7 color taxonomy. Each section opens to a card
grid, never a long fluid list.
---
@@ -85,8 +85,13 @@ describe("resolveChartRevealState", () => {
).toBe(true);
});
it("pins the 2 s fallback budget", () => {
expect(CHART_REVEAL_TIMEOUT_MS).toBe(2_000);
it("pins the 1.2 s fallback budget", () => {
// v1.16.1 — lowered from 2 s: the timeout is only the worst-case cap
// on how long fast charts wait for the slowest sibling (there is no
// minimum delay — the row reveals the moment every gated query
// settles). 1.2 s keeps the synchronized reveal without making one
// cold widget hold the whole row on skeletons for 2 s.
expect(CHART_REVEAL_TIMEOUT_MS).toBe(1_200);
});
});
@@ -0,0 +1,107 @@
import { describe, it, expect } 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 RootLoading from "../loading";
import {
DASHBOARD_WIDGET_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
/**
* v1.16.1 — root loading fallback renders the structured dashboard
* silhouette, not bare cards.
*
* The first frame a user sees on a cold dashboard load is this fallback
* (it streams before the page mounts and before any query resolves).
* Pre-fix it painted six EMPTY `bg-card` rectangles in a generic
* 3-column grid; the page then swapped to its structured
* `<TrendCardSkeleton>` strip + `<ChartSkeleton>` band — a visible
* "empty dark cards → skeletons" two-step with a layout jump in
* between. These tests pin the fallback to the SAME silhouette
* components and the SAME grid track the page's own loading phase
* uses, so every phase of the first paint shows one consistent
* structure.
*/
const GRID_TRACK =
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]";
// Same expression the fallback (and, per-user, the page) uses to size
// the silhouette strip: web-known widget ids, tileVisible falling back
// to the legacy visible flag.
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const expectedTileCount = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
).length;
function renderMarkup(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RootLoading />
</I18nProvider>,
);
}
describe("RootLoading — structured dashboard silhouette (SSR markup)", () => {
it("renders one structured trend-card silhouette per default-visible web tile", () => {
const markup = renderMarkup();
const tiles = markup.match(/data-slot="trend-card-skeleton"/g) ?? [];
expect(expectedTileCount).toBeGreaterThan(0);
expect(tiles).toHaveLength(expectedTileCount);
});
it("renders the chart-band silhouette", () => {
const markup = renderMarkup();
expect(markup).toContain('data-slot="chart-skeleton"');
});
it("uses the same auto-fit grid track as the in-page tile strip", () => {
const markup = renderMarkup();
expect(markup).toContain(
"grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))",
);
});
it("marks the silhouette strip with the shared tile-strip-skeleton slot", () => {
const markup = renderMarkup();
expect(markup).toContain('data-slot="dashboard-tile-strip-skeleton"');
});
it("contains no bare empty-card silhouette (every card carries inner skeleton rows)", () => {
const markup = renderMarkup();
// The legacy fallback's signature was a card with a single empty
// h-24 block and no structured rows. The structured silhouettes
// always carry the headline-value row (`h-[30px]`) inside each
// card; assert it is present and the legacy block is gone.
expect(markup).not.toContain("h-24");
expect(markup).toContain("h-[30px]");
});
});
describe("RootLoading — single skeleton source (wiring pins)", () => {
const loadingSrc = readFileSync(
join(process.cwd(), "src/app/loading.tsx"),
"utf8",
);
const pageSrc = readFileSync(join(process.cwd(), "src/app/page.tsx"), "utf8");
it("imports the same silhouette components the page's loading phase uses", () => {
expect(loadingSrc).toContain(
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
);
expect(loadingSrc).toContain(
'import { ChartSkeleton } from "@/components/charts/chart-skeleton"',
);
expect(pageSrc).toContain(
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
);
});
it("shares the grid-track literal with the page so the footprint matches at swap time", () => {
expect(loadingSrc).toContain(GRID_TRACK);
expect(pageSrc).toContain(GRID_TRACK);
});
});
+17 -5
View File
@@ -127,17 +127,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
// so a taken username or weak password can never burn one of the
// invite's uses. The guarded increment inside `consumeInviteToken`
// makes the last-use race safe under concurrent signups.
//
// v1.16.1 — consume whenever a token was sent, not only when the
// invite is the admission requirement. A signup through an invite
// link while registration happens to be open used to leave the
// invite untouched, so the issuer's ledger (uses, consumers) was
// silently incomplete. The consume stays a hard 403 only when
// registration is closed (the invite is the door key); under open
// registration a stale or exhausted token is annotated and the
// signup proceeds uninvited.
let inviteId: string | null = null;
if (!registrationEnabled && inviteToken) {
if (inviteToken) {
const consumed = await consumeInviteToken(inviteToken);
if (!consumed.ok) {
if (consumed.ok) {
inviteId = consumed.inviteId;
} else {
annotate({
action: { name: "auth.register.invite_rejected" },
meta: { reason: consumed.reason },
meta: { reason: consumed.reason, registration_open: registrationEnabled },
});
return apiError("Invalid or expired invite", 403);
if (!registrationEnabled) {
return apiError("Invalid or expired invite", 403);
}
}
inviteId = consumed.inviteId;
}
// First user becomes admin
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
},
}));
@@ -81,6 +82,9 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
},
}));
@@ -67,6 +68,9 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
});
describe("GET /api/gamification/achievements?format=ios", () => {
@@ -17,6 +17,10 @@ import {
buildExpansionMetricValues,
getEarnabilityFlags,
} from "@/lib/gamification/expansion-metrics";
import {
getMissFreeDayKeys,
getWeeklyConsistency,
} from "@/lib/gamification/care-metrics";
import {
classifyBMI,
classifyBP,
@@ -43,11 +47,19 @@ export const dynamic = "force-dynamic";
const DAY_MS = 24 * 60 * 60 * 1000;
const ROLLING_WINDOW_DAYS = 30;
// v1.16.1 — weekly measurement-consistency badge: 4 consecutive weeks
// with at least 5 distinct active vitals days each. Mirrors the
// `measurement-weeks-4` definition's target.
const MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK = 5;
const MEASUREMENT_CONSISTENCY_TARGET_WEEKS = 4;
type IntakeEventRecord = {
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
/** v1.16.1 — feeds the miss-free day streak. */
autoMissed: boolean;
};
type MedicationScheduleRecord = {
@@ -527,6 +539,8 @@ async function buildAchievementsResult(user: AuthedUser) {
passkeys,
auditEvents,
moodEntries,
sleepMeasurements,
healthProfile,
] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -560,6 +574,9 @@ async function buildAchievementsResult(user: AuthedUser) {
scheduledFor: true,
takenAt: true,
skipped: true,
// v1.16.1 — the miss-free streak disqualifies a day on any
// auto-missed slot.
autoMissed: true,
},
}),
prisma.medication.findMany({
@@ -623,6 +640,32 @@ async function buildAchievementsResult(user: AuthedUser) {
moodLoggedAt: true,
},
}),
// v1.16.1 — sleep samples feed the sleep-logging streak. Read
// separately from the vitals query above so passive sleep syncs
// never inflate the entry-day / consistent-month engagement
// metrics, which reward ACTIVE tracking.
prisma.measurement.findMany({
where: {
userId,
measuredAt: { gte: startDate, lte: now },
source: { not: "IMPORT" },
type: "SLEEP_DURATION",
deletedAt: null,
},
select: { measuredAt: true },
}),
// v1.16.1 — presence-only read of the self-context questionnaire
// (nothing is decrypted) for the maintained-self-report badge.
prisma.userHealthProfile.findUnique({
where: { userId },
select: {
aboutMeEncrypted: true,
conditionsEncrypted: true,
allergiesEncrypted: true,
coachFocusEncrypted: true,
updatedAt: true,
},
}),
]);
const schedulesByMedicationId = new Map(
@@ -665,6 +708,28 @@ async function buildAchievementsResult(user: AuthedUser) {
now,
);
// v1.16.1 — care-routine series. Miss-free days and sleep-logging
// days reuse the standard day-series plumbing; the weekly measurement
// consistency folds distinct active vitals days into Monday-anchored
// weeks (≥5 active days each, 4 consecutive weeks for the badge).
const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents));
const sleepSeries = getEventDaySeries(
sleepMeasurements.map((m) => m.measuredAt),
);
const weeklyConsistency = getWeeklyConsistency(
measurements.map((m) => toBerlinDayKey(m.measuredAt)),
MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK,
MEASUREMENT_CONSISTENCY_TARGET_WEEKS,
);
const selfContextComplete =
healthProfile !== null &&
healthProfile.aboutMeEncrypted !== null &&
healthProfile.conditionsEncrypted !== null &&
healthProfile.allergiesEncrypted !== null &&
healthProfile.coachFocusEncrypted !== null
? 1
: 0;
const expansionValues = buildExpansionMetricValues({
measurements,
moodEntries,
@@ -679,6 +744,7 @@ async function buildAchievementsResult(user: AuthedUser) {
bpCount: expansionValues.bpMeasurementCount,
pulseCount: expansionValues.pulseMeasurementCount,
},
sleepSampleCount: sleepMeasurements.length,
});
const metrics = {
@@ -696,6 +762,11 @@ async function buildAchievementsResult(user: AuthedUser) {
loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys),
bugReportCount: bugReportDates.length,
...expansionValues,
// v1.16.1 — care-routine metrics.
missFreeDayStreak: calculateLongestStreak(missFreeSeries.dayKeys),
measurementConsistencyWeeks: weeklyConsistency.longestRunWeeks,
selfContextCompleteCount: selfContextComplete,
sleepLogDayStreak: calculateLongestStreak(sleepSeries.dayKeys),
};
const completionDates: Partial<Record<string, Date>> = {};
@@ -821,6 +892,43 @@ async function buildAchievementsResult(user: AuthedUser) {
complianceSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
// ── v1.16.1 ─ care-routine completion dates ─────────────────────
if (definition.metric === "missFreeDayStreak") {
const completedAt = findStreakCompletionDate(
missFreeSeries.dayKeys,
definition.target,
missFreeSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "sleepLogDayStreak") {
const completedAt = findStreakCompletionDate(
sleepSeries.dayKeys,
definition.target,
sleepSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "measurementConsistencyWeeks") {
if (weeklyConsistency.completionDayKey) {
completionDates[definition.id] = dayKeyToDate(
weeklyConsistency.completionDayKey,
);
}
continue;
}
if (definition.metric === "selfContextCompleteCount") {
if (selfContextComplete === 1 && healthProfile) {
completionDates[definition.id] = healthProfile.updatedAt;
}
}
}
+52 -11
View File
@@ -3,23 +3,64 @@
* route without its own `loading.tsx`).
*
* Defense-in-depth for the navigation-freeze class: a segment that streams
* paints this tile-grid skeleton instead of a blank frame. Pure server
* component — no client hooks, no data fetch. More specific segments (e.g.
* `/insights`) provide their own skeleton and take precedence.
* paints this skeleton instead of a blank frame. More specific segments
* (e.g. `/insights`) provide their own skeleton and take precedence.
*
* v1.16.1 — the silhouette now mirrors the dashboard's own in-page loading
* phase instead of a generic card grid. Pre-fix this fallback painted six
* EMPTY `bg-card` rectangles in a 3-column grid; the dashboard then swapped
* to its structured `<TrendCardSkeleton>` strip + `<ChartSkeleton>` band, so
* the very first frame a user saw was a flash of bare dark cards followed by
* a layout jump. The fix reuses the exact same silhouette components and the
* same auto-fit grid track (`repeat(auto-fit, minmax(min(100%, 11rem), 1fr))`)
* as `src/app/page.tsx` — ONE skeleton source for every load phase, zero
* intermediate "empty card" state, zero footprint change at swap time.
*/
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { Skeleton } from "@/components/ui/skeleton";
import {
DASHBOARD_WIDGET_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
/**
* Default-layout visible web tiles. The page's own skeleton phase keys its
* silhouette count off the user's resolved layout (`configuredTileCount` in
* `src/app/page.tsx`); this fallback paints before any user data exists, so
* the default layout is the best available guess — and the two counts agree
* for any account that has not trimmed the strip in Settings → Dashboard.
* Same filter expression as the page: web-known ids only, `tileVisible`
* falling back to the legacy `visible` flag.
*/
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const DEFAULT_TILE_COUNT = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
).length;
export default function RootLoading() {
return (
<div data-slot="dashboard-loading" className="space-y-6 p-4">
<Skeleton className="bg-muted h-7 w-44 rounded" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-card rounded-xl border p-4">
<Skeleton className="bg-muted mb-3 h-4 w-24 rounded" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
<div data-slot="dashboard-loading" className="space-y-6">
{/* Header row — title + welcome line, mirroring the dashboard hero
(`text-2xl` heading + `text-sm` subtitle). */}
<div>
<Skeleton className="h-8 w-44 rounded" />
<Skeleton className="mt-2 h-4 w-64 rounded" />
</div>
{/* Tile strip — same auto-fit grid track + structured silhouettes as
the in-page `data-slot="dashboard-tile-strip-skeleton"` phase. */}
<div
aria-hidden="true"
data-slot="dashboard-tile-strip-skeleton"
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))] gap-3"
>
{Array.from({ length: DEFAULT_TILE_COUNT }).map((_, i) => (
<TrendCardSkeleton key={`tile-skeleton-${i}`} />
))}
</div>
{/* Chart band — the first chart cell's silhouette (header row + range
tabs + chart area), identical to the reveal-gated cells' overlay. */}
<ChartSkeleton />
</div>
);
}
+24 -11
View File
@@ -21,7 +21,10 @@
*
* The QR data-URL is rendered inside the mutation (not in an effect):
* `qrcode`'s `toDataURL` is async, so folding it into `mutationFn`
* keeps the component free of setState-in-effect churn.
* keeps the component free of setState-in-effect churn. The library
* itself loads via dynamic `import()` at that call site (v1.16.1) —
* it is only needed the moment an admin mints an invite, so it stays
* out of the admin route bundle entirely.
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -35,7 +38,6 @@ import {
Ticket,
Users,
} from "lucide-react";
import QRCode from "qrcode";
import { toast } from "sonner";
import {
@@ -108,10 +110,7 @@ interface MintedInvite {
type InviteStatus = "active" | "expired" | "exhausted" | "revoked";
export function deriveInviteStatus(
invite: Pick<
AdminInvite,
"uses" | "maxUses" | "expiresAt" | "revokedAt"
>,
invite: Pick<AdminInvite, "uses" | "maxUses" | "expiresAt" | "revokedAt">,
now: Date,
): InviteStatus {
if (invite.revokedAt !== null) return "revoked";
@@ -150,7 +149,13 @@ const STATUS_STYLES: Record<InviteStatus, { dot: string; chip: string }> = {
},
};
function StatusChip({ status, label }: { status: InviteStatus; label: string }) {
function StatusChip({
status,
label,
}: {
status: InviteStatus;
label: string;
}) {
const styles = STATUS_STYLES[status];
return (
<span
@@ -201,6 +206,8 @@ export function InviteTokensSection() {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()).data as Omit<MintedInvite, "qrDataUrl">;
// Lazy chunk: qrcode is only ever needed right here, at mint time.
const QRCode = (await import("qrcode")).default;
const qrDataUrl = await QRCode.toDataURL(data.url, {
width: 240,
margin: 1,
@@ -289,8 +296,7 @@ export function InviteTokensSection() {
function redemptionSummary(invite: AdminInvite): string {
if (invite.redemptions.length === 0) return "—";
const first = invite.redemptions[0];
const name =
first.user?.username ?? t("admin.invites.redeemerDeleted");
const name = first.user?.username ?? t("admin.invites.redeemerDeleted");
if (invite.redemptions.length === 1) return name;
return t("admin.invites.redeemedByMore", {
username: name,
@@ -345,7 +351,11 @@ export function InviteTokensSection() {
title={t("admin.invites.emptyTitle")}
description={t("admin.invites.emptyDescription")}
action={
<Button type="button" variant="outline" onClick={openCreateDialog}>
<Button
type="button"
variant="outline"
onClick={openCreateDialog}
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
@@ -544,7 +554,10 @@ export function InviteTokensSection() {
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
</legend>
<div className="mt-2 grid grid-cols-3 gap-2" role="radiogroup">
<div
className="mt-2 grid grid-cols-3 gap-2"
role="radiogroup"
>
{TTL_CHOICES.map((days) => (
<button
key={days}
+13 -4
View File
@@ -22,15 +22,24 @@ import { cn } from "@/lib/utils";
*
* 1. every gated chart has reported its data query settled
* (`onDataReady` — the Promise.all moment), or
* 2. the reveal timeout elapses (`CHART_REVEAL_TIMEOUT_MS`, 2 s) so a
* 2. the reveal timeout elapses (`CHART_REVEAL_TIMEOUT_MS`) so a
* single slow widget cannot hold the whole row hostage — the late
* chart simply finishes on its own skeleton-free cell afterwards.
*
* Both signals are monotonic: the ready-set only grows and the timeout
* only fires once, so after the first 2 s the reveal can never regress
* even when a later mutation makes an additional chart visible.
* only fires once, so after the timeout window the reveal can never
* regress even when a later mutation makes an additional chart visible.
*
* There is deliberately NO minimum delay: when every gated query is fast
* the row reveals the moment the last one settles. The timeout is only
* the worst-case cap on how long fast charts wait for the slowest
* sibling — v1.16.1 lowered it 2 s → 1.2 s after the maintainer flagged
* the dashboard as feeling slower: with warm caches most charts settle
* in well under a second, and capping the hostage window at 1.2 s keeps
* the synchronized reveal while shaving up to 800 ms of artificial wait
* when one widget lags.
*/
export const CHART_REVEAL_TIMEOUT_MS = 2_000;
export const CHART_REVEAL_TIMEOUT_MS = 1_200;
/**
* Pure resolver behind `useDashboardChartReveal` — exported so the gate
@@ -68,7 +68,7 @@ const ADVISOR_TIMEOUT_MS = 8_000;
* payload, the mutation only invalidated (re-reading the OLD cache), yet
* the server had often already written the fresh briefing. Give the force
* branch the same 45 s budget the out-of-band warm job uses
* (`FORCE_WARM_COMPREHENSIVE_TIMEOUT_MS`) so a slow success is kept, not
* (`COMPREHENSIVE_WARM_TIMEOUT_MS`) so a slow success is kept, not
* dropped. The READ path stays fast at 8 s — only the explicit user tap
* may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b.
*/
@@ -72,15 +72,18 @@ function render(locale: "en" | "de" = "en") {
}
describe("MeasurementList — filter bar + multi-select chrome", () => {
it("renders the source filter control", () => {
// v1.16.1 — the per-page Select/date-input row migrated to the unified
// `<FilterBar>` pill rail (date range · type · source pills + count).
// The date inputs now live inside the pill's popover (closed in SSR),
// so the guard pins the labelled pill triggers instead.
it("renders the filter rail with type, source and date-range pills", () => {
const html = render("en");
expect(html).toContain("Filter by source");
});
it("renders the date-range labels wired to from/to", () => {
const html = render("en");
expect(html).toContain('for="measurements-from"');
expect(html).toContain('for="measurements-to"');
expect(html).toContain('data-slot="filter-bar"');
expect(html).toContain('aria-label="Type"');
expect(html).toContain('aria-label="Source"');
expect(html).toContain('aria-label="Date range"');
const pills = html.match(/data-slot="filter-bar-pill"/g);
expect(pills?.length).toBe(3);
});
it("renders labelled selection checkboxes (per-row + select-all)", () => {
@@ -95,7 +98,8 @@ describe("MeasurementList — filter bar + multi-select chrome", () => {
it("localises the new chrome in German", () => {
const html = render("de");
expect(html).toContain("Nach Quelle filtern");
expect(html).toContain('aria-label="Quelle"');
expect(html).toContain('aria-label="Zeitraum"');
expect(html).toContain('aria-label="Alle auf dieser Seite auswählen"');
});
});
@@ -61,12 +61,15 @@ function render(lockedType?: string) {
}
describe("MeasurementList — lockedType", () => {
it("hides the type selector when locked to a metric", () => {
it("hides the filter rail when locked to a metric", () => {
const locked = render("WEIGHT");
const unlocked = render();
// The unlocked list paints the filter trigger; the locked one does
// not (the metric is fixed by the route).
expect(unlocked).toContain("Filter by type");
// v1.16.1 — the unlocked list paints the unified `<FilterBar>` pill
// rail (type pill included); the locked one suppresses the whole
// rail (the metric is fixed by the route) and keeps only the count.
expect(unlocked).toContain('data-slot="filter-bar"');
expect(unlocked).toContain('aria-label="Type"');
expect(locked).not.toContain('data-slot="filter-bar"');
expect(locked).not.toContain('role="combobox"');
});
+74 -118
View File
@@ -6,12 +6,10 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
FilterBar,
FilterBarDateRange,
FilterBarSelect,
} from "@/components/ui/filter-bar";
import {
AlertDialog,
AlertDialogAction,
@@ -65,7 +63,7 @@ import {
MEASUREMENT_NOTES_MAX_LENGTH,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
import { DateTimeInput } from "@/components/ui/date-input";
import { Checkbox } from "@/components/ui/checkbox";
import {
SortableHead,
@@ -643,117 +641,75 @@ export function MeasurementList({
return (
<>
<div className="space-y-4">
{/* v1.4.27 MB7 / CF-46 — the filter row stacks the SelectTrigger
and the count caption vertically on `<sm` so the trigger
fills the column and the count drops to a separate line.
Pre-fix the trigger had a fixed `w-48` (192 px) which on
Pixel 5 (375 px content width) left only ~120 px for the
count, which wrapped to 2 lines. */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
{lockedType ? (
// v1.8.5 — the insights "all readings" subpage knows the
// metric from the route, so the global type selector is
// suppressed; the count caption still anchors the column.
<span className="sr-only">{t("measurements.filterByType")}</span>
) : (
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("measurements.filterByType")}
</Label>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-44"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("measurements.allTypes")}
</SelectItem>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* v1.15.13 — source filter + date range. Suppressed on the
locked-type insights subpage, which wants a focused list
scoped to the route's single metric (matches the type-
selector suppression above). */}
{!lockedType && (
<>
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("measurements.filterBySource")}
</Label>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger
className="w-full sm:w-40"
aria-label={t("measurements.filterBySource")}
>
<SelectValue placeholder={t("dataList.allSources")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("dataList.allSources")}
</SelectItem>
{MEASUREMENT_SOURCE_OPTIONS.map((src) => (
<SelectItem key={src} value={src}>
{formatMeasurementSource(src, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="measurements-from"
className="text-muted-foreground text-xs"
>
{t("dataList.dateFrom")}
</Label>
<DateInput
id="measurements-from"
className="w-full sm:w-40"
value={fromDay}
max={toDay || undefined}
onChange={(e) => setFromDay(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="measurements-to"
className="text-muted-foreground text-xs"
>
{t("dataList.dateTo")}
</Label>
<DateInput
id="measurements-to"
className="w-full sm:w-40"
value={toDay}
min={fromDay || undefined}
onChange={(e) => setToDay(e.target.value)}
/>
</div>
</>
)}
</div>
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("measurements.measurementCount", {
count: fmt.integer(data.meta.total),
})}
</span>
)}
</div>
{/* v1.16.1 — unified filter rail (`<FilterBar>`): compact pill
triggers in the canonical order date range · type · source,
active filters fold into their pill as removable chips, plus
a reset action and the result count. Filter state and query
keys are unchanged — this swap is presentation-only.
On the locked-type insights subpage the rail is suppressed
entirely (the metric is fixed by the route); only the count
line remains. */}
{lockedType ? (
data?.meta?.total !== undefined && (
<div className="flex items-center justify-end">
<span className="text-muted-foreground text-sm tabular-nums">
{t("measurements.measurementCount", {
count: fmt.integer(data.meta.total),
})}
</span>
</div>
)
) : (
<FilterBar
isFiltered={
typeFilter !== "ALL" ||
sourceFilter !== "ALL" ||
fromDay !== "" ||
toDay !== ""
}
onReset={() => {
setTypeFilter("ALL");
setSourceFilter("ALL");
setFromDay("");
setToDay("");
}}
count={
data?.meta?.total !== undefined
? t("measurements.measurementCount", {
count: fmt.integer(data.meta.total),
})
: undefined
}
>
<FilterBarDateRange
label={t("dataList.dateRange")}
from={fromDay}
to={toDay}
onFromChange={setFromDay}
onToChange={setToDay}
idPrefix="measurements"
/>
<FilterBarSelect
label={t("dataList.typeLabel")}
value={typeFilter}
onValueChange={setTypeFilter}
allLabel={t("measurements.allTypes")}
options={Object.entries(TYPE_LABEL_KEYS).map(
([val, labelKey]) => ({ value: val, label: t(labelKey) }),
)}
/>
<FilterBarSelect
label={t("dataList.sourceLabel")}
value={sourceFilter}
onValueChange={setSourceFilter}
allLabel={t("dataList.allSources")}
options={MEASUREMENT_SOURCE_OPTIONS.map((src) => ({
value: src,
label: formatMeasurementSource(src, t),
}))}
/>
</FilterBar>
)}
{isLoading ? (
<div className="flex h-32 items-center justify-center">
@@ -72,15 +72,16 @@ function render(locale: "en" | "de" = "en") {
}
describe("MoodList — filter bar + multi-select chrome", () => {
it("renders the source filter control", () => {
// v1.16.1 — the per-page Select/date-input row migrated to the unified
// `<FilterBar>` pill rail; same grammar as the measurements list.
it("renders the filter rail with mood, source and date-range pills", () => {
const html = render("en");
expect(html).toContain("Filter by source");
});
it("renders the date-range labels wired to from/to", () => {
const html = render("en");
expect(html).toContain('for="mood-from"');
expect(html).toContain('for="mood-to"');
expect(html).toContain('data-slot="filter-bar"');
expect(html).toContain('aria-label="Mood"');
expect(html).toContain('aria-label="Source"');
expect(html).toContain('aria-label="Date range"');
const pills = html.match(/data-slot="filter-bar-pill"/g);
expect(pills?.length).toBe(3);
});
it("renders labelled selection checkboxes (per-row + select-all)", () => {
+59 -94
View File
@@ -6,12 +6,10 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
FilterBar,
FilterBarDateRange,
FilterBarSelect,
} from "@/components/ui/filter-bar";
import {
AlertDialog,
AlertDialogAction,
@@ -37,7 +35,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import {
Table,
@@ -415,93 +413,60 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
return (
<>
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("mood.moodLevel")}
</Label>
<Select value={moodFilter} onValueChange={setMoodFilter}>
<SelectTrigger className="w-full sm:w-44">
<SelectValue placeholder={t("mood.allMoods")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("mood.allMoods")}</SelectItem>
{MOOD_LEVELS_LIST.map((val) => (
<SelectItem key={val} value={val}>
{MOOD_SCORES[val]} ({t(MOOD_LABEL_KEYS[val])})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.15.13 — source filter. */}
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("mood.filterBySource")}
</Label>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger
className="w-full sm:w-40"
aria-label={t("mood.filterBySource")}
>
<SelectValue placeholder={t("dataList.allSources")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("dataList.allSources")}
</SelectItem>
{MOOD_SOURCE_OPTIONS.map((src) => (
<SelectItem key={src} value={src}>
{formatMoodSource(src, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.15.13 — optional date range, wired to from/to. */}
<div className="flex flex-col gap-1">
<Label
htmlFor="mood-from"
className="text-muted-foreground text-xs"
>
{t("dataList.dateFrom")}
</Label>
<DateInput
id="mood-from"
className="w-full sm:w-40"
value={fromDay}
max={toDay || undefined}
onChange={(e) => setFromDay(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="mood-to"
className="text-muted-foreground text-xs"
>
{t("dataList.dateTo")}
</Label>
<DateInput
id="mood-to"
className="w-full sm:w-40"
value={toDay}
min={fromDay || undefined}
onChange={(e) => setToDay(e.target.value)}
/>
</div>
</div>
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("mood.entryCount", {
count: fmt.integer(data.meta.total),
})}
</span>
)}
</div>
{/* v1.16.1 — unified filter rail (`<FilterBar>`), same grammar as
the measurements list: date range · mood · source as compact
pills, active filters as removable chips, reset + count.
Filter state and query keys unchanged. */}
<FilterBar
isFiltered={
moodFilter !== "ALL" ||
sourceFilter !== "ALL" ||
fromDay !== "" ||
toDay !== ""
}
onReset={() => {
setMoodFilter("ALL");
setSourceFilter("ALL");
setFromDay("");
setToDay("");
}}
count={
data?.meta?.total !== undefined
? t("mood.entryCount", {
count: fmt.integer(data.meta.total),
})
: undefined
}
>
<FilterBarDateRange
label={t("dataList.dateRange")}
from={fromDay}
to={toDay}
onFromChange={setFromDay}
onToChange={setToDay}
idPrefix="mood"
/>
<FilterBarSelect
label={t("mood.moodLevel")}
value={moodFilter}
onValueChange={setMoodFilter}
allLabel={t("mood.allMoods")}
options={MOOD_LEVELS_LIST.map((val) => ({
value: val,
label: `${MOOD_SCORES[val]} (${t(MOOD_LABEL_KEYS[val])})`,
}))}
/>
<FilterBarSelect
label={t("dataList.sourceLabel")}
value={sourceFilter}
onValueChange={setSourceFilter}
allLabel={t("dataList.allSources")}
options={MOOD_SOURCE_OPTIONS.map((src) => ({
value: src,
label: formatMoodSource(src, t),
}))}
/>
</FilterBar>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { FilterBar, FilterBarDateRange, FilterBarSelect } from "../filter-bar";
/**
* v1.16.1 — unified filter rail. SSR-markup guards for the shared
* grammar: pill = trigger + active chip in one element, reset only
* while filtered, count slot, per-pill clear button with a translated
* aria-label.
*/
const options = [
{ value: "WEIGHT", label: "Weight" },
{ value: "PULSE", label: "Pulse" },
];
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("FilterBarSelect", () => {
it("inactive: shows only the dimension name, no clear button", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarSelect
label="Type"
value="ALL"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
expect(html).toContain('data-slot="filter-bar-pill"');
expect(html).toContain('data-active="false"');
expect(html).toContain("Type");
expect(html).not.toContain("Clear filter");
});
it("active: shows `label: value`, active styling and a labelled clear button", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Type"
value="WEIGHT"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
expect(html).toContain('data-active="true"');
expect(html).toContain("Weight");
expect(html).toContain('aria-label="Clear filter: Type"');
});
});
describe("FilterBarDateRange", () => {
it("inactive: pill shows the dimension name only", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from=""
to=""
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
expect(html).toContain('data-active="false"');
expect(html).toContain("Date range");
});
it("active: pill shows the formatted bounds and a clear button", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from="2026-05-12"
to="2026-06-10"
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
expect(html).toContain('data-active="true"');
// en dateShort: "05/12" / "06/10" — assert both day fragments are in.
expect(html).toMatch(/05\/12/);
expect(html).toMatch(/06\/10/);
expect(html).toContain('aria-label="Clear filter: Date range"');
});
});
describe("FilterBar shell", () => {
it("shows the reset action only while filtered", () => {
const inactive = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<span />
</FilterBar>,
);
const active = render(
<FilterBar isFiltered onReset={() => {}}>
<span />
</FilterBar>,
);
expect(inactive).not.toContain("Reset");
expect(active).toContain("Reset");
});
it("renders the result count slot", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}} count="42 readings">
<span />
</FilterBar>,
);
expect(html).toContain("42 readings");
expect(html).toContain("tabular-nums");
});
it("localises reset + clear labels in German", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Quelle"
value="MANUAL"
onValueChange={() => {}}
allLabel="Alle Quellen"
options={[{ value: "MANUAL", label: "Manuell" }]}
/>
</FilterBar>,
"de",
);
expect(html).toContain("Zurücksetzen");
expect(html).toContain('aria-label="Filter entfernen: Quelle"');
});
});
+289
View File
@@ -0,0 +1,289 @@
"use client";
import * as React from "react";
import { ListFilter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.16.1 — unified filter rail for data lists.
*
* One filter language for every list surface (measurements, mood, …)
* instead of the per-page rows of large labelled Selects + date inputs
* that had drifted apart. The grammar:
*
* [filter icon] [Zeitraum] [Typ] [Quelle] … [Zurücksetzen] [count]
*
* - Each dimension renders as ONE compact pill that is trigger and
* active-filter chip at once: inactive it shows just the dimension
* name ("Typ"), active it shows `Name: Wert`, picks up the primary
* accent, and grows a removable ✕. No second chip row, no duplicated
* state.
* - Canonical order at the call sites: date range, type, source —
* surface-specific dimensions append after.
* - Reset appears only while at least one dimension is active.
* - Mobile (< sm): the rail is a horizontal scroll row (hidden
* scrollbar, edge-bleed) so pills never wrap into a tall block; the
* result count drops onto its own line.
* - Purely presentational: filter state, query keys and fetching stay
* in the owning page/component — this file only renders the controls.
*
* A11y: every pill keeps an explicit `aria-label`; the per-pill clear
* button carries `dataList.clearFilter` with the dimension name; touch
* targets follow the dense-surface idiom (`min-h-11 sm:min-h-9`,
* ui-guidelines §5).
*/
/** Shared pill chrome — trigger and chip in one element. */
const PILL_CLASSES =
"border-border bg-card text-foreground inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-all duration-150 ease-out sm:min-h-9 h-auto py-1";
const PILL_ACTIVE_CLASSES = "border-primary/40 bg-primary/10";
function ClearPillButton({
label,
onClear,
}: {
label: string;
onClear: () => void;
}) {
const { t } = useTranslations();
return (
<button
type="button"
aria-label={t("dataList.clearFilter", { label })}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-1.5 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]"
onClick={onClear}
>
<X className="size-3.5" aria-hidden="true" />
</button>
);
}
export function FilterBar({
isFiltered,
onReset,
count,
children,
}: {
/** True when at least one dimension is active — shows the reset button. */
isFiltered: boolean;
/** Clears every dimension back to its default. */
onReset: () => void;
/** Result count node (already translated/formatted by the caller). */
count?: React.ReactNode;
children: React.ReactNode;
}) {
const { t } = useTranslations();
return (
<div
data-slot="filter-bar"
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between"
>
{/* Pill rail — horizontal scroll row below `sm` (hidden scrollbar,
edge-bleed so the row scrolls to the screen edge), wrapping flex
row on desktop. */}
<div className="-mx-4 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden">
<ListFilter
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
{children}
{isFiltered && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-11 shrink-0 rounded-full sm:min-h-9"
onClick={onReset}
>
{t("dataList.resetFilters")}
</Button>
)}
</div>
{count != null && (
<span className="text-muted-foreground text-sm tabular-nums">
{count}
</span>
)}
</div>
);
}
export interface FilterBarSelectOption {
value: string;
label: string;
}
/**
* One enum dimension as a pill-shaped Radix Select. `allValue` is the
* "no filter" sentinel (the surfaces use `"ALL"`); the pill counts as
* active for any other value and then renders `label: valueLabel` plus
* the clear ✕.
*/
export function FilterBarSelect({
label,
value,
onValueChange,
options,
allValue = "ALL",
allLabel,
}: {
label: string;
value: string;
onValueChange: (value: string) => void;
options: FilterBarSelectOption[];
allValue?: string;
allLabel: string;
}) {
const active = value !== allValue;
const activeLabel = active
? (options.find((o) => o.value === value)?.label ?? value)
: null;
return (
<span className="relative inline-flex shrink-0">
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger
aria-label={label}
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
PILL_CLASSES,
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
)}
>
{active ? (
<span className="truncate">
<span className="text-muted-foreground">{label}: </span>
{activeLabel}
</span>
) : (
<span className="text-muted-foreground">{label}</span>
)}
</SelectTrigger>
<SelectContent>
<SelectItem value={allValue}>{allLabel}</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{active && (
<ClearPillButton
label={label}
onClear={() => onValueChange(allValue)}
/>
)}
</span>
);
}
/**
* The date-range dimension as a pill-shaped Popover with the existing
* from/to `DateInput` pair inside. Active as soon as either bound is
* set; the pill then shows the formatted range and the clear ✕ resets
* both bounds.
*/
export function FilterBarDateRange({
label,
from,
to,
onFromChange,
onToChange,
idPrefix,
}: {
label: string;
/** ISO day string (`YYYY-MM-DD`) or empty string. */
from: string;
to: string;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
/** Unique prefix for the two input ids (label association). */
idPrefix: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const active = from !== "" || to !== "";
const display = active
? `${from ? fmt.dateShort(from) : "…"} – ${to ? fmt.dateShort(to) : "…"}`
: null;
const clear = () => {
onFromChange("");
onToChange("");
};
return (
<span className="relative inline-flex shrink-0">
<Popover>
<PopoverTrigger
aria-label={label}
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
PILL_CLASSES,
"outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
)}
>
{active ? (
<span className="truncate">
<span className="text-muted-foreground">{label}: </span>
{display}
</span>
) : (
<span className="text-muted-foreground">{label}</span>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-auto space-y-3 p-4">
<div className="flex flex-col gap-1">
<Label
htmlFor={`${idPrefix}-from`}
className="text-muted-foreground text-xs"
>
{t("dataList.dateFrom")}
</Label>
<DateInput
id={`${idPrefix}-from`}
className="w-44"
value={from}
max={to || undefined}
onChange={(e) => onFromChange(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor={`${idPrefix}-to`}
className="text-muted-foreground text-xs"
>
{t("dataList.dateTo")}
</Label>
<DateInput
id={`${idPrefix}-to`}
className="w-44"
value={to}
min={from || undefined}
onChange={(e) => onToChange(e.target.value)}
/>
</div>
</PopoverContent>
</Popover>
{active && <ClearPillButton label={label} onClear={clear} />}
</span>
);
}