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
+13 -4
View File
@@ -3,6 +3,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import { useUiStore } from "../stores/ui";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import FilterBar from "../components/FilterBar.vue";
import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue";
@@ -47,6 +49,11 @@ function viewForRoute(name: unknown): NoteView {
const currentView = computed<NoteView>(() => viewForRoute(route.name));
const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null));
// Facet filters live in the URL query on the board route (a filtered board = a lens).
const facets = computed(() => facetsFromQuery(route.query));
const facetKey = computed(() => JSON.stringify(facetsToQuery(facets.value))); // stable; ignores ?open=
const filtered = computed(() => route.name === "board" && facetCount(facets.value) > 0);
// Quick-add + pinned/others split only on the main board (not archive/trash/label).
const isMainBoard = computed(() => route.name === "board");
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
@@ -107,9 +114,10 @@ watch(
if (focusedIndex.value >= len) focusedIndex.value = len - 1;
},
);
watch([currentView, currentLabel], () => (focusedIndex.value = -1));
watch([currentView, currentLabel, facetKey], () => (focusedIndex.value = -1));
const emptyState = computed(() => {
if (filtered.value) return { title: "No notes match these filters", subtitle: "Try clearing or loosening a facet." };
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
if (currentView.value === "archived")
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
@@ -120,7 +128,7 @@ const emptyState = computed(() => {
async function reload() {
loadError.value = "";
try {
await notes.load(currentView.value, currentLabel.value);
await notes.load(currentView.value, currentLabel.value, facets.value);
} catch (e) {
loadError.value = (e as { error?: string }).error ?? "Couldn't load your notes.";
}
@@ -131,7 +139,7 @@ onMounted(() => {
window.addEventListener("keydown", onBoardKey);
});
onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey));
watch([currentView, currentLabel], reload);
watch([currentView, currentLabel, facetKey], reload);
function openEditor(note: Note) {
editing.value = note;
@@ -174,7 +182,8 @@ async function onDrop(target: Note) {
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<NoteEditor v-if="isMainBoard" ref="composer" inline autofocus class="mb-8" />
<NoteEditor v-if="isMainBoard" ref="composer" inline autofocus class="mb-6" />
<FilterBar v-if="isMainBoard" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>