From 1ec44071d24bab0b8e91079950e5def342b6f977 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 28 Aug 2026 22:04:00 -0400 Subject: [PATCH] feat(ui): a note's check is editable, dated and sweepable (#3167, milestone 317 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 27: no UI, no ship. Three surfaces. THE EDITOR ASKS, but only where the answer can be saved: the fields appear for a plain note and not for a task or a snippet, matching the service gate from step 2 so the form never offers a write the save would reject. The labels are phrased as the QUESTION rather than the field name — "how would someone check this is still true?" and, underneath, "could this become false without anyone editing it?". "Verify with" gets filled in on every note; the question gets filled in on the few that can go stale. `expires_when` appears only once a check exists, and asks for a state rather than a date in the placeholder itself. THE NOTE SHOWS ITS AGE beside the field — "checked 2026-08-28" or "never checked", italic, and nothing at all when no check exists. No red/amber ramp, matching RuleSweepPane: a colour scale would restate the sweep's ordering and force an invented staleness threshold. "Never" is marked because it is categorically different from a date, not a worse one. THE SWEEP is a pane in the Knowledge view, not beside the rules sweep — operator's call, taken over a unified "everything due" surface and over a second pane under /rules. Notes stay where notes live. The cost, accepted knowingly: no single screen shows every unconfirmed record. It REPLACES the feed rather than filtering it, because a facet answers "show me this kind" and this answers "show me what nobody has confirmed" — a question the type chips cannot narrow without under-reporting. Two REST routes for it, since step 3 built only the service and the MCP door. Along the way: NoteEditorView spelled its write payload out at three call sites (save, create, auto-save), so every new field had to be added three times — which is how one of them ends up not carrying it. Now one `payload()` and one `snapshot()`. Known and filed, not fixed: NoteSweepPane copies ~12 scoped CSS rules from RuleSweepPane (#3207). The clean extraction needs prefixed names, because `.age`, `.row-title`, `.lede` and `.actions` all exist scoped in other components and an unscoped global would leak into them — which means editing the shipped rules sweep, blind, inside a step whose acceptance is the operator looking at a different surface. --- frontend/src/components/NoteSweepPane.vue | 198 ++++++++++++++++++++++ frontend/src/stores/notes.ts | 8 +- frontend/src/types/note.ts | 9 + frontend/src/views/KnowledgeView.vue | 35 ++++ frontend/src/views/NoteEditorView.vue | 154 ++++++++++++----- src/scribe/routes/notes.py | 62 +++++++ 6 files changed, 421 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/NoteSweepPane.vue diff --git a/frontend/src/components/NoteSweepPane.vue b/frontend/src/components/NoteSweepPane.vue new file mode 100644 index 0000000..cd18eb9 --- /dev/null +++ b/frontend/src/components/NoteSweepPane.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 0c00d75..42be645 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -31,6 +31,8 @@ export const useNotesStore = defineStore("notes", () => { project_id?: number | null; milestone_id?: number | null; note_type?: string; + verify_with?: string; + expires_when?: string; }): Promise { try { return await apiPost("/api/notes", data); @@ -42,7 +44,11 @@ export const useNotesStore = defineStore("notes", () => { async function updateNote( id: number, - data: Partial> + data: Partial> ): Promise { try { const note = await apiPut(`/api/notes/${id}`, data); diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index c11a9d3..2850ea3 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -34,6 +34,15 @@ export interface Note { is_task: boolean; note_type: NoteType; task_kind?: TaskKind; + // The note's own check (milestone 317). Empty on almost every note — that + // is the normal case: a note with no `verify_with` is a DECISION, and there + // is nothing to go and check. Only a note asserting a fact about something + // outside the operator's control carries one. `verified_at` null while + // `verify_with` is set means NOBODY HAS EVER CONFIRMED IT, which is the + // state the sweep ranks first. + verify_with?: string; + expires_when?: string; + verified_at?: string | null; systems?: System[]; arose_from_id?: number | null; created_at: string; diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 281da8b..61ca964 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -4,6 +4,7 @@ import { useRouter } from "vue-router"; import { apiGet } from "@/api/client"; import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note"; import KindBadge from "@/components/KindBadge.vue"; +import NoteSweepPane from "@/components/NoteSweepPane.vue"; import StatusBadge from "@/components/StatusBadge.vue"; import PriorityBadge from "@/components/PriorityBadge.vue"; import GraphView from "@/views/GraphView.vue"; @@ -13,6 +14,7 @@ import { Workflow, Search, Share2, + ShieldCheck, ChevronLeft, ChevronRight, X, @@ -67,6 +69,13 @@ const FACET_CHIPS: [Exclude, string][] = [ ["process", "Processes"], ]; +// ─── View mode ──────────────────────────────────────────────────────────────── +// The sweep is cross-cutting — a note that has gone false does not care which +// facet it sits under — so it REPLACES the browse list rather than filtering +// it. Filtering would mean the answer depended on which chip was active, which +// is the under-reporting the sweep exists to prevent (milestone 317 step 4). +const sweepActive = ref(false); + // ─── Filter state ───────────────────────────────────────────────────────────── const activeType = ref(""); @@ -263,6 +272,10 @@ function onSearchInput() { } watch([activeType, sortMode], () => resetAndReobserve()); +// Closing the sweep remounts the feed, and with it the scroll sentinel — a +// fresh element the old observer is not watching. Without this the list loads +// its first page and then never loads another. +watch(sweepActive, (open) => { if (!open) resetAndReobserve(); }); watch(activeTag, () => { fetchCounts(); resetAndReobserve(); }); // ─── Today bar ──────────────────────────────────────────────────────────────── @@ -471,6 +484,15 @@ onUnmounted(() => { Graph + + + + + diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 620467b..33bff3b 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -34,6 +34,14 @@ const tags = ref([]); const projectId = ref(null); const milestoneId = ref(null); const noteType = ref("note"); + +// The note's own check (milestone 317). Offered only for a plain note: a +// task's decay is its status, and a snippet has verify_snippet — the service +// refuses both, so the form must not ask for what the save would reject. +const verifyWith = ref(""); +const expiresWhen = ref(""); +const verifiedAt = ref(null); +const canCarryCheck = computed(() => noteType.value === "note"); const dirty = ref(false); const saving = ref(false); const showPreview = ref(false); @@ -198,6 +206,41 @@ let savedTags: string[] = []; let savedProjectId: number | null = null; let savedMilestoneId: number | null = null; let savedNoteType: NoteType = "note"; +let savedVerifyWith = ""; +let savedExpiresWhen = ""; + +/** The write, in one place. Three call sites (save, create, auto-save) each + * spelled this out, so every new field had to be added three times — which is + * how one of them ends up not carrying it. */ +function payload() { + return { + title: title.value, + body: body.value, + tags: tags.value, + project_id: projectId.value, + milestone_id: milestoneId.value, + note_type: noteType.value, + // "" clears the check: the REST door reads an empty string as NULL + // (NULLABLE_NOTE_TEXT), which is how a cleared form input says "remove + // this" without needing the MCP door's explicit `clear` list. + verify_with: canCarryCheck.value ? verifyWith.value : "", + expires_when: canCarryCheck.value ? expiresWhen.value : "", + }; +} + +/** What the form last agreed with the server about — the other half of the + * same list, and for the same reason. */ +function snapshot() { + savedTitle = title.value; + savedBody = body.value; + savedTags = [...tags.value]; + savedProjectId = projectId.value; + savedMilestoneId = milestoneId.value; + savedNoteType = noteType.value; + savedVerifyWith = verifyWith.value; + savedExpiresWhen = expiresWhen.value; + dirty.value = false; +} function markDirty() { dirty.value = @@ -206,7 +249,9 @@ function markDirty() { JSON.stringify(tags.value) !== JSON.stringify(savedTags) || projectId.value !== savedProjectId || milestoneId.value !== savedMilestoneId || - noteType.value !== savedNoteType; + noteType.value !== savedNoteType || + verifyWith.value !== savedVerifyWith || + expiresWhen.value !== savedExpiresWhen; } function onBodyUpdate(newVal: string) { @@ -224,12 +269,10 @@ onMounted(async () => { projectId.value = store.currentNote.project_id ?? null; milestoneId.value = store.currentNote.milestone_id ?? null; noteType.value = (store.currentNote.note_type as NoteType) || "note"; - savedTitle = title.value; - savedBody = body.value; - savedTags = [...tags.value]; - savedProjectId = projectId.value; - savedMilestoneId = milestoneId.value; - savedNoteType = noteType.value; + verifyWith.value = store.currentNote.verify_with || ""; + expiresWhen.value = store.currentNote.expires_when || ""; + verifiedAt.value = store.currentNote.verified_at ?? null; + snapshot(); } } else { // New note: read type from query param @@ -260,31 +303,11 @@ async function save() { const finalBody = body.value; try { if (isEditing.value) { - await store.updateNote(noteId.value!, { - title: title.value, - body: finalBody, - tags: tags.value, - project_id: projectId.value, - milestone_id: milestoneId.value, - note_type: noteType.value, - }); - savedTitle = title.value; - savedBody = body.value; - savedTags = [...tags.value]; - savedProjectId = projectId.value; - savedMilestoneId = milestoneId.value; - savedNoteType = noteType.value; - dirty.value = false; + await store.updateNote(noteId.value!, { ...payload(), body: finalBody }); + snapshot(); toast.show("Note saved"); } else { - const note = await store.createNote({ - title: title.value, - body: finalBody, - tags: tags.value, - project_id: projectId.value, - milestone_id: milestoneId.value, - note_type: noteType.value, - }); + const note = await store.createNote({ ...payload(), body: finalBody }); dirty.value = false; toast.show("Note created"); router.push(`/notes/${note.id}`); @@ -321,18 +344,8 @@ async function doAutoSave() { saving.value = true; const finalBody = body.value; try { - await store.updateNote(noteId.value!, { - title: title.value, body: finalBody, tags: tags.value, - project_id: projectId.value, milestone_id: milestoneId.value, - note_type: noteType.value, - }); - savedTitle = title.value; - savedBody = body.value; - savedTags = [...tags.value]; - savedProjectId = projectId.value; - savedMilestoneId = milestoneId.value; - savedNoteType = noteType.value; - dirty.value = false; + await store.updateNote(noteId.value!, { ...payload(), body: finalBody }); + snapshot(); toast.show("Auto-saved"); } catch { // Silent @@ -496,6 +509,41 @@ onUnmounted(() => assist.clearSelection()); + + +