diff --git a/CHANGELOG.md b/CHANGELOG.md index ef49af76b..017f68dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.27.25] — 2026-07-08 — Documents filter dropdowns + +### Changed + +- The Documents page filters (type, condition, year) move from an inline chip row into compact dropdowns beside the search field, so the filter bar stays on a single line at every width — on a phone the dropdowns collapse to icons rather than wrapping or scrolling. Selection behaviour is unchanged (type is multi-select; condition and year pick one). + ## [1.27.24] — 2026-07-07 — Share documents ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 80f4f580b..be34a2aa1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.24 + version: 1.27.25 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 9d9cfb339..e11580fe6 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -108,7 +108,7 @@ test.describe("document vault", () => { // ── §1A — the 30-second doctor flow, three independent routes ───────── - test("doctor flow A: the type chip reaches the MRT report", async ({ + test("doctor flow A: the type filter reaches the MRT report", async ({ page, }) => { await page.goto("/documents"); @@ -116,7 +116,11 @@ test.describe("document vault", () => { page.getByRole("heading", { name: "Documents" }), ).toBeVisible(); - await page.getByRole("button", { name: "Imaging", exact: true }).click(); + // Open the type dropdown, tick Imaging (multi-select stays open), then + // dismiss the menu. + await page.getByRole("button", { name: "All types" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Imaging" }).click(); + await page.keyboard.press("Escape"); // Autumn 2025 sits below the newest sections — scroll the window down. await scrollUntilVisible(page, openButton(page, "MRT Knie")); await openButton(page, "MRT Knie").click(); @@ -128,13 +132,15 @@ test.describe("document vault", () => { await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); }); - test("doctor flow B: the condition chip reaches the MRT report", async ({ + test("doctor flow B: the condition filter reaches the MRT report", async ({ page, }) => { await page.goto("/documents"); + // Open the condition dropdown and pick the linked episode (single-select + // → the menu closes on choice). + await page.locator('[data-slot="document-condition-filter"]').click(); await page - .locator('[data-slot="document-filter-bar"]') - .getByRole("button", { name: "Knie", exact: true }) + .getByRole("menuitemcheckbox", { name: "Knie", exact: true }) .click(); // The Knie filter narrows the corpus to the linked MRT trio. @@ -162,12 +168,13 @@ test.describe("document vault", () => { page, }) => { await page.goto("/documents?kind=IMAGING&year=2025"); + // A deep-linked facet surfaces on its dropdown trigger label. await expect( - page.getByRole("button", { name: "Imaging", exact: true }), - ).toHaveAttribute("aria-pressed", "true"); + page.locator('[data-slot="document-type-filter"]'), + ).toContainText("Imaging"); await expect( - page.getByRole("button", { name: "2025", exact: true }), - ).toHaveAttribute("aria-pressed", "true"); + page.locator('[data-slot="document-year-filter"]'), + ).toContainText("2025"); await scrollUntilVisible(page, openButton(page, "MRT Knie")); await page.goto(`/documents?episode=${KNIE_EPISODE_ID}`); @@ -345,7 +352,7 @@ test.describe("document vault", () => { await expect(openButton(page, "MRT Knie")).toBeVisible(); // Tab from the search into the grid's single roving slot (bounded walk - // across the chip rail), then arrows move the active card. + // across the filter dropdowns), then arrows move the active card. let reachedGrid = false; for (let i = 0; i < 40; i++) { await page.keyboard.press("Tab"); diff --git a/messages/de.json b/messages/de.json index 29b5eedaa..68f24e399 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8067,11 +8067,14 @@ "filter": { "searchLabel": "Dokumente durchsuchen", "searchPlaceholder": "Titel oder Dateiname suchen…", - "groupLabel": "Dokumente filtern", "typeGroup": "Dokumenttyp", "conditionGroup": "Erkrankung", "yearGroup": "Jahr", - "clear": "Filter zurücksetzen" + "clear": "Filter zurücksetzen", + "typeAll": "Alle Typen", + "typeCount": "{count} Typen", + "conditionAll": "Alle Erkrankungen", + "yearAll": "Alle Jahre" }, "card": { "untitled": "Unbenanntes Dokument", diff --git a/messages/en.json b/messages/en.json index 3b4ab0049..fec5652bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8067,11 +8067,14 @@ "filter": { "searchLabel": "Search documents", "searchPlaceholder": "Search title or filename…", - "groupLabel": "Filter documents", "typeGroup": "Document type", "conditionGroup": "Condition", "yearGroup": "Year", - "clear": "Clear filters" + "clear": "Clear filters", + "typeAll": "All types", + "typeCount": "{count} types", + "conditionAll": "All conditions", + "yearAll": "All years" }, "card": { "untitled": "Untitled document", diff --git a/messages/es.json b/messages/es.json index a81dcae95..1bb6347a0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8067,11 +8067,14 @@ "filter": { "searchLabel": "Buscar documentos", "searchPlaceholder": "Buscar por título o nombre de archivo…", - "groupLabel": "Filtrar documentos", "typeGroup": "Tipo de documento", "conditionGroup": "Afección", "yearGroup": "Año", - "clear": "Borrar filtros" + "clear": "Borrar filtros", + "typeAll": "Todos los tipos", + "typeCount": "{count} tipos", + "conditionAll": "Todas las afecciones", + "yearAll": "Todos los años" }, "card": { "untitled": "Documento sin título", diff --git a/messages/fr.json b/messages/fr.json index 1d4655f0e..f732a04fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8067,11 +8067,14 @@ "filter": { "searchLabel": "Rechercher des documents", "searchPlaceholder": "Rechercher un titre ou un nom de fichier…", - "groupLabel": "Filtrer les documents", "typeGroup": "Type de document", "conditionGroup": "Affection", "yearGroup": "Année", - "clear": "Effacer les filtres" + "clear": "Effacer les filtres", + "typeAll": "Tous les types", + "typeCount": "{count} types", + "conditionAll": "Toutes les affections", + "yearAll": "Toutes les années" }, "card": { "untitled": "Document sans titre", diff --git a/messages/it.json b/messages/it.json index 6e99c85cd..0c56822f9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8067,11 +8067,14 @@ "filter": { "searchLabel": "Cerca documenti", "searchPlaceholder": "Cerca titolo o nome file…", - "groupLabel": "Filtra documenti", "typeGroup": "Tipo di documento", "conditionGroup": "Patologia", "yearGroup": "Anno", - "clear": "Cancella filtri" + "clear": "Cancella filtri", + "typeAll": "Tutti i tipi", + "typeCount": "{count} tipi", + "conditionAll": "Tutte le patologie", + "yearAll": "Tutti gli anni" }, "card": { "untitled": "Documento senza titolo", diff --git a/messages/pl.json b/messages/pl.json index 54dac5057..3791f3a07 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8067,11 +8067,14 @@ "filter": { "searchLabel": "Szukaj dokumentów", "searchPlaceholder": "Szukaj tytułu lub nazwy pliku…", - "groupLabel": "Filtruj dokumenty", "typeGroup": "Typ dokumentu", "conditionGroup": "Schorzenie", "yearGroup": "Rok", - "clear": "Wyczyść filtry" + "clear": "Wyczyść filtry", + "typeAll": "Wszystkie typy", + "typeCount": "{count} typy", + "conditionAll": "Wszystkie schorzenia", + "yearAll": "Wszystkie lata" }, "card": { "untitled": "Dokument bez tytułu", diff --git a/package.json b/package.json index 6a06103fd..9b8fdf122 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.24", + "version": "1.27.25", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 7a85579c5..a75583ec7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.24"; + /* @sw-version-fallback */ "v1.27.25"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/components/documents/__tests__/documents-view-states.test.tsx b/src/components/documents/__tests__/documents-view-states.test.tsx index 0bfbd899d..56c270cf1 100644 --- a/src/components/documents/__tests__/documents-view-states.test.tsx +++ b/src/components/documents/__tests__/documents-view-states.test.tsx @@ -80,6 +80,27 @@ describe(" states", () => { expect(html).not.toContain("Your document vault is empty"); }); + it("filter bar: type facet is a compact dropdown, not an inline chip row", () => { + const html = render(""); + // The type facet renders as a single dropdown trigger labelled by the + // active selection ("All types" while nothing is picked)… + expect(html).toContain('data-slot="document-type-filter"'); + expect(html).toContain("All types"); + // …and the controls share ONE row — the container never wraps. + expect(html).toContain("flex-nowrap"); + // The old horizontal chip scroller is gone. + expect(html).not.toContain("overflow-x-auto"); + }); + + it("filter bar: an active type selection labels the dropdown trigger", () => { + const html = render("kind=IMAGING", { kinds: ["IMAGING"] }); + // Selection flows into the URL (→ the list query) AND surfaces on the + // trigger label rather than a pressed chip. + expect(html).toContain('data-slot="document-type-filter"'); + expect(html).toContain(">Imaging<"); + expect(html).not.toContain("All types"); + }); + it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => { const html = render("", {}); expect(html).toContain('data-slot="empty-state"'); diff --git a/src/components/documents/document-filter-bar.tsx b/src/components/documents/document-filter-bar.tsx index c71ef91df..74312ce90 100644 --- a/src/components/documents/document-filter-bar.tsx +++ b/src/components/documents/document-filter-bar.tsx @@ -1,30 +1,67 @@ "use client"; /** - * The vault's sticky filter rail: debounced title/filename search, the - * nine-kind type-chip row (multi-select, OR inside the facet), condition - * chips (episodes that actually carry links), a year segmenter (years - * present in the corpus), and a one-tap clear with the active-facet count. + * The vault's sticky filter rail: debounced title/filename search plus three + * compact dropdown facets — type (multi-select, OR inside the facet), + * condition (episodes that actually carry links, single-select), and year + * (years present in the corpus, single-select) — and a one-tap clear with + * the active-facet count. + * + * The controls sit on ONE row at every width: the search flexes and can + * shrink to nothing while each dropdown trigger stays a fixed compact + * control, so the bar never wraps to a second line and never grows a + * horizontal chip scroller. The content-search hint (indexed-content match + * + corpus backfill) rides its own helper line below, only when indexing is + * live. * * Purely presentational — the filter state lives in the page URL and is * owned by `documents-view.tsx`; this bar only renders the controls. It * sticks below the top edge inside the shell's scroll container (sticky, * translucent background, border-b — no viewport-height tricks). */ -import { ScanSearch, Search, X } from "lucide-react"; +import { + Activity, + Calendar, + ChevronDown, + ListFilter, + ScanSearch, + Search, + X, +} from "lucide-react"; import type { RefObject } from "react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta"; -/** Shared chip chrome — mirrors the app-wide FilterBar pill vocabulary. */ -const CHIP_CLASSES = - "border-border bg-card text-foreground inline-flex min-h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none"; -const CHIP_ACTIVE_CLASSES = "border-primary/40 bg-primary/10"; +/** Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. */ +const TRIGGER_ACTIVE = "border-primary/40 bg-primary/10 text-foreground"; +/** Shared trigger chrome: compact, fixed, never shrinks below its label. */ +const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal"; + +/** + * The facet label is the whole point of the trigger — but three text labels + * plus the search will not fit a 360px phone. So the label shows from `sm` + * up, and on a phone only when the facet is ACTIVE (truncated), while the + * leading icon always carries the facet identity. The trailing chevron is + * desktop-only chrome. Net: inactive phone triggers collapse to a single + * icon, the row stays one line at every width, and the accessible name is + * pinned via `aria-label` so an icon-only trigger still announces its state. + */ +function facetLabelClass(active: boolean): string { + return cn("max-w-28 truncate", active ? "inline" : "hidden sm:inline"); +} export interface ConditionChip { episodeId: string; @@ -73,17 +110,36 @@ export function DocumentFilterBar({ }) { const { t } = useTranslations(); + const kindCount = activeKinds.size; + const typeLabel = + kindCount === 0 + ? t("documents.filter.typeAll") + : kindCount === 1 + ? t(`documents.kind.${[...activeKinds][0]}`) + : t("documents.filter.typeCount", { count: kindCount }); + + const activeCondition = conditionChips.find( + (chip) => chip.episodeId === activeEpisodeId, + ); + const conditionLabel = + activeCondition?.name ?? t("documents.filter.conditionAll"); + + const yearLabel = + activeYear !== undefined + ? String(activeYear) + : t("documents.filter.yearAll"); + return (
- {/* Search shares one row with the tag chips on desktop — it wraps to - its own line only on phone widths where a chip scroller plus an - input can't share a row. The chip scroller flexes to fill the - rest; the clear button pins to the trailing edge. */} -
-
+ {/* One row at every width: the search flexes (min-w-0 → it can shrink to + nothing), every dropdown trigger stays a fixed compact control, and + the clear pins to the trailing edge. `flex-nowrap` guarantees the bar + never breaks to a second line. */} +
+
-
- + + + + + + {t("documents.filter.typeGroup")} + + {DOCUMENT_KIND_ORDER.map((kind) => { const Icon = DOCUMENT_KIND_ICONS[kind]; - const active = activeKinds.has(kind); return ( - + ); })} - + + - {conditionChips.length > 0 ? ( - <> - - 0 ? ( + + + - ); - })} - - - ) : null} + + + {conditionLabel} + + + + + + + {t("documents.filter.conditionGroup")} + + + {conditionChips.map((chip) => ( + onToggleEpisode(chip.episodeId)} + > + {chip.name} + + ))} + + + ) : null} - {years.length > 0 ? ( - <> - - 0 ? ( + + + - ); - })} - - - ) : null} -
+ + + {yearLabel} + + + + + + + {t("documents.filter.yearGroup")} + + + {years.map((year) => ( + onToggleYear(year)} + className="tabular-nums" + > + {year} + + ))} + + + ) : null} {activeCount > 0 ? (