M6 1902b: facet bar + saved views UI (frontend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 37s

The dead-simple facet bar over the board + saved views in the sidebar,
completing task 1902. Filter state lives in the URL query, so a filtered
board is a shareable lens and a saved view is just a link ("one space,
many lenses").

- notes/facets.ts: facetsFromQuery / facetsToQuery / facetCount helpers.
- FilterBar.vue (board only): a "Filters (N)" toggle expanding to text
  search + color swatches + label chips + has-reminder / has-attachment /
  Lists / Notes toggles + a created-date range; Clear + "Save view".
  Each control writes the URL query (router.replace).
- notes store: load(view, label, facets) builds the query; NoteFacets type
  + activeFacets; import reload preserves active facets.
- savedFilters store + sidebar "Views" section (each a query-link, delete
  on hover); loaded on mount.
- BoardView derives facets from the query, reloads on facet change (ignores
  ?open=), and shows a "no notes match these filters" empty state.
- Backend: saved-filter param whitelist keys on `label` (matches the
  repeatable ?label= query) so saved views keep their labels. New filter
  icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-23 08:45:56 -04:00
co-authored by Claude Opus 4.8
parent 5fdc124c77
commit 5c045aed63
9 changed files with 382 additions and 11 deletions
+56
View File
@@ -0,0 +1,56 @@
// Convert between the board's URL query and a NoteFacets object. The URL query IS the
// filter state (so a lens is shareable/bookmarkable and a saved view is just a link).
import type { LocationQuery, LocationQueryRaw } from "vue-router";
import type { NoteFacets } from "../stores/notes";
function one(v: LocationQuery[string]): string | undefined {
return (Array.isArray(v) ? v[0] : v) ?? undefined;
}
export function facetsFromQuery(q: LocationQuery): NoteFacets {
const rawLabel = q.label;
const labels = (Array.isArray(rawLabel) ? rawLabel : [rawLabel]).filter(
(x): x is string => typeof x === "string" && x.length > 0,
);
const f: NoteFacets = {};
const text = one(q.q);
if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
const kind = one(q.kind);
if (kind === "text" || kind === "list") f.kind = kind;
if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true;
const after = one(q.created_after);
if (after) f.created_after = after;
const before = one(q.created_before);
if (before) f.created_before = before;
return f;
}
export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {};
if (f.q) q.q = f.q;
if (f.color) q.color = f.color;
if (f.kind) q.kind = f.kind;
if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true";
if (f.created_after) q.created_after = f.created_after;
if (f.created_before) q.created_before = f.created_before;
return q;
}
// How many facets are active (labels counted individually) — drives the "Filters (N)" badge.
export function facetCount(f: NoteFacets): number {
let n = 0;
if (f.q) n++;
if (f.color) n++;
if (f.kind) n++;
n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++;
if (f.created_after || f.created_before) n++;
return n;
}