Files
FabledScribe/frontend/src/stores/notes.ts
T
bvandeusen 1ec44071d2
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 1m1s
feat(ui): a note's check is editable, dated and sweepable (#3167, milestone 317 step 4)
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.
2026-08-28 22:04:00 -04:00

142 lines
3.7 KiB
TypeScript

import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Note } from "@/types/note";
// Single-note + mutation surface. The list/filter/sort/pagination surface that
// backed the removed /notes list view was dropped in the 2026-06-02 drift-audit
// cleanup (no consumers — KnowledgeView and the editors use single-entity and
// mutation methods only).
export const useNotesStore = defineStore("notes", () => {
const currentNote = ref<Note | null>(null);
const loading = ref(false);
async function fetchNote(id: number) {
loading.value = true;
try {
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
} catch (e) {
useToastStore().show("Failed to load note", "error");
throw e;
} finally {
loading.value = false;
}
}
async function createNote(data: {
title: string;
body: string;
tags?: string[];
project_id?: number | null;
milestone_id?: number | null;
note_type?: string;
verify_with?: string;
expires_when?: string;
}): Promise<Note> {
try {
return await apiPost<Note>("/api/notes", data);
} catch (e) {
useToastStore().show("Failed to create note", "error");
throw e;
}
}
async function updateNote(
id: number,
data: Partial<Pick<
Note,
"title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type"
| "verify_with" | "expires_when"
>>
): Promise<Note> {
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
if (currentNote.value?.id === id) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to update note", "error");
throw e;
}
}
async function deleteNote(id: number) {
try {
await apiDelete(`/api/notes/${id}`);
if (currentNote.value?.id === id) {
currentNote.value = null;
}
} catch (e) {
useToastStore().show("Failed to delete note", "error");
throw e;
}
}
async function resolveTitle(title: string): Promise<Note> {
return await apiPost<Note>("/api/notes/resolve-title", { title });
}
async function convertToTask(noteId: number): Promise<Note> {
try {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-task`,
{}
);
if (currentNote.value?.id === noteId) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to convert to task", "error");
throw e;
}
}
async function convertToNote(noteId: number): Promise<Note> {
try {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-note`,
{}
);
if (currentNote.value?.id === noteId) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to convert to note", "error");
throw e;
}
}
async function fetchBacklinks(
noteId: number
): Promise<{ type: string; id: number; title: string }[]> {
const data = await apiGet<{
backlinks: { type: string; id: number; title: string }[];
}>(`/api/notes/${noteId}/backlinks`);
return data.backlinks;
}
async function fetchAllTags(q?: string): Promise<string[]> {
const params = q ? `?q=${encodeURIComponent(q)}` : "";
const data = await apiGet<{ tags: string[] }>(`/api/notes/tags${params}`);
return data.tags;
}
return {
currentNote,
loading,
fetchNote,
createNote,
updateNote,
deleteNote,
resolveTitle,
convertToTask,
convertToNote,
fetchBacklinks,
fetchAllTags,
};
});