feat(rules): the rule editor asks when it applies, and the list shows its age (#3029, milestone 307 step 3, UI)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Failing after 27s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Skipped

Rule 27 — the schema and both doors shipped with no human surface, so step 3
was not shippable until this.

RuleEditorSlideOver gains the trigger, the tier, the areas, and a read-only
view of the rule's edges. The tier is a radio pair carrying the test itself
rather than a bare toggle: can you name the trigger WITHOUT naming a system,
an artifact type or a moment? If the honest answer is "whenever you are
working", it is always on. It also says why conditional is not a demotion —
it costs nothing when irrelevant, which is what lets a rule be as long as it
needs to be. The relations block states the rule the whole milestone turns on:
rules that FAIL TOGETHER are linked, never merged.

RuleListPane shows the trigger and the LAST-CHANGED DATE on every row, and
marks conditional only — always_on is the default and badging every row would
say nothing. The date is the cheap triage the FabledCurator case wanted: a
rule whose age predates the capability it duplicates is visible at a glance
instead of needing a get_rule to find out.

ProjectRulesTab's inline create form gains the same two fields, because a
project rule bloats exactly the way a family one does — FabledCurator has 23
of them.

Two type fixes the new shapes forced, both worth keeping:
- toHeader() in the store: a list row is the server's rule_brief, so patching
  a list locally has to mirror every field it carries or the two disagree.
  There were two hand-built four-field literals doing that job.
- ApplicableRules.rules / .project_rules are now described AS RuleHeader
  rather than as two more hand-written shapes — the same builder produces
  them, so the same type should describe them.

groupByRulebookAndTopic skips a null-topic rule rather than widening
TopicGroup to accept one: a rule carries topic_id XOR project_id, so a null
topic in that list means something is wrong upstream, and a widened type would
hide it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 14:23:40 -04:00
co-authored by Claude Opus 5
parent ffb7a0fe38
commit 8b60d552d2
5 changed files with 302 additions and 33 deletions
+76 -14
View File
@@ -1,5 +1,24 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** How a rule reaches a session (milestone 307). */
export type RuleTier = "always_on" | "conditional";
/**
* A typed edge between two rules. Each kind exists because its absence forced
* a workaround: merging two rules into one row, writing an override as a
* near-copy, or leaving a local addendum with nothing to say it is one.
*/
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
export interface RuleRelation {
id: number;
kind: RuleRelationKind;
/** The rule at the OTHER end. */
rule_id: number;
direction: "outgoing" | "incoming";
note: string;
}
export interface Rulebook { export interface Rulebook {
id: number; id: number;
owner_user_id: number; owner_user_id: number;
@@ -26,35 +45,53 @@ export interface Rule {
project_id: number | null; project_id: number | null;
title: string; title: string;
statement: string; statement: string;
/** WHEN this rule fires — the trigger, not the instruction. */
when_to_apply: string;
/**
* always_on preloads into every session; conditional is reachable and
* surfaced when its trigger fires. A rule with no tier set behaves as
* always_on, which is how every rule behaved before this existed.
*/
tier: RuleTier;
why: string; why: string;
how_to_apply: string; how_to_apply: string;
/** The note or task that caused this rule, if one was recorded. */
arose_from_id: number | null;
order_index: number; order_index: number;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
/** Present only when the rule has them (the server omits empty keys). */
systems?: { id: number; name: string }[];
relations?: RuleRelation[];
} }
/**
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
* age deliberately: a rule written before the capability it duplicates is
* otherwise indistinguishable, at a glance, from one still doing work.
*/
export interface RuleHeader { export interface RuleHeader {
id: number; id: number;
title: string; title: string;
statement: string; statement: string;
topic_id: number | null; topic_id: number | null;
tier: RuleTier;
/** A date (YYYY-MM-DD), not a timestamp. */
updated_at: string | null;
when_to_apply?: string;
arose_from_id?: number;
} }
export interface ApplicableRules { export interface ApplicableRules {
rules: { // Both lists are rule_brief's output — the SAME builder, so they are
id: number; // described the same way here rather than as two hand-written shapes that
title: string; // drift from it and from each other (which is what the server side had).
statement: string; rules: (RuleHeader & {
topic_id: number;
topic_title: string; topic_title: string;
rulebook_id: number; rulebook_id: number;
rulebook_title: string; rulebook_title: string;
}[]; })[];
project_rules: { project_rules: RuleHeader[];
id: number;
title: string;
statement: string;
}[];
suppressed_rules: { suppressed_rules: {
id: number; id: number;
title: string; title: string;
@@ -133,14 +170,39 @@ export async function getRule(id: number): Promise<Rule> {
return apiGet(`/api/rules/${id}`); return apiGet(`/api/rules/${id}`);
} }
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> { /** The fields both write paths accept. `system_ids` REPLACES a rule's areas. */
export interface RuleWrite {
title: string;
statement: string;
when_to_apply: string;
tier: RuleTier;
why: string;
how_to_apply: string;
order_index: number;
system_ids: number[];
arose_from_id: number | null;
}
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data); return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
} }
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> { export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
return apiPatch(`/api/rules/${id}`, data); return apiPatch(`/api/rules/${id}`, data);
} }
/** Draw a typed edge from one rule to another. Idempotent. */
export async function relateRules(
fromRuleId: number,
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
): Promise<{ id: number }> {
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
}
export async function unrelateRules(relationId: number): Promise<void> {
return apiDelete(`/api/rule-relations/${relationId}`);
}
export async function deleteRule(id: number): Promise<void> { export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`); return apiDelete(`/api/rules/${id}`);
} }
@@ -161,7 +223,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
export async function createProjectRule( export async function createProjectRule(
projectId: number, projectId: number,
data: { statement: string; title?: string; why?: string; how_to_apply?: string }, data: Partial<RuleWrite> & { statement: string },
): Promise<Rule> { ): Promise<Rule> {
return apiPost(`/api/projects/${projectId}/rules`, data); return apiPost(`/api/projects/${projectId}/rules`, data);
} }
@@ -27,7 +27,10 @@ const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({}); const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
const showProjectRuleForm = ref(false); const showProjectRuleForm = ref(false);
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" }); const newProjectRule = ref({
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
});
async function load() { async function load() {
applicable.value = await getProjectApplicableRules(props.projectId); applicable.value = await getProjectApplicableRules(props.projectId);
@@ -90,14 +93,20 @@ interface RulebookGroup {
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] { function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
const byRulebook = new Map<number, RulebookGroup>(); const byRulebook = new Map<number, RulebookGroup>();
for (const r of rules) { for (const r of rules) {
// A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach
// this list, so a null topic would be a server-side contradiction — skip
// it rather than widen the group's type to accommodate a case that means
// something is wrong upstream.
if (r.topic_id === null) continue;
const topicId = r.topic_id;
let rb = byRulebook.get(r.rulebook_id); let rb = byRulebook.get(r.rulebook_id);
if (!rb) { if (!rb) {
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] }; rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
byRulebook.set(r.rulebook_id, rb); byRulebook.set(r.rulebook_id, rb);
} }
let topic = rb.topics.find((t) => t.topic_id === r.topic_id); let topic = rb.topics.find((t) => t.topic_id === topicId);
if (!topic) { if (!topic) {
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] }; topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] };
rb.topics.push(topic); rb.topics.push(topic);
} }
topic.rules.push(r); topic.rules.push(r);
@@ -113,8 +122,13 @@ async function submitProjectRule() {
title: newProjectRule.value.title.trim() || undefined, title: newProjectRule.value.title.trim() || undefined,
why: newProjectRule.value.why.trim() || undefined, why: newProjectRule.value.why.trim() || undefined,
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined, how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
tier: newProjectRule.value.tier,
}); });
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" }; newProjectRule.value = {
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "", tier: "always_on",
};
showProjectRuleForm.value = false; showProjectRuleForm.value = false;
await load(); await load();
} }
@@ -219,6 +233,24 @@ watch(() => props.projectId, load);
placeholder="Statement (required) — the actionable instruction, 1-2 sentences" placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
rows="2" rows="2"
></textarea> ></textarea>
<textarea
v-model="newProjectRule.when_to_apply"
placeholder="When to apply — the trigger, not the instruction"
rows="2"
></textarea>
<div class="tier-row">
<label>
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
Always on
</label>
<label>
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
Conditional
</label>
<span class="tier-hint">
Conditional if you had to name a system, an artifact or a moment to state the trigger.
</span>
</div>
<textarea <textarea
v-model="newProjectRule.why" v-model="newProjectRule.why"
placeholder="Why (optional) — the rationale" placeholder="Why (optional) — the rationale"
@@ -345,6 +377,11 @@ watch(() => props.projectId, load);
</template> </template>
<style scoped> <style scoped>
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
.tier-row input { accent-color: var(--fs-accent); }
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; } .excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.chip-excluded { opacity: 0.8; text-decoration: line-through; } .chip-excluded { opacity: 0.8; text-decoration: line-through; }
.chip-excluded .chip-remove { text-decoration: none; } .chip-excluded .chip-remove { text-decoration: none; }
@@ -1,16 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from "vue"; import { computed, ref, watch, onMounted } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks"; import { useRulebooksStore } from "@/stores/rulebooks";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import type { RuleTier } from "@/api/rulebooks";
const props = defineProps<{ ruleId: number | null; topicId: number | null }>(); const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
const store = useRulebooksStore(); const store = useRulebooksStore();
const canon = useCanonicalSystemsStore();
const title = ref(""); const title = ref("");
const statement = ref(""); const statement = ref("");
const whenToApply = ref("");
const tier = ref<RuleTier>("always_on");
const systemIds = ref<number[]>([]);
const why = ref(""); const why = ref("");
const howToApply = ref(""); const howToApply = ref("");
const relations = computed(() => store.currentRule?.relations ?? []);
// The label a reader needs to judge an edge, not the stored token.
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
overrides: { outgoing: "overrides", incoming: "is overridden by" },
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
};
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
return RELATION_LABEL[kind]?.[direction] ?? kind;
}
function toggleSystem(id: number) {
const at = systemIds.value.indexOf(id);
if (at >= 0) systemIds.value.splice(at, 1);
else systemIds.value.push(id);
}
const isCreating = ref(props.ruleId === null); const isCreating = ref(props.ruleId === null);
async function load() { async function load() {
@@ -20,15 +45,22 @@ async function load() {
if (r) { if (r) {
title.value = r.title; title.value = r.title;
statement.value = r.statement; statement.value = r.statement;
whenToApply.value = r.when_to_apply || "";
tier.value = r.tier || "always_on";
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
why.value = r.why || ""; why.value = r.why || "";
howToApply.value = r.how_to_apply || ""; howToApply.value = r.how_to_apply || "";
} }
} else { } else {
title.value = ""; title.value = "";
statement.value = ""; statement.value = "";
whenToApply.value = "";
tier.value = "always_on";
systemIds.value = [];
why.value = ""; why.value = "";
howToApply.value = ""; howToApply.value = "";
} }
await canon.fetchCatalog();
} }
async function save() { async function save() {
@@ -36,16 +68,21 @@ async function save() {
emit("close"); emit("close");
return; return;
} }
const fields = {
title: title.value,
statement: statement.value,
when_to_apply: whenToApply.value,
tier: tier.value,
// Always sent, so clearing the last area actually clears it — the server
// reads a list as "these ARE the areas now".
system_ids: systemIds.value,
why: why.value,
how_to_apply: howToApply.value,
};
if (isCreating.value && props.topicId !== null) { if (isCreating.value && props.topicId !== null) {
await store.createRule(props.topicId, { await store.createRule(props.topicId, fields);
title: title.value, statement: statement.value,
why: why.value, how_to_apply: howToApply.value,
});
} else if (props.ruleId !== null) { } else if (props.ruleId !== null) {
await store.updateRule(props.ruleId, { await store.updateRule(props.ruleId, fields);
title: title.value, statement: statement.value,
why: why.value, how_to_apply: howToApply.value,
});
} }
emit("close"); emit("close");
} }
@@ -77,6 +114,69 @@ watch(() => props.ruleId, load);
Statement <span class="required">*</span> Statement <span class="required">*</span>
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." /> <textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
</label> </label>
<label>
When to apply
<textarea
v-model="whenToApply"
rows="2"
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
/>
</label>
<fieldset class="tier">
<legend>How it reaches a session</legend>
<label class="tier-opt">
<input v-model="tier" type="radio" value="always_on" />
<span>
<strong>Always on</strong>
loaded into every session.
</span>
</label>
<label class="tier-opt">
<input v-model="tier" type="radio" value="conditional" />
<span>
<strong>Conditional</strong>
arrives when its trigger fires.
</span>
</label>
<p class="tier-test">
The test: can you name the trigger <em>without</em> naming a system, an artifact type
or a moment? If the honest answer is whenever you are working, it is always on.
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
it needs to be.
</p>
</fieldset>
<fieldset v-if="canon.catalog.length" class="areas">
<legend>Areas this rule is about</legend>
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
<input
type="checkbox"
:checked="systemIds.includes(entry.id)"
@change="toggleSystem(entry.id)"
/>
<span>{{ entry.name }}</span>
</label>
<p class="tier-test">
What lets this rule reach a project working in that area.
</p>
</fieldset>
<section v-if="relations.length" class="relations">
<h3>Related rules</h3>
<ul>
<li v-for="rel in relations" :key="rel.id" class="relation">
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
<span class="relation-target">rule #{{ rel.rule_id }}</span>
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
</li>
</ul>
<p class="tier-test">
Rules that <em>fail together</em> are linked, never merged a merged rule cannot be
cited, surfaced or suppressed a clause at a time.
</p>
</section>
<label> <label>
Why Why
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." /> <textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
@@ -118,6 +218,19 @@ input, textarea {
padding: 0.5rem; font: inherit; padding: 0.5rem; font: inherit;
font-family: inherit; font-family: inherit;
} }
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
.relation-kind { color: var(--fs-accent); }
.relation-target { color: var(--fs-text-primary); }
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; } .trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
.trash:hover, .close:hover { opacity: 1; } .trash:hover, .close:hover { opacity: 1; }
</style> </style>
+24 -1
View File
@@ -13,8 +13,17 @@ const emit = defineEmits<{
<header><h2>Rules</h2></header> <header><h2>Rules</h2></header>
<ul> <ul>
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)"> <li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
<div class="title">{{ r.title }}</div> <div class="title">
{{ r.title }}
<!-- Only conditional is marked: always-on is the default and
badging every row would say nothing. -->
<span v-if="r.tier === 'conditional'" class="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
</div>
<div class="statement">{{ r.statement }}</div> <div class="statement">{{ r.statement }}</div>
<div v-if="r.when_to_apply || r.updated_at" class="meta">
<span v-if="r.when_to_apply" class="trigger">{{ r.when_to_apply }}</span>
<span v-if="r.updated_at" class="age" :title="`Last changed ${r.updated_at}`">{{ r.updated_at }}</span>
</div>
</li> </li>
</ul> </ul>
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button> <button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
@@ -35,5 +44,19 @@ li {
li:hover { background: var(--fs-surface-hover); } li:hover { background: var(--fs-surface-hover); }
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; } .title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; } .statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
.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;
}
.new-rule { cursor: pointer; } .new-rule { cursor: pointer; }
</style> </style>
+39 -5
View File
@@ -98,24 +98,58 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
delete rulesByTopic.value[id]; delete rulesByTopic.value[id];
} }
async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) { /**
* 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,
};
}
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
const rule = await api.createRule(topicId, data); const rule = await api.createRule(topicId, data);
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = []; if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id }); rulesByTopic.value[topicId].push(toHeader(rule));
return rule; return rule;
} }
async function updateRule(id: number, data: Partial<Pick<Rule, "title" | "statement" | "why" | "how_to_apply" | "order_index">>) { async function updateRule(id: number, data: Partial<api.RuleWrite>) {
const rule = await api.updateRule(id, data); const rule = await api.updateRule(id, data);
if (currentRule.value?.id === id) currentRule.value = rule; if (currentRule.value?.id === id) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) { for (const tid of Object.keys(rulesByTopic.value)) {
const list = rulesByTopic.value[Number(tid)]; const list = rulesByTopic.value[Number(tid)];
const idx = list.findIndex((r) => r.id === id); const idx = list.findIndex((r) => r.id === id);
if (idx >= 0) list[idx] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id }; if (idx >= 0) list[idx] = toHeader(rule);
} }
return 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);
}
async function deleteRule(id: number) { async function deleteRule(id: number) {
await api.deleteRule(id); await api.deleteRule(id);
if (currentRule.value?.id === id) currentRule.value = null; if (currentRule.value?.id === id) currentRule.value = null;
@@ -129,6 +163,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
fetchRulebooks, fetchTopics, fetchRules, fetchRule, fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook, createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
createTopic, updateTopic, deleteTopic, createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule, createRule, updateRule, deleteRule, relateRules, unrelateRules,
}; };
}); });