From 1e34671d925f573481ed0203c57103d122874de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 20:48:46 +0200 Subject: [PATCH] feat(ux): silhouettes from the first paint and one filter language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ui-guidelines.md | 24 +- .../__tests__/dashboard-chart-reveal.test.tsx | 9 +- .../root-loading-silhouette.test.tsx | 107 +++++++ src/app/api/auth/register/route.ts | 22 +- .../__tests__/hidden-redaction.test.ts | 4 + .../achievements/__tests__/ios-format.test.ts | 4 + .../api/gamification/achievements/route.ts | 108 +++++++ src/app/loading.tsx | 63 +++- .../admin/invite-tokens-section.tsx | 35 ++- src/components/dashboard/chart-reveal.tsx | 17 +- .../insights/use-insights-advisor.ts | 2 +- .../measurement-list-filter-select.test.tsx | 22 +- .../measurement-list-locked-type.test.tsx | 11 +- .../measurements/measurement-list.tsx | 192 +++++------- .../mood-list-filter-select.test.tsx | 17 +- src/components/mood/mood-list.tsx | 153 ++++------ .../ui/__tests__/filter-bar.test.tsx | 143 +++++++++ src/components/ui/filter-bar.tsx | 289 ++++++++++++++++++ 18 files changed, 953 insertions(+), 269 deletions(-) create mode 100644 src/app/__tests__/root-loading-silhouette.test.tsx create mode 100644 src/components/ui/__tests__/filter-bar.test.tsx create mode 100644 src/components/ui/filter-bar.tsx diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index 1afa09ad3..d58ef382d 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -198,7 +198,27 @@ admin views. Default to `` + 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 `` rail from +`src/components/ui/filter-bar.tsx` — never a per-page row of large +labelled ` - - - - - - {t("measurements.allTypes")} - - {Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => ( - - {t(labelKey)} - - ))} - - - - )} - - {/* 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 && ( - <> -
- - -
- -
- - setFromDay(e.target.value)} - /> -
-
- - setToDay(e.target.value)} - /> -
- - )} - - - {data?.meta?.total !== undefined && ( - - {t("measurements.measurementCount", { - count: fmt.integer(data.meta.total), - })} - - )} - + {/* v1.16.1 — unified filter rail (``): 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 && ( +
+ + {t("measurements.measurementCount", { + count: fmt.integer(data.meta.total), + })} + +
+ ) + ) : ( + { + setTypeFilter("ALL"); + setSourceFilter("ALL"); + setFromDay(""); + setToDay(""); + }} + count={ + data?.meta?.total !== undefined + ? t("measurements.measurementCount", { + count: fmt.integer(data.meta.total), + }) + : undefined + } + > + + ({ value: val, label: t(labelKey) }), + )} + /> + ({ + value: src, + label: formatMeasurementSource(src, t), + }))} + /> + + )} {isLoading ? (
diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx index 774e8e154..64438ff32 100644 --- a/src/components/mood/__tests__/mood-list-filter-select.test.tsx +++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx @@ -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 + // `` 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)", () => { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index b3c4e6bde..57b87fbfe 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -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 ( <>
-
-
-
- - -
- - {/* v1.15.13 — source filter. */} -
- - -
- - {/* v1.15.13 — optional date range, wired to from/to. */} -
- - setFromDay(e.target.value)} - /> -
-
- - setToDay(e.target.value)} - /> -
-
- - {data?.meta?.total !== undefined && ( - - {t("mood.entryCount", { - count: fmt.integer(data.meta.total), - })} - - )} -
+ {/* v1.16.1 — unified filter rail (``), 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. */} + { + setMoodFilter("ALL"); + setSourceFilter("ALL"); + setFromDay(""); + setToDay(""); + }} + count={ + data?.meta?.total !== undefined + ? t("mood.entryCount", { + count: fmt.integer(data.meta.total), + }) + : undefined + } + > + + ({ + value: val, + label: `${MOOD_SCORES[val]} (${t(MOOD_LABEL_KEYS[val])})`, + }))} + /> + ({ + value: src, + label: formatMoodSource(src, t), + }))} + /> + {isLoading ? (
diff --git a/src/components/ui/__tests__/filter-bar.test.tsx b/src/components/ui/__tests__/filter-bar.test.tsx new file mode 100644 index 000000000..5830b2e0f --- /dev/null +++ b/src/components/ui/__tests__/filter-bar.test.tsx @@ -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( + {node}, + ); +} + +describe("FilterBarSelect", () => { + it("inactive: shows only the dimension name, no clear button", () => { + const html = render( + {}}> + {}} + allLabel="All types" + options={options} + /> + , + ); + 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( + {}}> + {}} + allLabel="All types" + options={options} + /> + , + ); + 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( + {}}> + {}} + onToChange={() => {}} + idPrefix="probe" + /> + , + ); + 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( + {}}> + {}} + onToChange={() => {}} + idPrefix="probe" + /> + , + ); + 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( + {}}> + + , + ); + const active = render( + {}}> + + , + ); + expect(inactive).not.toContain("Reset"); + expect(active).toContain("Reset"); + }); + + it("renders the result count slot", () => { + const html = render( + {}} count="42 readings"> + + , + ); + expect(html).toContain("42 readings"); + expect(html).toContain("tabular-nums"); + }); + + it("localises reset + clear labels in German", () => { + const html = render( + {}}> + {}} + allLabel="Alle Quellen" + options={[{ value: "MANUAL", label: "Manuell" }]} + /> + , + "de", + ); + expect(html).toContain("Zurücksetzen"); + expect(html).toContain('aria-label="Filter entfernen: Quelle"'); + }); +}); diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx new file mode 100644 index 000000000..599ecdadd --- /dev/null +++ b/src/components/ui/filter-bar.tsx @@ -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 ( + + ); +} + +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 ( +
+ {/* Pill rail — horizontal scroll row below `sm` (hidden scrollbar, + edge-bleed so the row scrolls to the screen edge), wrapping flex + row on desktop. */} +
+
+ {count != null && ( + + {count} + + )} +
+ ); +} + +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 ( + + + {active && ( + onValueChange(allValue)} + /> + )} + + ); +} + +/** + * 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 ( + + + + {active ? ( + + {label}: + {display} + + ) : ( + {label} + )} + + +
+ + onFromChange(e.target.value)} + /> +
+
+ + onToChange(e.target.value)} + /> +
+
+
+ {active && } +
+ ); +}