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
+36
View File
@@ -4,11 +4,13 @@ import { useRoute, useRouter } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels";
import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
import { useUiStore } from "../stores/ui";
import CommandPalette from "./CommandPalette.vue";
import Icon from "./Icon.vue";
import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue";
import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
const route = useRoute();
@@ -16,8 +18,18 @@ const router = useRouter();
const session = useSessionStore();
const config = useConfigStore();
const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const ui = useUiStore();
async function removeView(f: SavedFilter) {
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
try {
await savedFilters.remove(f.id);
} catch {
ui.showToast("Couldn't delete that view.");
}
}
const managing = ref(false);
const showShortcuts = ref(false);
const paletteOpen = ref(false);
@@ -140,6 +152,7 @@ function onKeydown(e: KeyboardEvent) {
onMounted(() => {
if (!labels.loaded) void labels.load();
if (!savedFilters.loaded) void savedFilters.load();
window.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
@@ -289,6 +302,29 @@ async function signOut() {
<span class="truncate">{{ lb.name }}</span>
</RouterLink>
<template v-if="savedFilters.items.length">
<div class="mt-3 px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Views</span>
</div>
<RouterLink
v-for="f in savedFilters.items"
:key="f.id"
:to="{ path: '/', query: facetsToQuery(f.params) }"
class="nav-link group/view"
>
<Icon name="filter" />
<span class="flex-1 truncate">{{ f.name }}</span>
<button
type="button"
class="text-neutral-400 opacity-0 hover:text-red-500 group-hover/view:opacity-100"
:aria-label="`Delete view ${f.name}`"
@click.prevent.stop="removeView(f)"
>
×
</button>
</RouterLink>
</template>
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
<Icon name="archive" /> Archive
</RouterLink>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLabelsStore } from "../stores/labels";
import { useSavedFiltersStore } from "../stores/savedFilters";
import { useUiStore } from "../stores/ui";
import type { NoteFacets } from "../stores/notes";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: text search + color + labels + has-reminder
// + has-attachment + kind + created-date range. The URL query IS the state, so a
// filtered board is a shareable lens and a saved view is just a link.
const route = useRoute();
const router = useRouter();
const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const ui = useUiStore();
const open = ref(false);
const facets = computed<NoteFacets>(() => facetsFromQuery(route.query));
const count = computed(() => facetCount(facets.value));
function apply(next: NoteFacets) {
void router.replace({ path: "/", query: facetsToQuery(next) });
}
function patch(p: Partial<NoteFacets>) {
apply({ ...facets.value, ...p });
}
function clearAll() {
void router.replace({ path: "/", query: {} });
}
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function setKind(k: "text" | "list") {
patch({ kind: facets.value.kind === k ? undefined : k });
}
function toggleLabel(id: string) {
const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
patch({ label: next.length ? next : undefined });
}
function toggleReminder() {
patch({ has_reminder: facets.value.has_reminder ? undefined : true });
}
function toggleAttachment() {
patch({ has_attachment: facets.value.has_attachment ? undefined : true });
}
let qTimer: ReturnType<typeof setTimeout> | undefined;
function onQ(e: Event) {
const v = (e.target as HTMLInputElement).value;
clearTimeout(qTimer);
qTimer = setTimeout(() => patch({ q: v.trim() || undefined }), 300);
}
function isoDay(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value;
patch({ created_after: v ? `${v}T00:00:00` : undefined });
}
function onTo(e: Event) {
const v = (e.target as HTMLInputElement).value;
if (!v) {
patch({ created_before: undefined });
return;
}
// Half-open upper bound: the start of the day AFTER the chosen date (so it's inclusive).
const d = new Date(`${v}T00:00:00`);
d.setDate(d.getDate() + 1);
patch({ created_before: `${isoDay(d)}T00:00:00` });
}
const fromInput = computed(() => (facets.value.created_after ?? "").slice(0, 10));
const toInput = computed(() => {
if (!facets.value.created_before) return "";
const d = new Date(facets.value.created_before);
d.setDate(d.getDate() - 1);
return isoDay(d);
});
async function saveView() {
const name = window.prompt("Name this view:");
if (!name?.trim()) return;
try {
await savedFilters.create(name.trim(), facets.value);
ui.showToast(`Saved view "${name.trim()}".`);
} catch (e) {
ui.showToast((e as { error?: string }).error ?? "Couldn't save the view.");
}
}
const chipBase =
"rounded-full border px-2.5 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand";
const chipOn = "border-brand bg-brand/10 text-brand-700 dark:text-brand";
const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800";
</script>
<template>
<div class="mb-6">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
:class="count ? 'border-brand text-brand-700 dark:text-brand' : ''"
@click="open = !open"
>
<Icon name="filter" /> Filters
<span v-if="count" class="rounded-full bg-brand px-1.5 text-xs font-semibold text-black">{{ count }}</span>
</button>
<template v-if="count">
<button type="button" class="text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300" @click="clearAll">
Clear
</button>
<button type="button" class="text-xs font-medium text-brand-700 underline dark:text-brand" @click="saveView">
Save view
</button>
</template>
</div>
<div
v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<input
type="search"
:value="facets.q ?? ''"
placeholder="Search text…"
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@input="onQ"
/>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
v-for="c in NOTE_COLOR_KEYS"
:key="c"
type="button"
:title="NOTE_COLOR_LABELS[c]"
:aria-label="NOTE_COLOR_LABELS[c]"
class="h-6 w-6 rounded-full border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/10"
:class="[NOTE_SWATCH_CLASSES[c], facets.color === c ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="setColor(c)"
/>
</div>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<button
v-for="lb in labels.items"
:key="lb.id"
type="button"
:class="[chipBase, (facets.label ?? []).includes(lb.id) ? chipOn : chipOff]"
@click="toggleLabel(lb.id)"
>
{{ lb.name }}
</button>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Only</span>
<button type="button" :class="[chipBase, facets.has_reminder ? chipOn : chipOff]" @click="toggleReminder">
Has reminder
</button>
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment
</button>
<button type="button" :class="[chipBase, facets.kind === 'list' ? chipOn : chipOff]" @click="setKind('list')">
Lists
</button>
<button type="button" :class="[chipBase, facets.kind === 'text' ? chipOn : chipOff]" @click="setKind('text')">
Notes
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-16 shrink-0 text-xs text-neutral-400">Created</span>
<input
type="date"
:value="fromInput"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@change="onFrom"
/>
<span class="text-xs text-neutral-400">to</span>
<input
type="date"
:value="toInput"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@change="onTo"
/>
</div>
</div>
</div>
</template>
+1
View File
@@ -28,6 +28,7 @@ const paths: Record<string, string> = {
device: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
};
</script>