From c83bedf3bea68d19cba6ed729ebc6301b8853778 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 27 Aug 2026 11:53:35 -0400 Subject: [PATCH] feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 27 — the milestone was backend-only until this. Four surfaces: RULE EDITOR — verify_with and expires_when under a legend that asks the actual question ("Can this rule go stale?") and says empty is the normal answer, because most rules are decisions and a form that implies a missing field would get them filled in out of tidiness. When the SAVED rule carries a check, the stamp shows with Still true / No longer true beside it. The stamp reads the stored value, not the draft: an unsaved edit to the textarea has not been run against anything. SWEEP PANE — its own surface, not a filter on the rule list. That list can only ever show one topic of one rulebook, and a rule that has gone false belongs to no one rulebook; filtering it would under-report, which is the failure this whole surface exists to catch. Reached from the rulebook list, below the rulebooks, because that is where you go to look at rules. RULE ROWS — a chip only on rules carrying a check, so its presence is the signal. PROJECT RULES TAB — the check shows beside `why` when a rule has one, read-only: that tab is the project's view of what binds it. NO AGE-GRADED COLOUR anywhere, deliberately. The sweep is already ordered by urgency, so a red/amber ramp would restate the ordering AND require an invented "stale after N days" threshold — a magic number nobody could defend and the first thing to go out of date. --fs-overdue is error red and reserved for a broken promise like a missed due date; a verification age is not one, and colouring it that way makes a rule someone just wrote look broken. Only "never" is marked, because it is categorically different from a date rather than a worse one — and it is marked by weight, not hue. An empty sweep says "Nothing to check", not nothing: good news must not read as a broken page. Two chips (tier, then verification) turned out byte-identical, so .rule-chip moves to rules-shared.css and snippet #2906 is updated to match rather than left describing a file that has moved on. Its header comment counted the panes it served; that count went stale the moment a fourth arrived, so it no longer counts. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/assets/rules-shared.css | 22 ++- .../src/components/rules/ProjectRulesTab.vue | 35 +++- .../components/rules/RuleEditorSlideOver.vue | 100 ++++++++++ .../src/components/rules/RuleListPane.vue | 31 +-- .../src/components/rules/RuleSweepPane.vue | 180 ++++++++++++++++++ .../src/components/rules/RulebookListPane.vue | 25 ++- frontend/src/stores/rulebooks.ts | 50 ++++- frontend/src/views/RulesView.vue | 25 ++- 8 files changed, 447 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/rules/RuleSweepPane.vue diff --git a/frontend/src/assets/rules-shared.css b/frontend/src/assets/rules-shared.css index 9efc62b..72c1448 100644 --- a/frontend/src/assets/rules-shared.css +++ b/frontend/src/assets/rules-shared.css @@ -1,5 +1,7 @@ -/* Shared by the three rules panes (RulebookListPane, RuleListPane, - RulebookDetailPane): the pane surface and its heading. Load with +/* Shared by the rules panes (RulebookListPane, RuleListPane, + RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the + title chip. Counting them in this comment went stale the first time a + fourth was added, so it no longer does. Load with diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue index 78ba14a..6c2a662 100644 --- a/frontend/src/components/rules/RuleListPane.vue +++ b/frontend/src/components/rules/RuleListPane.vue @@ -17,7 +17,17 @@ const emit = defineEmits<{ {{ r.title }} - conditional + conditional + + {{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}
{{ r.statement }}
@@ -47,16 +57,13 @@ li:hover { background: var(--fs-surface-hover); } .meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; } .trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; } -.tier-chip { - margin-left: 0.4rem; - font-family: var(--fs-font-body); - font-style: normal; - font-size: 0.62rem; - color: var(--fs-text-secondary); - background: var(--fs-surface-raised); - border-radius: var(--fs-radius-pill); - padding: 0.05rem 0.4rem; - vertical-align: middle; -} +/* Only the departures from .rule-chip (rules-shared.css) live here. */ +.check-chip { font-variant-numeric: tabular-nums; } +/* No age-graded colour on purpose. The sweep is already ordered by urgency, so + a red/amber ramp would restate the ordering AND require an invented "stale + after N days" threshold — a magic number nobody could defend and the first + thing to go out of date. Only "never" is marked, because it is categorically + different from a date rather than a worse one. */ +.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); } .new-rule { cursor: pointer; } diff --git a/frontend/src/components/rules/RuleSweepPane.vue b/frontend/src/components/rules/RuleSweepPane.vue new file mode 100644 index 0000000..f072500 --- /dev/null +++ b/frontend/src/components/rules/RuleSweepPane.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/frontend/src/components/rules/RulebookListPane.vue b/frontend/src/components/rules/RulebookListPane.vue index b808c3d..f76effe 100644 --- a/frontend/src/components/rules/RulebookListPane.vue +++ b/frontend/src/components/rules/RulebookListPane.vue @@ -3,8 +3,8 @@ import { ref } from "vue"; import { useRulebooksStore } from "@/stores/rulebooks"; import type { Rulebook } from "@/api/rulebooks"; -defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>(); -const emit = defineEmits<{ select: [id: number] }>(); +defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>(); +const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>(); const store = useRulebooksStore(); const isCreating = ref(false); @@ -34,6 +34,18 @@ async function submitNew() { always on + + +
@@ -63,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); } color: var(--fs-text-on-action); margin-left: auto; } +.sweep-entry { + display: block; width: 100%; text-align: left; + margin-top: var(--fs-space-3); + padding: 0.5rem; border-radius: 6px; + background: none; border: 1px dashed var(--fs-border-color); + color: var(--fs-text-secondary); font: inherit; cursor: pointer; +} +.sweep-entry:hover { background: var(--fs-surface-hover); } +.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); } .new-rulebook { margin-top: 1rem; } .new-rulebook input { width: 100%; margin-bottom: 0.5rem; diff --git a/frontend/src/stores/rulebooks.ts b/frontend/src/stores/rulebooks.ts index cfaa8ea..a02be56 100644 --- a/frontend/src/stores/rulebooks.ts +++ b/frontend/src/stores/rulebooks.ts @@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => { const topicsByRulebook = ref>({}); const rulesByTopic = ref>({}); const currentRule = ref(null); + const rulesDue = ref([]); + // Kept so a verify re-reads the sweep with the SAME filters the operator is + // looking at — re-fetching unfiltered would silently widen the list under + // them at the moment they acted on it. + const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({}); const loading = ref(false); async function fetchRulebooks() { @@ -111,6 +116,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => { updated_at: rule.updated_at, when_to_apply: rule.when_to_apply || undefined, arose_from_id: rule.arose_from_id ?? undefined, + // Mirrors services.rulebooks.last_verified_label: present ONLY when the + // rule carries a check, and "never" rather than absent when it has one + // nobody has run. Computed here so a row just written looks identical to + // the same row re-fetched, instead of losing its chip until a reload. + last_verified: rule.verify_with + ? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never") + : undefined, }; } @@ -148,6 +160,41 @@ export const useRulebooksStore = defineStore("rulebooks", () => { await fetchRule(refreshRuleId); } + /** The staleness sweep: rules asserting a fact, oldest verification first. */ + async function fetchRulesDue(opts: { + olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean; + } = {}) { + loading.value = true; + lastSweepOpts.value = opts; + try { + const data = await api.listRulesDueForVerification(opts); + rulesDue.value = data.rules; + } finally { + loading.value = false; + } + } + + /** + * Record that a rule's check was RUN, and what it said. + * + * A pass re-sorts the row to the back of the sweep, so the list is re-read + * rather than patched: the whole point of this surface is an ORDER, and a + * locally-mutated row would sit in its old position claiming a new date. + * A failure writes nothing server-side and the row keeps its place — also + * correct, and also what a re-read shows. + */ + async function verifyRule(id: number, stillTrue: boolean) { + const rule = await api.markRuleVerified(id, stillTrue); + if (currentRule.value?.id === id) currentRule.value = rule; + for (const tid of Object.keys(rulesByTopic.value)) { + const list = rulesByTopic.value[Number(tid)]; + const idx = list.findIndex((r) => r.id === id); + if (idx >= 0) list[idx] = toHeader(rule); + } + if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value); + return rule; + } + async function deleteRule(id: number) { await api.deleteRule(id); if (currentRule.value?.id === id) currentRule.value = null; @@ -157,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => { } return { - rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading, + rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading, fetchRulebooks, fetchTopics, fetchRules, fetchRule, createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook, createTopic, updateTopic, deleteTopic, createRule, updateRule, deleteRule, relateRules, unrelateRules, + fetchRulesDue, verifyRule, }; }); diff --git a/frontend/src/views/RulesView.vue b/frontend/src/views/RulesView.vue index 1236267..c201e2e 100644 --- a/frontend/src/views/RulesView.vue +++ b/frontend/src/views/RulesView.vue @@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue"; import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue"; import RuleListPane from "@/components/rules/RuleListPane.vue"; import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue"; +import RuleSweepPane from "@/components/rules/RuleSweepPane.vue"; const store = useRulebooksStore(); const route = useRoute(); @@ -15,6 +16,7 @@ const selectedRulebookId = ref(null); const selectedTopicId = ref(null); const editingRuleId = ref(null); const creatingRuleForTopic = ref(null); +const sweepActive = ref(false); function syncFromRoute() { const rb = route.query.rb ? Number(route.query.rb) : null; @@ -23,9 +25,20 @@ function syncFromRoute() { selectedRulebookId.value = rb; selectedTopicId.value = topic; editingRuleId.value = rule; + sweepActive.value = route.query.view === "due"; +} + +function selectSweep() { + sweepActive.value = true; + // Keeps ?rule=… so the editor survives the mode switch, and drops the + // rulebook/topic selection the sweep does not use. + const { rb, topic, ...rest } = route.query; + void rb; void topic; + router.replace({ query: { ...rest, view: "due" } }); } function selectRulebook(id: number) { + sweepActive.value = false; selectedRulebookId.value = id; selectedTopicId.value = null; router.replace({ query: { rb: String(id) } }); @@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute); + route.query, syncFromRoute);

Select a rulebook to view its topics.

-
+

Select a topic to view its rules.

route.query, syncFromRoute); gap: 1px; background: var(--fs-border-color); } +/* The sweep is cross-cutting, so it takes the width the rulebook + topic + panes would have used rather than being squeezed into one column. */ +.sweep-span { grid-column: 2 / -1; } .pane.empty { background: var(--fs-surface-hover); padding: 1rem;