From a8b2040216f718ec9ed752e2a100acc81de2015c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 30 Aug 2026 12:52:07 -0400 Subject: [PATCH] feat(rules): the edit history is visible in the slide-over (#3243, milestone 323 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 27: a history nobody can read is not shipped. `RuleHistoryPanel.vue` sits below the fields in `RuleEditorSlideOver`, where a rule is read in full — not on the list row, where a history entry point would compete with the row's job. REUSE, DECIDED FIELD BY FIELD RATHER THAN ALL AT ONCE. DiffView.vue is reused unchanged: it takes DiffLine[] and nothing note-shaped. HistoryPanel.vue is NOT, and its props are the reason — noteId + currentBody, a NoteVersion carrying tags and pin columns, a fetch of /api/notes/…, a restore emit, pin/unpin buttons. Rules have no tags, no pins, and deliberately no restore, and a rule's text is EIGHT fields rather than one body, which changes the reader's question from "what changed" to "which fields moved". Recorded here rather than forked silently, per #3207. THE FORK THAT WAS ALREADY THERE. The LCS walk existed three times — privately in useAssist.ts, and again inside HistoryPanel.vue and VersionHistorySection.vue — character-identical apart from quote style, because computeDiff was never exported. Rather than add a fourth copy, it moves to utils/diff.ts and the three become imports; the extraction was verified equivalent to all three before anything was deleted. DiffLine is re-exported from useAssist so its existing importers are untouched. WHAT A ROW SHOWS: when, and which fields moved. A version holds the text the edit REPLACED, so the edit is the step from a row to the next NEWER state — the row above it, or, for the newest row, the rule as it stands now. Comparing against the row below would attribute every change to the wrong edit. A field nobody has fetched yet reads as neither changed nor unchanged. An edit that touched verify_with is badged "check reset", because that edit silently cleared verified_at (milestone 312) and put the rule back at the top of the staleness sweep — a moment visible nowhere else. The badge is a 12% color-mix TINT, not solid `--fs-warning`. `--fs-warning-fg` is defined in theme.css as "warning TEXT on a warning tint", so painting it over the solid token is exactly the same-hue contrast failure #3141 records. Every var() the component references resolves against theme.css, checked before pushing. Co-Authored-By: Claude Opus 5 --- frontend/src/api/rulebooks.ts | 43 +++ frontend/src/components/HistoryPanel.vue | 24 +- .../src/components/VersionHistorySection.vue | 22 +- .../components/rules/RuleEditorSlideOver.vue | 12 + .../src/components/rules/RuleHistoryPanel.vue | 274 ++++++++++++++++++ frontend/src/composables/useAssist.ts | 31 +- frontend/src/utils/diff.ts | 50 ++++ 7 files changed, 388 insertions(+), 68 deletions(-) create mode 100644 frontend/src/components/rules/RuleHistoryPanel.vue create mode 100644 frontend/src/utils/diff.ts diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index f884999..9665f42 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -228,6 +228,49 @@ export async function unrelateRules(relationId: number): Promise { return apiDelete(`/api/rule-relations/${relationId}`); } +/** + * One entry in a rule's edit history. + * + * Each entry holds the text the edit REPLACED, not the text it introduced — + * so the newest entry is what the rule said before its most recent change, + * and what that change produced is the rule as it stands now. Read the other + * way round, every diff comes out backwards. + * + * The listing form omits the long fields; open one to get them. + */ +export interface RuleVersion { + id: number; + rule_id: number; + /** Who made the edit. Null when that account has since been deleted. */ + user_id: number | null; + title: string; + created_at: string; + statement?: string; + why?: string; + how_to_apply?: string; + when_to_apply?: string; + tier?: string; + verify_with?: string; + expires_when?: string; +} + +export async function listRuleVersions(ruleId: number): Promise { + const data = await apiGet<{ versions: RuleVersion[] }>( + `/api/rules/${ruleId}/versions`, + ); + return data.versions; +} + +export async function getRuleVersion( + ruleId: number, versionId: number, +): Promise { + return apiGet(`/api/rules/${ruleId}/versions/${versionId}`); +} + +// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording +// back goes through updateRule, which snapshots what it replaces — so the +// undo stays visible in the history like any other edit. + export async function deleteRule(id: number): Promise { return apiDelete(`/api/rules/${id}`); } diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index 0688a9c..56491bc 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted } from "vue"; import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client"; import DiffView from "@/components/DiffView.vue"; -import type { DiffLine } from "@/composables/useAssist"; +import { computeDiff, type DiffLine } from "@/utils/diff"; import { fmtStamp } from "@/utils/dateFormat"; interface NoteVersion { @@ -33,28 +33,8 @@ const loadingDetail = ref(false); const diff = computed(() => { if (!selectedVersion.value?.body) return []; - const a = props.currentBody; - const b = selectedVersion.value.body; - const aLines = a.split('\n'); - const bLines = b.split('\n'); - const m = aLines.length, n = bLines.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = m - 1; i >= 0; i--) - for (let j = n - 1; j >= 0; j--) - dp[i][j] = aLines[i] === bLines[j] - ? dp[i+1][j+1] + 1 - : Math.max(dp[i+1][j], dp[i][j+1]); - const result: DiffLine[] = []; - let i = 0, j = 0; - while (i < m && j < n) { - if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; } - else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] }); - else result.push({ type: 'insert', text: bLines[j++] }); - } - while (i < m) result.push({ type: 'delete', text: aLines[i++] }); - while (j < n) result.push({ type: 'insert', text: bLines[j++] }); - return result; + return computeDiff(props.currentBody, selectedVersion.value.body); }); async function loadVersions() { diff --git a/frontend/src/components/VersionHistorySection.vue b/frontend/src/components/VersionHistorySection.vue index 6c7cc16..fb344c6 100644 --- a/frontend/src/components/VersionHistorySection.vue +++ b/frontend/src/components/VersionHistorySection.vue @@ -2,7 +2,7 @@ import { ref, computed } from "vue"; import { apiGet } from "@/api/client"; import DiffView from "@/components/DiffView.vue"; -import type { DiffLine } from "@/composables/useAssist"; +import { computeDiff, type DiffLine } from "@/utils/diff"; interface NoteVersion { id: number; @@ -31,25 +31,7 @@ const loadingDetail = ref(false); const diff = computed(() => { if (!selectedVersion.value?.body) return []; - const aLines = props.currentBody.split("\n"); - const bLines = selectedVersion.value.body.split("\n"); - const m = aLines.length, n = bLines.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = m - 1; i >= 0; i--) - for (let j = n - 1; j >= 0; j--) - dp[i][j] = aLines[i] === bLines[j] - ? dp[i + 1][j + 1] + 1 - : Math.max(dp[i + 1][j], dp[i][j + 1]); - const result: DiffLine[] = []; - let i = 0, j = 0; - while (i < m && j < n) { - if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; } - else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] }); - else result.push({ type: "insert", text: bLines[j++] }); - } - while (i < m) result.push({ type: "delete", text: aLines[i++] }); - while (j < n) result.push({ type: "insert", text: bLines[j++] }); - return result; + return computeDiff(props.currentBody, selectedVersion.value.body); }); function formatDate(iso: string): string { diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue index 26523bf..22f54c4 100644 --- a/frontend/src/components/rules/RuleEditorSlideOver.vue +++ b/frontend/src/components/rules/RuleEditorSlideOver.vue @@ -3,6 +3,7 @@ import { computed, ref, watch, onMounted } from "vue"; import { useRulebooksStore } from "@/stores/rulebooks"; import { useCanonicalSystemsStore } from "@/stores/canonicalSystems"; import type { RuleTier } from "@/api/rulebooks"; +import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue"; const props = defineProps<{ ruleId: number | null; topicId: number | null }>(); const emit = defineEmits<{ close: [] }>(); @@ -256,6 +257,17 @@ watch(() => props.ruleId, load); How to apply