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()); + + +