feat(ui): a note's check is editable, dated and sweepable (#3167, milestone 317 step 4)
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
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
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.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The staleness sweep for NOTES: notes that assert a fact, oldest first.
|
||||
*
|
||||
* Sibling of RuleSweepPane, not a shared component — the two read differently
|
||||
* enough that merging them would mean a prop for every difference (a rule has
|
||||
* a tier and a statement; a note has a project and opens at a route). What
|
||||
* they share is the SHAPE of the judgement, and that is worth copying
|
||||
* deliberately rather than abstracting: the ordering carries urgency, "never"
|
||||
* is categorically different from a date, and a failed check writes nothing.
|
||||
*
|
||||
* Lives in the Knowledge view rather than beside the rules sweep (operator's
|
||||
* call, milestone 317 step 4): notes stay where notes live. The cost, accepted
|
||||
* knowingly, is that there is no single screen showing every record anyone has
|
||||
* left unconfirmed — /rules keeps its own.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
interface DueNote {
|
||||
id: number;
|
||||
title: string;
|
||||
project_id: number | null;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
last_verified: string | null;
|
||||
days_since_verified: number | null;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ "open-note": [id: number] }>();
|
||||
|
||||
const toast = useToastStore();
|
||||
const rows = ref<DueNote[]>([]);
|
||||
const loading = ref(false);
|
||||
const neverOnly = ref(false);
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const p = new URLSearchParams();
|
||||
if (neverOnly.value) p.set("never_only", "1");
|
||||
const data = await apiGet<{ notes: DueNote[] }>(
|
||||
`/api/notes/due-for-verification?${p}`,
|
||||
);
|
||||
rows.value = data.notes;
|
||||
} catch {
|
||||
toast.show("Could not load the sweep", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(id: number, stillTrue: boolean) {
|
||||
busyId.value = id;
|
||||
try {
|
||||
await apiPost(`/api/notes/${id}/verify`, { still_true: stillTrue });
|
||||
if (stillTrue) {
|
||||
// It has been confirmed, so it leaves the list — the sweep shows what
|
||||
// still needs looking at, and leaving it in place would invite a second
|
||||
// stamp nobody earned.
|
||||
rows.value = rows.value.filter((r) => r.id !== id);
|
||||
toast.show("Recorded — checked today");
|
||||
} else {
|
||||
// It stays. A failed check writes nothing on purpose: the note is wrong
|
||||
// rather than in a state worth recording, so it keeps its place until
|
||||
// someone corrects, supersedes, or unhooks it.
|
||||
toast.show("Recorded as no longer true — the note keeps its place here");
|
||||
}
|
||||
} catch {
|
||||
toast.show("Could not record that", "error");
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sweep">
|
||||
<header>
|
||||
<h2>Due for verification</h2>
|
||||
<p class="lede">
|
||||
Notes that assert a fact about something outside your control — what a
|
||||
service does, how a tool behaves. Most notes are decisions and never
|
||||
appear here; they have no truth value to go stale.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<label class="filter">
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="state">Loading…</p>
|
||||
|
||||
<!-- An empty sweep is GOOD NEWS and must not read like a broken page. -->
|
||||
<p v-else-if="!rows.length" class="state empty">
|
||||
Nothing to check.
|
||||
{{ neverOnly
|
||||
? "Every note that carries a check has been confirmed at least once."
|
||||
: "No note carries a check yet — add one to a note that asserts a fact." }}
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="n in rows" :key="n.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-note', n.id)">{{ n.title }}</button>
|
||||
<span class="age" :class="{ unchecked: n.days_since_verified === null }">
|
||||
{{ n.days_since_verified === null
|
||||
? "never checked"
|
||||
: `${n.days_since_verified}d ago` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="check">
|
||||
<dt>Check</dt>
|
||||
<dd>{{ n.verify_with }}</dd>
|
||||
<template v-if="n.expires_when">
|
||||
<dt>Ends when</dt>
|
||||
<dd>{{ n.expires_when }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="busyId === n.id" @click="verify(n.id, true)">Still true</button>
|
||||
<button :disabled="busyId === n.id" @click="verify(n.id, false)">No longer true</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="rows.length" class="footnote">
|
||||
Record a result only after actually running the check. “No longer true” stores nothing
|
||||
on purpose — the note is wrong rather than in a state worth recording, so it keeps its
|
||||
place here until you correct it, supersede it, or remove its check.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
h2 { margin: 0; font-size: 1.05rem; }
|
||||
.lede {
|
||||
margin: 0.35rem 0 0;
|
||||
max-width: 62ch;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
|
||||
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
|
||||
|
||||
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.row {
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
}
|
||||
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.row-title {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
|
||||
color: var(--fs-text-primary); text-align: left;
|
||||
}
|
||||
.row-title:hover { text-decoration: underline; }
|
||||
/* The ORDER carries urgency — the top of this list is the least-confirmed
|
||||
thing in the corpus. No red/amber ramp: it would restate the ordering and
|
||||
force an invented "stale after N days" threshold. "Never" is marked because
|
||||
it is categorically DIFFERENT from a date, not a worse one. */
|
||||
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
|
||||
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
|
||||
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
|
||||
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
|
||||
.actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user