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) <noreply@anthropic.com>
215 lines
7.9 KiB
TypeScript
215 lines
7.9 KiB
TypeScript
import { ref } from "vue";
|
|
import { defineStore } from "pinia";
|
|
import * as api from "@/api/rulebooks";
|
|
import type { Rulebook, RulebookTopic, Rule, RuleHeader } from "@/api/rulebooks";
|
|
import { useToastStore } from "@/stores/toast";
|
|
|
|
export const useRulebooksStore = defineStore("rulebooks", () => {
|
|
const rulebooks = ref<Rulebook[]>([]);
|
|
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
|
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
|
const currentRule = ref<Rule | null>(null);
|
|
const rulesDue = ref<api.RuleVerificationRow[]>([]);
|
|
// 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() {
|
|
loading.value = true;
|
|
try {
|
|
rulebooks.value = await api.listRulebooks();
|
|
} catch (e) {
|
|
useToastStore().show("Failed to load rulebooks", "error");
|
|
throw e;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function fetchTopics(rulebookId: number) {
|
|
try {
|
|
topicsByRulebook.value[rulebookId] = await api.listTopics(rulebookId);
|
|
} catch (e) {
|
|
useToastStore().show("Failed to load topics", "error");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function fetchRules(topicId: number) {
|
|
try {
|
|
const rules = await api.listRules({ topic_id: topicId });
|
|
rulesByTopic.value[topicId] = rules.map(toHeader);
|
|
} catch (e) {
|
|
useToastStore().show("Failed to load rules", "error");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function fetchRule(id: number) {
|
|
currentRule.value = await api.getRule(id);
|
|
}
|
|
|
|
async function createRulebook(data: { title: string; description?: string }) {
|
|
const rb = await api.createRulebook(data);
|
|
rulebooks.value.push(rb);
|
|
return rb;
|
|
}
|
|
|
|
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) {
|
|
const rb = await api.updateRulebook(id, data);
|
|
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
|
if (idx >= 0) rulebooks.value[idx] = rb;
|
|
return rb;
|
|
}
|
|
|
|
async function toggleAlwaysOn(id: number) {
|
|
const current = rulebooks.value.find((r) => r.id === id);
|
|
if (!current) return;
|
|
return updateRulebook(id, { always_on: !current.always_on });
|
|
}
|
|
|
|
async function deleteRulebook(id: number) {
|
|
await api.deleteRulebook(id);
|
|
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
|
delete topicsByRulebook.value[id];
|
|
}
|
|
|
|
async function createTopic(rulebookId: number, data: { title: string; description?: string }) {
|
|
const topic = await api.createTopic(rulebookId, data);
|
|
if (!topicsByRulebook.value[rulebookId]) topicsByRulebook.value[rulebookId] = [];
|
|
topicsByRulebook.value[rulebookId].push(topic);
|
|
return topic;
|
|
}
|
|
|
|
async function updateTopic(id: number, data: Partial<Pick<RulebookTopic, "title" | "description" | "order_index">>) {
|
|
const topic = await api.updateTopic(id, data);
|
|
for (const rbId of Object.keys(topicsByRulebook.value)) {
|
|
const list = topicsByRulebook.value[Number(rbId)];
|
|
const idx = list.findIndex((t) => t.id === id);
|
|
if (idx >= 0) list[idx] = topic;
|
|
}
|
|
return topic;
|
|
}
|
|
|
|
async function deleteTopic(id: number) {
|
|
await api.deleteTopic(id);
|
|
for (const rbId of Object.keys(topicsByRulebook.value)) {
|
|
topicsByRulebook.value[Number(rbId)] = topicsByRulebook.value[Number(rbId)].filter((t) => t.id !== id);
|
|
}
|
|
delete rulesByTopic.value[id];
|
|
}
|
|
|
|
/**
|
|
* A list row built from a full rule. The row shape is the server's
|
|
* rule_brief, so every field it carries has to be mirrored here or the two
|
|
* disagree the moment a list is patched locally instead of re-fetched.
|
|
*/
|
|
function toHeader(rule: Rule): api.RuleHeader {
|
|
return {
|
|
id: rule.id,
|
|
title: rule.title,
|
|
statement: rule.statement,
|
|
topic_id: rule.topic_id,
|
|
tier: rule.tier,
|
|
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,
|
|
};
|
|
}
|
|
|
|
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
|
|
const rule = await api.createRule(topicId, data);
|
|
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
|
rulesByTopic.value[topicId].push(toHeader(rule));
|
|
return rule;
|
|
}
|
|
|
|
async function updateRule(id: number, data: Partial<api.RuleWrite>) {
|
|
const rule = await api.updateRule(id, data);
|
|
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);
|
|
}
|
|
return rule;
|
|
}
|
|
|
|
async function relateRules(
|
|
fromRuleId: number,
|
|
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
|
|
) {
|
|
await api.relateRules(fromRuleId, data);
|
|
// Re-read rather than patching locally: the edge reads from BOTH ends, so
|
|
// the far rule's relations changed too and a local splice would show only
|
|
// half of what just happened.
|
|
await fetchRule(fromRuleId);
|
|
}
|
|
|
|
async function unrelateRules(relationId: number, refreshRuleId: number) {
|
|
await api.unrelateRules(relationId);
|
|
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;
|
|
for (const tid of Object.keys(rulesByTopic.value)) {
|
|
rulesByTopic.value[Number(tid)] = rulesByTopic.value[Number(tid)].filter((r) => r.id !== id);
|
|
}
|
|
}
|
|
|
|
return {
|
|
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,
|
|
};
|
|
});
|