Merge pull request #435 from MBombeck/release/v1.27.25

Release v1.27.25 — documents filter dropdowns
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-08 00:33:08 +02:00
commit 052ead6bf3
13 files changed
+280 -121

No files matched your search

+6
View File
@@ -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
+1 -1
View File
@@ -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.
+17 -10
View File
@@ -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");
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -80,6 +80,27 @@ describe("<DocumentsView> 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"');
+203 -96
View File
@@ -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 (
<div
data-slot="document-filter-bar"
className="bg-background/95 border-border sticky top-0 z-10 -mx-4 border-b px-4 pt-1 pb-3 backdrop-blur md:-mx-6 md:px-6"
>
{/* 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. */}
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-3">
<div className="relative w-full shrink-0 md:w-64">
{/* 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. */}
<div className="flex flex-nowrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
@@ -106,109 +162,160 @@ export function DocumentFilterBar({
</kbd>
</div>
<div
className="-mx-4 flex min-w-0 flex-1 [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
role="group"
aria-label={t("documents.filter.groupLabel")}
>
<span
role="group"
{/* Type — multi-select (OR inside the facet). Items keep the menu
open so several kinds can be toggled in one pass. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-type-filter"
aria-label={typeLabel}
className={cn(TRIGGER_CLASSES, kindCount > 0 && TRIGGER_ACTIVE)}
>
<ListFilter className="size-4 shrink-0" aria-hidden />
<span className={facetLabelClass(kindCount > 0)}>
{typeLabel}
</span>
<ChevronDown
className="hidden size-3.5 opacity-60 sm:inline"
aria-hidden
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
aria-label={t("documents.filter.typeGroup")}
className="contents"
>
<DropdownMenuLabel>
{t("documents.filter.typeGroup")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
const active = activeKinds.has(kind);
return (
<button
<DropdownMenuCheckboxItem
key={kind}
type="button"
aria-pressed={active}
onClick={() => onToggleKind(kind)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
checked={activeKinds.has(kind)}
onCheckedChange={() => onToggleKind(kind)}
onSelect={(event) => event.preventDefault()}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</button>
</DropdownMenuCheckboxItem>
);
})}
</span>
</DropdownMenuContent>
</DropdownMenu>
{conditionChips.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.conditionGroup")}
className="contents"
{/* Condition — single-select; only rendered when episodes carry
links. Picking closes the menu (natural single-choice cadence). */}
{conditionChips.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-condition-filter"
aria-label={conditionLabel}
className={cn(
TRIGGER_CLASSES,
activeCondition && TRIGGER_ACTIVE,
)}
>
{conditionChips.map((chip) => {
const active = chip.episodeId === activeEpisodeId;
return (
<button
key={chip.episodeId}
type="button"
aria-pressed={active}
onClick={() => onToggleEpisode(chip.episodeId)}
className={cn(
CHIP_CLASSES,
active && CHIP_ACTIVE_CLASSES,
)}
>
<span className="max-w-40 truncate">{chip.name}</span>
</button>
);
})}
</span>
</>
) : null}
<Activity className="size-4 shrink-0" aria-hidden />
<span className={facetLabelClass(Boolean(activeCondition))}>
{conditionLabel}
</span>
<ChevronDown
className="hidden size-3.5 opacity-60 sm:inline"
aria-hidden
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
aria-label={t("documents.filter.conditionGroup")}
>
<DropdownMenuLabel>
{t("documents.filter.conditionGroup")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{conditionChips.map((chip) => (
<DropdownMenuCheckboxItem
key={chip.episodeId}
checked={chip.episodeId === activeEpisodeId}
onCheckedChange={() => onToggleEpisode(chip.episodeId)}
>
<span className="max-w-56 truncate">{chip.name}</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{years.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.yearGroup")}
className="contents"
{/* Year — single-select; only rendered when the corpus spans years. */}
{years.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-year-filter"
aria-label={yearLabel}
className={cn(
TRIGGER_CLASSES,
"tabular-nums",
activeYear !== undefined && TRIGGER_ACTIVE,
)}
>
{years.map((year) => {
const active = year === activeYear;
return (
<button
key={year}
type="button"
aria-pressed={active}
onClick={() => onToggleYear(year)}
className={cn(
CHIP_CLASSES,
"tabular-nums",
active && CHIP_ACTIVE_CLASSES,
)}
>
{year}
</button>
);
})}
</span>
</>
) : null}
</div>
<Calendar className="size-4 shrink-0" aria-hidden />
<span className={facetLabelClass(activeYear !== undefined)}>
{yearLabel}
</span>
<ChevronDown
className="hidden size-3.5 opacity-60 sm:inline"
aria-hidden
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
aria-label={t("documents.filter.yearGroup")}
>
<DropdownMenuLabel>
{t("documents.filter.yearGroup")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{years.map((year) => (
<DropdownMenuCheckboxItem
key={year}
checked={year === activeYear}
onCheckedChange={() => onToggleYear(year)}
className="tabular-nums"
>
{year}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{activeCount > 0 ? (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="text-muted-foreground shrink-0 self-start md:self-auto"
aria-label={t("documents.filter.clear")}
className="text-muted-foreground shrink-0"
>
<X className="size-3.5" aria-hidden />
{t("documents.filter.clear")}
<span className="hidden sm:inline">
{t("documents.filter.clear")}
</span>
{activeCount > 1 ? (
<span className="tabular-nums">({activeCount})</span>
) : null}