From f746ba6c5df4e2cd8e7252594e36ffeacadc5cd9 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:29:25 -0700 Subject: [PATCH 01/10] docs: Scan Ledger design spec + blueprint --- .../specs/2026-06-15-scan-ledger-design.html | 288 ++++++++++++++++++ .../specs/2026-06-15-scan-ledger-design.md | 137 +++++++++ 2 files changed, 425 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-scan-ledger-design.html create mode 100644 docs/superpowers/specs/2026-06-15-scan-ledger-design.md diff --git a/docs/superpowers/specs/2026-06-15-scan-ledger-design.html b/docs/superpowers/specs/2026-06-15-scan-ledger-design.html new file mode 100644 index 0000000..67684c9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-scan-ledger-design.html @@ -0,0 +1,288 @@ + + + + + +Scan Ledger — Design Blueprint + + + + + + +
+ +
+

Design Blueprint · for review

+

Scan Ledger

+

Give every repo a memory. Each scan becomes a durable, versioned snapshot — so a repo's health, fit, and flags read as a trajectory, not a single anonymous number.

+
+ Feature Scan Ledger + Direction evaluations compound + Filed 2026-06-15 + Spec scan-ledger-design.md +
+
+
v1 scope
+
FOUNDATION
+ TRAJECTORY
+
+
+ + +
01

Why now

+
+

The product contradicts its own thesis.

+

RepoLens's pitch is "evaluations compound" — but today saveRepo overwrites the latest analysis on every re-scan and keeps just a single prevFitLevel. The diffAnalyses() engine is fully built, yet structurally starved: it can only ever see two points because only one prior exists.

+

Scan Ledger persists the history those features were waiting for — the missing substrate that later unlocks drift/regret alerts and decision-replay.

+
+ + +
02

What you'll see

+

Two read-only surfaces. Both appear only once a repo has ≥2 scans, and both stay static under reduced-motion.

+
+
+

Library card — health sparkline

+
+
+
+ facebook/react + Strong +
+
ui-library · 220k★ · health 91
+
+ + + + + + +19 · 6 scans · 60d +
+
+
+
+
+

Verdict tab — "History" strip

+
+

History

+
6 scans · last 60 days
+
+ Health + + + + + 72 → 80 → 76 → 84 → 88 → 91 + +
+
+ Fit + care → solid → solid → solid → strong +
+
+ Flags + −2 resolved · +0 new +
+
+
+
+

Mockups rendered live — the real sparkline is an inline SVG string built the same way graph.js / diagram.js draw, no chart library.

+ + +
03

Confirmed decisions

+
+
Snapshot trigger
Every scanIt's a scan ledger — honest, and the cap bounds it.
+
Sparkline metric
Health over timeFit shown as the line's tint + the progression row.
+
Retention
30 / repoRing buffer — drop the oldest.
+
Storage shape
One record / repoA snaps[] array — trivial trimming, one read.
+
+ + +
04

Under the hood

+

A new IndexedDB store, one new pure module, thin store wrappers. Everything testable lands on the testable side of the codebase.

+ +

Data model — new snapshots store

+
// one record per repo, keyed by id (same convention as every store)
+{
+  id:     <repoHash>,   // hashRepoId(repoId) — the hash 'repos' already uses
+  repoId: string,
+  snaps:  Snapshot[]    // oldest → newest, max 30
+}
+
+Snapshot = {
+  ts:      string,        // ISO; equals the scan's saved_at
+  health:  number | null,
+  fit:     'strong'|'solid'|'care'|'risky'|'unrated',
+  stars:   number,
+  flags:   string[],      // red_flag titles at scan time
+  version: string | null  // repo HEAD sha / pkg version, if known
+}
+ +

Data flow

+
+
1

A scan completes → saveRepo() writes the repos payload (unchanged), then calls appendScanSnapshot(payload).

+
2

Library loads → snapshots are batch-read once via a single idbGetAll('snapshots') into a Map (no per-card round-trips); each card draws its sparkline.

+
3

Verdict tab renders → the History strip is built from the same snapshotTrend(), through the existing html/esc escaping path.

+
+ +

New pure module — snapshots.js fully unit-tested

+
+
toSnapshot(payload) → Snapshot
Trim a repo payload to a snapshot. Fit via deriveFit(payload)?.level — the same helper store.js already uses, so the ledger matches the app.
+
appendSnapshot(snaps, snap, cap = 30) → Snapshot[]
Immutable: append, then keep the last cap.
+
snapshotTrend(snaps) → { series, healthDelta, fitDirection, flagsResolved, flagsNew, daysSpan }
Reuses diffAnalyses() (first vs latest) by adapting each snapshot's tscachedAt.
+
sparkline(series, { metric:'health', width, height }) → string | null
An inline-SVG points/path string. null if <2 usable points.
+
+ + +
05

Migration, backup & edges

+
+
+

Additive migration

+
    +
  • store/idb.js: DB_VERSION 3 → 4, add 'snapshots' to STORES. Existing data untouched — same as the v2 (collections) / v3 (decisions) upgrades.
  • +
  • Backfill: a repo scanned before this feature has no record — on first read, synthesize one snapshot from its stored payload, so existing users see a point right away.
  • +
+
+
+

Backup & safety

+
    +
  • snapshots joins exportStores/importStores; bump BACKUP_VERSION; clamp to the 30-cap on import (fails safe).
  • +
  • Quota: 30 × ~200 B × N repos ≈ a few MB — negligible for IndexedDB.
  • +
  • Race: saveRepo is the only writer and scans are serialized — read-modify-write is safe.
  • +
+
+
+ + +
06

Testing & footprint

+
+
+

Tests

+
    +
  • snapshots.test.js — append/trim/immutability, trend deltas, fit direction, sparkline math (<2 → null).
  • +
  • Store (fake-indexeddb) — append + cap at 30, backfill-from-payload, export/import round-trip + clamp.
  • +
  • UI rendering stays untested (repo norm); its pure view-model builders are covered.
  • +
+
+
+

Files

+
    +
  • new snapshots.js, tests/snapshots.test.js
  • +
  • edit store/idb.js · store.js · library.js · output-tab.js · backup.js
  • +
+
+
+ + +
+ + diff --git a/docs/superpowers/specs/2026-06-15-scan-ledger-design.md b/docs/superpowers/specs/2026-06-15-scan-ledger-design.md new file mode 100644 index 0000000..1d4c046 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-scan-ledger-design.md @@ -0,0 +1,137 @@ +# Scan Ledger — Design + +**Date:** 2026-06-15 +**Status:** Approved in brainstorm → ready for implementation plan +**Scope (v1):** "Foundation + visible trajectory" + +## Goal + +RepoLens's stated direction is _"evaluations compound,"_ but `saveRepo` overwrites +the latest analysis on every re-scan and keeps only a single `prevFitLevel`, so the +existing `diffAnalyses()` engine can only ever compare two points. **Scan Ledger** +persists a versioned history of every scan per repo and surfaces each repo's +trajectory — turning the one-shot explainer into a longitudinal monitor, and laying +the substrate that later unlocks drift/regret alerts and decision-replay. + +## Non-goals (v1) + +- Drift/regret alerts wired to real history (deferred — the ledger is their substrate). +- Decision-replay, portfolio-level views (deferred). +- Editing/annotating past snapshots. +- No new network calls, no new permissions, no backend. 100% client-side. + +## Confirmed decisions + +- **Trigger:** record a snapshot on **every** successful scan (it's a _scan_ ledger). +- **Sparkline metric:** **health** over time. +- **Retention:** ring buffer, **30 snapshots/repo**, drop oldest. +- **Storage shape:** one record per repo holding a `snaps` array. + +## Data model + +New IndexedDB object store `snapshots`, keyed by `id` (same convention as every +existing store). **One record per repo:** + +``` +{ + id: , // hashRepoId(repoId) — the same hash store.js uses for 'repos' + repoId: string, + snaps: Snapshot[] // oldest → newest, max 30 +} + +Snapshot = { + ts: string, // ISO timestamp; equals the scan's saved_at + health: number | null, // health score (0–100) + fit: 'strong' | 'solid' | 'care' | 'risky' | 'unrated', + stars: number, + flags: string[], // red_flag titles at scan time + version: string | null // repo HEAD sha / package version if known, else null +} +``` + +Rationale for one-record-per-repo: trivial retention (trim the array), a single +`idbGet` to read a repo's history, and far fewer rows than per-snapshot records. +`saveRepo` is the only writer and scans are serialized through the AI queue, so the +read-modify-write has no real race. + +## Migration + +`store/idb.js`: bump `DB_VERSION` 3 → 4 and add `'snapshots'` to `STORES`. The +existing `onupgradeneeded` creates any missing store, so all existing data survives +untouched — identical to the v1→v2 (`collections`) and v2→v3 (`decisions`) upgrades. + +**Backfill:** a repo scanned before this feature has no `snapshots` record. On first +`listSnapshots`, if there's no record but the repo has a stored payload, synthesize a +single snapshot from that payload and persist it — so existing users see one point +immediately and the next scan adds the second. + +## Pure logic — new `snapshots.js` (no DOM / no chrome) + +- `toSnapshot(payload)` → `Snapshot` — extract the trimmed fields from a repo payload. + Fit comes from `deriveFit(payload)?.level` (the same helper `store.js` already uses + to compute `prevFitLevel`), so the ledger's fit matches the rest of the app. +- `appendSnapshot(snaps, snap, cap = 30)` → `Snapshot[]` — immutable: append, then + keep only the last `cap`. +- `snapshotTrend(snaps)` → `{ series, first, latest, healthDelta, fitDirection, + flagsResolved, flagsNew, daysSpan }`. Reuses `diffAnalyses()` for the first-vs-latest + fit/flag deltas by adapting each snapshot to the shape it expects (`ts` → `cachedAt`). + `series` is `[{ ts, health, fit, stars }]`. +- `sparkline(series, { metric = 'health', width, height })` → an SVG points/path + **string** (string builder, like `graph.js` / `diagram.js` — no chart library). + Returns `null` if fewer than 2 usable points. + +`store.js` gains thin, non-pure wrappers that do the IndexedDB I/O and call the pure +helpers: + +- `appendScanSnapshot(payload)` — invoked by `saveRepo` after the `repos` write: read + the repo's `snapshots` record, `appendSnapshot(toSnapshot(payload))`, `idbPut`. +- `listSnapshots(repoId)` — `idbGet`, with the backfill above. + +## Data flow + +1. Scan completes → `saveRepo(analysis)` writes the `repos` payload **(unchanged)**, + then calls `appendScanSnapshot(payload)`. +2. Library renders → snapshots are **batch-loaded once** via a single + `idbGetAll('snapshots')` into a `Map` (avoid N per-card round-trips); `card(r)` + pulls its series → `snapshotTrend` → `sparkline` → inline SVG. +3. Verdict tab renders → builds the History strip from the same `snapshotTrend`. + +## UI surface (read-only) + +- **Library card** (`library.js card()`): a tiny inline-SVG **health** sparkline, + stroke tinted by the current fit token, with a `+Δ / N days` caption. Hidden if + `<2` points. Static under `prefers-reduced-motion`. +- **Verdict tab** (`output-tab.js`): a compact **"History"** section — health + sparkline + the series numbers, fit progression (`care → solid → strong ↑`), and + flags resolved/new. Hidden if `<2` points. Rendered through the existing + `html` / `esc` escaping path. + +## Backup / export + +`store.js` `exportStores` / `importStores`: include `snapshots`. Bump +`BACKUP_VERSION`. On import, clamp each repo's `snaps` to the 30-cap and validate +shape, failing safe like the existing bounded import. + +## Testing + +- `tests/snapshots.test.js` (new): `toSnapshot`; `appendSnapshot` (append + trim + + immutability); `snapshotTrend` (deltas, empty/single input, fit direction); + `sparkline` (point math, `<2` → `null`). +- Store tests (`fake-indexeddb`): `appendScanSnapshot` appends + caps at 30; + `listSnapshots` backfills from the payload when empty; export/import round-trips + snapshots and clamps on import. +- UI rendering stays untested (repo norm); the pure view-model builders that feed it + are covered. + +## Files + +- **New:** `snapshots.js`, `tests/snapshots.test.js` +- **Edit:** `store/idb.js` (migration), `store.js` (append/list/backup), + `library.js` (card sparkline + batched load), `output-tab.js` (History strip), + `backup.js` (version bump + import clamp) + +## Risks / mitigations + +- **Quota:** 30 × ~200 B × N repos ≈ a few MB worst case — negligible for IndexedDB. +- **Read-modify-write race:** scans are serialized, single writer — acceptable. +- **Library perf:** batch-load snapshots once into a `Map` at load, never per-card. From cf123d822d5e21348839123bc9a0bd6eec25b70b Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:37:48 -0700 Subject: [PATCH 02/10] docs: Scan Ledger implementation plan --- .../plans/2026-06-15-scan-ledger.md | 781 ++++++++++++++++++ 1 file changed, 781 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-scan-ledger.md diff --git a/docs/superpowers/plans/2026-06-15-scan-ledger.md b/docs/superpowers/plans/2026-06-15-scan-ledger.md new file mode 100644 index 0000000..19ca60d --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-scan-ledger.md @@ -0,0 +1,781 @@ +# Scan Ledger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist a versioned, capped history of every repo scan and surface each repo's trajectory (a health sparkline on library cards + a "History" strip on the Verdict tab). + +**Architecture:** A new additive IndexedDB store (`snapshots`, one record per repo holding a capped `snaps[]` array). All logic lives in a new pure module `snapshots.js` (no DOM/chrome); `store.js` adds thin IndexedDB wrappers and seeds a snapshot on every `saveRepo`. The UI reads snapshots (batched for the library) and draws an inline-SVG sparkline. Snapshots round-trip through the existing backup envelope. + +**Tech Stack:** Vanilla ES modules (no build step), IndexedDB (promise-wrapped in `store/idb.js`), Vitest + `fake-indexeddb` for tests. + +**Spec:** `docs/superpowers/specs/2026-06-15-scan-ledger-design.md` + +--- + +## File structure + +| File | Responsibility | Change | +|------|----------------|--------| +| `snapshots.js` | Pure ledger logic: `toSnapshot`, `appendSnapshot`, `snapshotTrend`, `sparkline` | **create** | +| `tests/snapshots.test.js` | Unit tests for the above | **create** | +| `diff-analysis.js` | Reuse its `FIT_ORDER` (export it) | modify (1 line) | +| `store/idb.js` | Add the `snapshots` object store (v3→v4) | modify | +| `store.js` | `appendScanSnapshot`, `listSnapshots`, `listAllSnapshots`; call from `saveRepo`; backup wiring | modify | +| `backup.js` | Include `snapshots` in the envelope; bump `BACKUP_VERSION` | modify | +| `library.js` | Batch-load snapshots in `init()`; draw the card sparkline | modify | +| `output-tab.js` | `renderHistory(d)` — the Verdict "History" strip | modify | +| `output-tab.html` | A `
` host for the strip | modify | + +Notes carried from the spec into this plan: +- **Trigger:** every scan. **Metric:** health. **Cap:** 30/repo (ring buffer). +- **Seeding (refines the spec's "backfill on read"):** seed the *prior* scan into the ledger at scan time, inside `appendScanSnapshot`, using the old payload `saveRepo` already reads. This is strictly better than read-time backfill — no write-on-read side effect, and an existing repo's first re-scan yields a real 2-point trend instead of one dead point. `listSnapshots` therefore stays a pure read. +- **Trend computation (refines the spec's "reuse `diffAnalyses`"):** `snapshotTrend` computes deltas directly rather than feeding snapshots through `diffAnalyses`. Reason: snapshots store the already-*derived* `fit` and flag *titles* (no severity), so `diffAnalyses`' `_fitLevel` would re-derive fit from lossy data and get it wrong. Direct computation uses the stored fit and is simpler and correct. The one thing reused is `FIT_ORDER` (now exported from `diff-analysis.js`), so the constant isn't copied a fourth time. + +--- + +### Task 1: Pure `snapshots.js` module + tests + +**Files:** +- Create: `snapshots.js` +- Create: `tests/snapshots.test.js` +- Modify: `diff-analysis.js:4` (export `FIT_ORDER`) + +- [ ] **Step 1: Export `FIT_ORDER` from `diff-analysis.js`** + +Change line 4 from `const FIT_ORDER = ['strong', 'solid', 'care', 'risky'];` to: + +```js +export const FIT_ORDER = ['strong', 'solid', 'care', 'risky']; +``` + +(Avoids a 4th copy of the constant; the rest of `diff-analysis.js` is unchanged.) + +- [ ] **Step 2: Write the failing tests** — `tests/snapshots.test.js` + +```js +import { describe, it, expect } from 'vitest'; +import { toSnapshot, appendSnapshot, snapshotTrend, sparkline, SNAPSHOT_CAP } from '../snapshots.js'; + +describe('toSnapshot', () => { + it('trims a payload to the snapshot shape, normalizing health and flags', () => { + const snap = toSnapshot( + { repoId: 'a/b', health: 88, stars: 1200, red_flags: [{ title: 'No tests' }, { title: '' }], saved_at: '2026-06-01T00:00:00.000Z' }, + '2026-06-01T00:00:00.000Z' + ); + expect(snap).toEqual({ + ts: '2026-06-01T00:00:00.000Z', + health: 88, + fit: 'strong', + stars: 1200, + flags: ['No tests'], + version: null, + }); + }); + it('accepts health as a { score } object and defaults ts to saved_at', () => { + const snap = toSnapshot({ repoId: 'a/b', health: { score: 60 }, stars: 0, red_flags: [], saved_at: '2026-06-02T00:00:00.000Z' }); + expect(snap.health).toBe(60); + expect(snap.ts).toBe('2026-06-02T00:00:00.000Z'); + expect(snap.fit).toBe('care'); // 60, 0 flags + }); + it('yields null health when absent', () => { + expect(toSnapshot({ repoId: 'a/b', red_flags: [] }, '2026-06-01T00:00:00.000Z').health).toBeNull(); + }); +}); + +describe('appendSnapshot', () => { + it('appends immutably and never mutates the input', () => { + const a = [{ ts: '1' }]; + const out = appendSnapshot(a, { ts: '2' }); + expect(out).toHaveLength(2); + expect(a).toHaveLength(1); + }); + it('keeps only the most recent `cap`', () => { + const many = Array.from({ length: SNAPSHOT_CAP }, (_, i) => ({ ts: String(i) })); + const out = appendSnapshot(many, { ts: 'new' }, SNAPSHOT_CAP); + expect(out).toHaveLength(SNAPSHOT_CAP); + expect(out[0].ts).toBe('1'); + expect(out[out.length - 1].ts).toBe('new'); + }); + it('handles a non-array prev', () => { + expect(appendSnapshot(undefined, { ts: 'x' })).toEqual([{ ts: 'x' }]); + }); +}); + +describe('snapshotTrend', () => { + const snaps = [ + { ts: '2026-06-01T00:00:00.000Z', health: 72, fit: 'care', stars: 100, flags: ['No tests', 'Stale'] }, + { ts: '2026-06-11T00:00:00.000Z', health: 91, fit: 'strong', stars: 150, flags: ['No tests'] }, + ]; + it('returns null for <2 points', () => { + expect(snapshotTrend([])).toBeNull(); + expect(snapshotTrend([snaps[0]])).toBeNull(); + }); + it('computes health delta, fit direction, flag diffs and day span', () => { + const t = snapshotTrend(snaps); + expect(t.count).toBe(2); + expect(t.healthDelta).toBe(19); + expect(t.fitFrom).toBe('care'); + expect(t.fitTo).toBe('strong'); + expect(t.fitDirection).toBe('up'); + expect(t.flagsResolved).toEqual(['Stale']); + expect(t.flagsNew).toEqual([]); + expect(t.daysSpan).toBe(10); + expect(t.series).toHaveLength(2); + }); +}); + +describe('sparkline', () => { + it('returns null for <2 plottable points', () => { + expect(sparkline([{ health: 5 }])).toBeNull(); + expect(sparkline([{ health: null }, { health: null }])).toBeNull(); + }); + it('builds an svg polyline scaled to the box', () => { + const svg = sparkline([{ health: 0 }, { health: 50 }, { health: 100 }], { width: 100, height: 20 }); + expect(svg).toContain(' f && f.title).filter(Boolean), + version: (payload && payload.version) || null, + }; +} + +/** Append a snapshot immutably, keeping only the most recent `cap`. */ +export function appendSnapshot(snaps, snap, cap = SNAPSHOT_CAP) { + const next = [...(Array.isArray(snaps) ? snaps : []), snap]; + return next.length > cap ? next.slice(next.length - cap) : next; +} + +/** Direction of a fit change: 'up' (improved), 'down' (worse), 'same'. Lower index = better. */ +function fitDirection(from, to) { + const a = FIT_ORDER.indexOf(from); + const b = FIT_ORDER.indexOf(to); + if (a < 0 || b < 0 || a === b) return 'same'; + return b < a ? 'up' : 'down'; +} + +/** + * Derive a trend from a snapshot list (oldest→newest). Returns null if <2 points. + * @returns {null | { count, series, first, latest, healthDelta, fitFrom, fitTo, + * fitDirection, flagsResolved, flagsNew, daysSpan }} + */ +export function snapshotTrend(snaps) { + const list = (Array.isArray(snaps) ? snaps : []).filter((s) => s && s.ts); + if (list.length < 2) return null; + const first = list[0]; + const latest = list[list.length - 1]; + const series = list.map((s) => ({ ts: s.ts, health: s.health, fit: s.fit, stars: s.stars })); + const healthDelta = + first.health != null && latest.health != null ? latest.health - first.health : null; + const firstFlags = new Set(first.flags || []); + const latestFlags = new Set(latest.flags || []); + return { + count: list.length, + series, + first, + latest, + healthDelta, + fitFrom: first.fit, + fitTo: latest.fit, + fitDirection: fitDirection(first.fit, latest.fit), + flagsResolved: [...firstFlags].filter((t) => !latestFlags.has(t)), + flagsNew: [...latestFlags].filter((t) => !firstFlags.has(t)), + daysSpan: Math.max(0, Math.round((Date.parse(latest.ts) - Date.parse(first.ts)) / 86_400_000)), + }; +} + +/** + * Build an inline-SVG sparkline string from a trend series. Plots `metric` + * (default 'health'), skipping null points. Returns null if <2 plottable points. + */ +export function sparkline(series, { metric = 'health', width = 120, height = 32, stroke = 'currentColor' } = {}) { + const all = Array.isArray(series) ? series : []; + const pts = all.map((s, i) => ({ i, v: Number(s && s[metric]) })).filter((p) => Number.isFinite(p.v)); + if (pts.length < 2) return null; + const n = all.length; + const vals = pts.map((p) => p.v); + const min = Math.min(...vals); + const max = Math.max(...vals); + const span = max - min || 1; + const x = (i) => (n <= 1 ? 0 : (i / (n - 1)) * width); + const y = (v) => height - ((v - min) / span) * height; + const coords = pts.map((p) => `${x(p.i).toFixed(1)},${y(p.v).toFixed(1)}`); + const last = pts[pts.length - 1]; + return ( + `` + ); +} +``` + +- [ ] **Step 5: Run the tests, verify they PASS** + +Run: `npx vitest run tests/snapshots.test.js` +Expected: PASS (all describe blocks green). + +- [ ] **Step 6: Commit** + +```bash +git add snapshots.js tests/snapshots.test.js diff-analysis.js +git commit -m "feat(ledger): pure snapshots module (toSnapshot/appendSnapshot/snapshotTrend/sparkline)" +``` + +--- + +### Task 2: Add the `snapshots` IndexedDB store (v3 → v4) + +**Files:** +- Modify: `store/idb.js:5-9` + +- [ ] **Step 1: Bump the schema** + +Replace the version/comment/STORES block (lines 5-9) with: + +```js +// v2 added the 'collections' store. v3 added the 'decisions' store. v4 added the +// 'snapshots' store (the Scan Ledger). Each upgrade is additive — onupgradeneeded +// creates any store in STORES that doesn't already exist, so existing data survives. +const DB_VERSION = 4; +const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots']; +``` + +- [ ] **Step 2: Verify nothing broke** + +Run: `npx vitest run tests/idb.test.js tests/store.test.js` +Expected: PASS (existing IndexedDB tests still green — the change is purely additive). + +- [ ] **Step 3: Commit** + +```bash +git add store/idb.js +git commit -m "feat(ledger): add 'snapshots' object store (idb v3->v4, additive)" +``` + +--- + +### Task 3: `store.js` wrappers + `saveRepo` hook + +**Files:** +- Modify: `store.js` (import; `saveRepo`; three new functions) +- Modify: `tests/store.test.js` (new cases) + +- [ ] **Step 1: Write the failing tests** — append to `tests/store.test.js` + +```js +import { appendScanSnapshot, listSnapshots, listAllSnapshots, saveRepo } from '../store.js'; + +describe('scan ledger', () => { + it('saveRepo records a snapshot and re-scan appends a second point', async () => { + await saveRepo({ repoId: 'led/one', health: 70, stars: 10, red_flags: [] }); + await saveRepo({ repoId: 'led/one', health: 90, stars: 20, red_flags: [] }); + const snaps = await listSnapshots('led/one'); + expect(snaps.length).toBe(2); + expect(snaps[0].health).toBe(70); + expect(snaps[1].health).toBe(90); + }); + + it('caps history at 30 (ring buffer)', async () => { + for (let i = 0; i < 35; i++) { + await appendScanSnapshot({ repoId: 'led/cap', health: i, stars: 0, red_flags: [], saved_at: `2026-06-01T00:00:${String(i).padStart(2, '0')}.000Z` }); + } + const snaps = await listSnapshots('led/cap'); + expect(snaps.length).toBe(30); + expect(snaps[snaps.length - 1].health).toBe(34); + }); + + it('seeds the prior scan into the ledger on first re-scan of an existing repo', async () => { + // A repo scanned before the ledger existed has a repos payload but no snapshots. + // appendScanSnapshot must record that prior state (prevPayload) before the new one. + const prev = { repoId: 'led/seed', health: 40, stars: 1, red_flags: [], saved_at: '2026-05-01T00:00:00.000Z' }; + const next = { repoId: 'led/seed', health: 80, stars: 2, red_flags: [], saved_at: '2026-06-01T00:00:00.000Z' }; + await appendScanSnapshot(next, prev); + const snaps = await listSnapshots('led/seed'); + expect(snaps.map((s) => s.health)).toEqual([40, 80]); + }); + + it('listAllSnapshots returns a Map keyed by repoId', async () => { + await saveRepo({ repoId: 'led/map', health: 60, stars: 0, red_flags: [] }); + const map = await listAllSnapshots(); + expect(map.has('led/map')).toBe(true); + expect(Array.isArray(map.get('led/map'))).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** + +Run: `npx vitest run tests/store.test.js` +Expected: FAIL — `appendScanSnapshot`/`listSnapshots`/`listAllSnapshots` are not exported. + +- [ ] **Step 3: Implement in `store.js`** + +Add to the imports near the top (after the existing `./store/egograph.js` import on line 10): + +```js +import { toSnapshot, appendSnapshot, SNAPSHOT_CAP } from './snapshots.js'; +``` + +In `saveRepo`, the function already reads `existing` (line 26). Add the ledger write at the end of the function, immediately after `await idbPut('repos', { id: hashRepoId(analysis.repoId), payload });` (line 50): + +```js + await appendScanSnapshot(payload, existing?.payload); +``` + +Then add these three functions in the `// ─── repos` section (e.g. after `saveRepo`): + +```js +/** + * Append a snapshot for a repo's current payload (best-effort — a ledger write + * must never fail a scan). `prevPayload` (the repo's previous state, which saveRepo + * already reads) seeds one prior point the first time, so an existing repo's first + * re-scan yields a real 2-point trend instead of losing its history. + */ +export async function appendScanSnapshot(payload, prevPayload) { + if (!payload || !payload.repoId) return; + try { + const id = hashRepoId(payload.repoId); + const rec = await idbGet('snapshots', id).catch(() => null); + let snaps = rec && Array.isArray(rec.snaps) ? rec.snaps : []; + if (!snaps.length && prevPayload && prevPayload.saved_at) { + snaps = [toSnapshot(prevPayload)]; + } + snaps = appendSnapshot(snaps, toSnapshot(payload), SNAPSHOT_CAP); + await idbPut('snapshots', { id, repoId: payload.repoId, snaps }); + } catch { + /* the ledger is additive; a write failure must not break the scan */ + } +} + +/** A repo's snapshot history (oldest→newest). Best-effort — [] on failure. */ +export async function listSnapshots(repoId) { + try { + const rec = await idbGet('snapshots', hashRepoId(repoId)); + return rec && Array.isArray(rec.snaps) ? rec.snaps : []; + } catch { + return []; + } +} + +/** All snapshot histories as a Map(repoId → snaps[]) for batch rendering. */ +export async function listAllSnapshots() { + try { + const rows = await idbGetAll('snapshots'); + return new Map((rows || []).filter((r) => r && r.repoId).map((r) => [r.repoId, r.snaps || []])); + } catch { + return new Map(); + } +} +``` + +- [ ] **Step 4: Run, verify PASS** + +Run: `npx vitest run tests/store.test.js` +Expected: PASS. + +- [ ] **Step 5: Full suite green** + +Run: `npm test` +Expected: PASS (no regressions). + +- [ ] **Step 6: Commit** + +```bash +git add store.js tests/store.test.js +git commit -m "feat(ledger): record + read snapshots in store.js, seed prior scan on saveRepo" +``` + +--- + +### Task 4: Backup round-trip for `snapshots` + +**Files:** +- Modify: `backup.js` (version, MAX_ROWS, emptyValue, buildBackup, validateBackup, summarizeBackup) +- Modify: `store.js` (`exportStores`, `importStores`) +- Modify: `tests/backup.test.js`, `tests/store-backup.test.js` (new cases) + +- [ ] **Step 1: Write the failing tests** — append to `tests/backup.test.js` + +```js +import { buildBackup, validateBackup, BACKUP_VERSION } from '../backup.js'; + +describe('backup: snapshots', () => { + const snapRow = { id: 1, repoId: 'a/b', snaps: [{ ts: '2026-06-01T00:00:00.000Z', health: 80, fit: 'solid', stars: 1, flags: [] }] }; + + it('buildBackup includes snapshots and counts them', () => { + const b = buildBackup({ snapshots: [snapRow], exportedAt: '2026-06-15T00:00:00.000Z' }); + expect(b.version).toBe(BACKUP_VERSION); + expect(b.snapshots).toHaveLength(1); + expect(b.counts.snapshots).toBe(1); + }); + + it('validateBackup keeps well-formed snapshot rows and drops malformed ones', () => { + const { value } = validateBackup({ + format: 'repolens-backup', + version: BACKUP_VERSION, + snapshots: [snapRow, { id: 2 }, { repoId: 'x', snaps: [] }], + }); + expect(value.snapshots).toHaveLength(1); + expect(value.snapshots[0].repoId).toBe('a/b'); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** + +Run: `npx vitest run tests/backup.test.js` +Expected: FAIL — `b.snapshots` is undefined / `value.snapshots` is undefined. + +- [ ] **Step 3: Edit `backup.js`** + +Bump the version (line 11): + +```js +export const BACKUP_VERSION = 2; +``` + +Add a snapshots bound to `MAX_ROWS` (line 16) — append `, snapshots: 5000` inside the object: + +```js +export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000 }; +``` + +Add a validator next to the others (after line 24): + +```js +const snapshotOk = (r) => !!(r && r.id != null && r.repoId && Array.isArray(r.snaps)); +``` + +In `emptyValue()` (line 28) add `snapshots: []`: + +```js + return { repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [], snapshots: [] }; +``` + +In `buildBackup` (lines 37-50) add `snapshots` to the destructure, the `arr()` line, `counts`, and the body: + +```js +export function buildBackup({ repos, nodes, edges, cache, collections, decisions, snapshots, exportedAt } = {}) { + const r = arr(repos), n = arr(nodes), e = arr(edges), c = arr(cache), col = arr(collections), dec = arr(decisions), snap = arr(snapshots); + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: exportedAt || new Date().toISOString(), + counts: { repos: r.length, nodes: n.length, edges: e.length, cache: c.length, collections: col.length, decisions: dec.length, snapshots: snap.length }, + repos: r, nodes: n, edges: e, cache: c, collections: col, decisions: dec, snapshots: snap, + }; +} +``` + +In `validateBackup`'s `value` object (lines 83-90) add the snapshots line: + +```js + snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk)), +``` + +In `summarizeBackup` (line 102) add `, snapshots: value.snapshots.length` to the returned object. + +- [ ] **Step 4: Run, verify PASS** + +Run: `npx vitest run tests/backup.test.js` +Expected: PASS. + +- [ ] **Step 5: Wire the IndexedDB I/O in `store.js`** + +In `exportStores` (lines 234-243): add `idbGetAll('snapshots')` to the `Promise.all` and include it in the destructure + returned object: + +```js +export async function exportStores() { + const [repos, nodes, edges, collections, decisions, snapshots] = await Promise.all([ + idbGetAll('repos'), + idbGetAll('nodes'), + idbGetAll('edges'), + idbGetAll('collections'), + idbGetAll('decisions'), + idbGetAll('snapshots'), + ]); + return { repos: repos || [], nodes: nodes || [], edges: edges || [], collections: collections || [], decisions: decisions || [], snapshots: snapshots || [] }; +} +``` + +In `importStores` (lines 254-265): add `snapshots = []` to the params, the `replace` clear, the validRows line, and the write loop: + +```js +export async function importStores({ repos = [], nodes = [], edges = [], collections = [], decisions = [], snapshots = [] } = {}, { mode = 'merge' } = {}) { + if (mode === 'replace') { + await Promise.all([idbClear('repos'), idbClear('nodes'), idbClear('edges'), idbClear('collections'), idbClear('decisions'), idbClear('snapshots')]); + } + const vr = validRows(repos), vn = validRows(nodes), ve = validRows(edges), vc = validRows(collections), vd = validRows(decisions), vs = validRows(snapshots); + for (const row of vr) await idbPut('repos', row); + for (const row of vn) await idbPut('nodes', row); + for (const row of ve) await idbPut('edges', row); + for (const row of vc) await idbPut('collections', row); + for (const row of vd) await idbPut('decisions', row); + for (const row of vs) await idbPut('snapshots', row); + return { repos: vr.length, nodes: vn.length, edges: ve.length, collections: vc.length, decisions: vd.length, snapshots: vs.length }; +} +``` + +Also add `idbClear('snapshots')` to the `clearLibrary()` `Promise.all` (line 269). + +- [ ] **Step 6: Confirm the export/import HANDLERS pass snapshots through** + +The library export handler is around `library.js:861` (`const [stores, cached] = await Promise.all([exportStores(), listCached()...])`). Open it and confirm `buildBackup` is called by spreading the stores object (e.g. `buildBackup({ ...stores, cache })`). Because `exportStores` now returns `snapshots`, a spread flows it through with no further change. If the handler enumerates fields explicitly instead of spreading, add `snapshots: stores.snapshots`. Likewise, find the import handler (it calls `importStores(...)` with `validateBackup(parsed).value`) and confirm it passes the whole `value` (which now includes `snapshots`). If it destructures explicitly, add `snapshots`. + +- [ ] **Step 7: Round-trip test** — append to `tests/store-backup.test.js` + +```js +import { saveRepo, exportStores, importStores, clearLibrary, listSnapshots } from '../store.js'; + +it('snapshots survive an export → clear → import round-trip', async () => { + await saveRepo({ repoId: 'rt/one', health: 70, stars: 0, red_flags: [] }); + await saveRepo({ repoId: 'rt/one', health: 85, stars: 0, red_flags: [] }); + const dump = await exportStores(); + expect(dump.snapshots.length).toBe(1); + await clearLibrary(); + expect(await listSnapshots('rt/one')).toEqual([]); + await importStores(dump, { mode: 'replace' }); + const snaps = await listSnapshots('rt/one'); + expect(snaps.map((s) => s.health)).toEqual([70, 85]); +}); +``` + +- [ ] **Step 8: Run, verify PASS + full suite** + +Run: `npx vitest run tests/backup.test.js tests/store-backup.test.js && npm test` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add backup.js store.js tests/backup.test.js tests/store-backup.test.js library.js +git commit -m "feat(ledger): round-trip snapshots through the backup envelope (v2)" +``` + +--- + +### Task 5: Library card health sparkline + +**Files:** +- Modify: `library.js` (import; module Map; load in `init()`; render in `card()`) + +No unit test (DOM/SW shell, per repo norm); the `snapshotTrend`/`sparkline` math is already covered in Task 1. Verify manually. + +- [ ] **Step 1: Import the helpers + store loader** + +Add to the `library-data.js` import line area (top of file). Extend the existing `./store.js` import (line 6) to include `listAllSnapshots`, and add a new import for the pure helpers: + +```js +import { listAllSnapshots } from './store.js'; // add to the existing store.js import list +import { snapshotTrend, sparkline } from './snapshots.js'; +``` + +(Practically: append `listAllSnapshots` to the destructured names already imported from `./store.js` on line 6, and add the `snapshots.js` import below the `library-data.js` import on line 11.) + +- [ ] **Step 2: Add a module-level snapshot map** + +Next to `let allRows = [];` (line 44), add: + +```js +let snapsByRepo = new Map(); // repoId → snaps[] (batch-loaded once in init) +``` + +- [ ] **Step 3: Batch-load snapshots in `init()`** + +`init()` starts at line 2300 and builds rows at lines 2349-2351. Immediately before `const savedRows = points.map((p) => libraryRow(p.payload));` (line 2349), add: + +```js + snapsByRepo = await listAllSnapshots(); +``` + +- [ ] **Step 4: Render the sparkline in `card()`** + +In `card(r)`, just before the closing `
` of `.lc-meta` is built — i.e. insert a new line in the returned template right after the `.lc-meta` closing `` (after line 155) and before the `${tags || boardDots ...}` line (156): + +```js + ${(() => { + const trend = snapshotTrend(snapsByRepo.get(r.repoId) || []); + if (!trend) return ''; + const svg = sparkline(trend.series, { metric: 'health', width: 96, height: 22 }); + if (!svg) return ''; + const sign = trend.healthDelta > 0 ? '+' : ''; + const delta = trend.healthDelta != null ? `${sign}${trend.healthDelta}` : ''; + return `
${svg}${delta ? `${delta} · ` : ''}${trend.count} scans${trend.daysSpan ? ` · ${trend.daysSpan}d` : ''}
`; + })()} +``` + +- [ ] **Step 5: Add the sparkline styles** — append to `library.html` inside its ` diff --git a/library.js b/library.js index 927eb5b..2dd043a 100644 --- a/library.js +++ b/library.js @@ -3,12 +3,13 @@ // show), and each card manages its repo: click to reopen the saved analysis, hover for // re-scan / source / remove actions. -import { scrollPoints, deleteRepo, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision } from './store.js'; +import { scrollPoints, deleteRepo, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision, listAllSnapshots } from './store.js'; import { rankRepos } from './store/search.js'; import { DECISION_META } from './decision-log.js'; import { makeCollection, validateCollectionName, addRepoToCollection, toggleRepoInCollection, collectionContains, sortedCollections, repoCollections, removeRepoFromCollection, nextColor, COLLECTION_COLORS } from './collections.js'; import { listCached, removeCached, openCachedAnalysis, importCache, clearCache } from './cache.js'; import { libraryRow, sortRows, filterRows, allCapabilities, relativeTime, sourceUrl, mergeRows, libraryStats } from './library-data.js'; +import { snapshotTrend, sparkline } from './snapshots.js'; import { buildBackup, validateBackup, summarizeBackup, backupFilename } from './backup.js'; import { detectPlatform } from './url-detector.js'; import { html, escapeHtml as esc } from './safe-html.js'; @@ -42,6 +43,7 @@ function hilite(text, q) { } let allRows = []; +let snapsByRepo = new Map(); // repoId → snaps[] (batch-loaded once in init) let cacheByRepo = new Map(); // repoId → full cached analysis (instant reopen) let decisionMap = new Map(); // repoId → decision payload const state = { query: '', sort: 'fit', capability: '', collection: '', decision: '', lang: '', view: 'list' }; @@ -153,6 +155,15 @@ function card(r) { ${isToday ? `Today` : when ? `scanned ${esc(when)}` : ''} ${isStale ? `⟳ stale` : ''} + ${(() => { + const trend = snapshotTrend(snapsByRepo.get(r.repoId) || []); + if (!trend) return ''; + const svg = sparkline(trend.series, { metric: 'health', width: 96, height: 22 }); + if (!svg) return ''; + const sign = trend.healthDelta > 0 ? '+' : ''; + const delta = trend.healthDelta != null ? `${sign}${trend.healthDelta}` : ''; + return `
${svg}${delta ? `${delta} · ` : ''}${trend.count} scans${trend.daysSpan ? ` · ${trend.daysSpan}d` : ''}
`; + })()} ${tags || boardDots ? `
${tags}${boardDots}
` : ''}
@@ -2346,6 +2357,7 @@ async function init() { // Saved-library rows win (richer capabilities); local cache fills the gaps (repos // scanned with auto-save off) and supplies a blurb for older payloads. + snapsByRepo = await listAllSnapshots(); const savedRows = points.map((p) => libraryRow(p.payload)); const cacheRows = cachedList.filter((c) => c && c.repoId).map((c) => libraryRow(c)); allRows = mergeRows(savedRows, cacheRows).map((r) => { From 25e43abbc7c00ab0c50ad5270460d77f532825c9 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:23:38 -0700 Subject: [PATCH 09/10] feat(ledger): History strip on the Verdict tab --- output-tab.html | 13 +++++++++++++ output-tab.js | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/output-tab.html b/output-tab.html index d83fa87..8318006 100644 --- a/output-tab.html +++ b/output-tab.html @@ -752,6 +752,18 @@ .guide-veil.open { visibility: visible; opacity: 1; transition: opacity var(--dur-slow) var(--ease-out); } .guide { transform: translateY(10px) scale(0.985); transition: transform var(--dur-slow) var(--ease-out); } .guide-veil.open .guide { transform: none; } + + .sh-card { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin: 16px 0; background: var(--surface); } + .sh-head { font: 600 11px ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint); margin-bottom: 8px; } + .sh-row { display: grid; grid-template-columns: 56px 1fr; gap: 10px; align-items: center; padding: 5px 0; border-top: 1px solid var(--border-2); font-size: 13px; } + .sh-k { font: 600 10px ui-monospace, monospace; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); } + .sh-v { color: var(--text-sub); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .sh-v b { color: var(--text); } + .sh-card.sh-fit-strong .rl-spark { color: var(--ok-ink, #2f7d34); } + .sh-card.sh-fit-solid .rl-spark { color: var(--accent, #2f5fae); } + .sh-card.sh-fit-care .rl-spark { color: var(--warn-ink, #b8902a); } + .sh-card.sh-fit-risky .rl-spark { color: var(--bad-ink, #b4322a); } + .sh-arrow { color: var(--ok-ink, #2f7d34); font-weight: 700; } } @@ -827,6 +839,7 @@
+
diff --git a/output-tab.js b/output-tab.js index 96a394a..95edf83 100644 --- a/output-tab.js +++ b/output-tab.js @@ -27,6 +27,8 @@ import { FITS_VERDICTS } from './fits-stack.js'; import { initPalette } from './palette.js'; import { toggleRepoInCollection, collectionContains, sortedCollections, COLLECTION_COLORS } from './collections.js'; import { detectPlatform } from './url-detector.js'; +import { listSnapshots } from './store.js'; +import { snapshotTrend, sparkline } from './snapshots.js'; // Apply the saved theme ASAP (before render) to minimise flash. initTheme(); @@ -712,10 +714,32 @@ function renderHighlights(d) { }); } +async function renderHistory(d) { + const host = document.getElementById('scan-history'); + if (!host || !d || !d.repoId) return; + const trend = snapshotTrend(await listSnapshots(d.repoId)); + if (!trend) { host.innerHTML = ''; return; } + const svg = sparkline(trend.series, { metric: 'health', width: 160, height: 30 }) || ''; + const sign = trend.healthDelta > 0 ? '+' : ''; + const healthLine = trend.series.map((s) => (s.health == null ? '–' : s.health)).join(' → '); + const fitLine = trend.series.map((s) => esc(s.fit)).join(' → '); + const arrow = trend.fitDirection === 'up' ? '↑' : trend.fitDirection === 'down' ? '↓' : ''; + const resolved = trend.flagsResolved.length ? `−${trend.flagsResolved.length} resolved` : ''; + const added = trend.flagsNew.length ? `+${trend.flagsNew.length} new` : ''; + const flags = [resolved, added].filter(Boolean).join(' · ') || 'no flag changes'; + host.innerHTML = `
+
History · ${trend.count} scans${trend.daysSpan ? ` · ${trend.daysSpan}d` : ''}
+
Health${svg} ${esc(healthLine)} ${trend.healthDelta != null ? `(${sign}${trend.healthDelta})` : ''}
+
Fit${fitLine} ${arrow}
+
Flags${esc(flags)}
+
`; +} + function renderPage(d) { renderHeader(d); renderTabs(d); renderHighlights(d); + renderHistory(d); } // ─── Deep Dive tab ──────────────────────────────────────────────────────────── From 08ce2635301243c9d7b085adfc381138c9d93779 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:31:11 -0700 Subject: [PATCH 10/10] fix(ledger): back up decisions, clamp imported snaps to 30, isolate ledger tests --- backup.js | 6 +++++- library.js | 2 +- tests/backup.test.js | 8 ++++++++ tests/store.test.js | 5 +++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backup.js b/backup.js index a8bdd3f..fe2990c 100644 --- a/backup.js +++ b/backup.js @@ -15,6 +15,10 @@ export const BACKUP_VERSION = 2; // past these is dropped with a surfaced warning (never silently). export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000 }; +// Per-repo snapshot ring-buffer cap (mirrors SNAPSHOT_CAP in snapshots.js); each +// imported snapshots row is trimmed to its most recent SNAP_CAP entries. +const SNAP_CAP = 30; + const arr = (x) => (Array.isArray(x) ? x : []); const rowHasRepo = (r) => !!(r && r.id != null && r.payload && r.payload.repoId); const rowHasId = (r) => !!(r && r.id != null && r.payload != null); @@ -83,7 +87,7 @@ export function validateBackup(obj) { cache: clamp('cache', arr(obj.cache).filter(cacheOk)), collections: clamp('collections', arr(obj.collections).filter(collectionOk)), decisions: clamp('decisions', arr(obj.decisions).filter(decisionOk)), - snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk)), + snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({ ...r, snaps: arr(r.snaps).slice(-SNAP_CAP) }))), }; return { ok: errors.length === 0, errors, warnings, value }; } diff --git a/library.js b/library.js index 2dd043a..5824f55 100644 --- a/library.js +++ b/library.js @@ -870,7 +870,7 @@ async function exportLibrary() { try { setStatus('Preparing backup…'); const [stores, cached] = await Promise.all([exportStores(), listCached().catch(() => [])]); - const backup = buildBackup({ repos: stores.repos, nodes: stores.nodes, edges: stores.edges, cache: cached, collections: stores.collections, snapshots: stores.snapshots }); + const backup = buildBackup({ repos: stores.repos, nodes: stores.nodes, edges: stores.edges, cache: cached, collections: stores.collections, decisions: stores.decisions, snapshots: stores.snapshots }); const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/tests/backup.test.js b/tests/backup.test.js index 1d9a081..9535e5d 100644 --- a/tests/backup.test.js +++ b/tests/backup.test.js @@ -121,6 +121,14 @@ describe('backupFilename', () => { describe('backup: snapshots', () => { const snapRow = { id: 1, repoId: 'a/b', snaps: [{ ts: '2026-06-01T00:00:00.000Z', health: 80, fit: 'solid', stars: 1, flags: [] }] }; + it('clamps an imported snapshots row to the 30 most-recent entries', () => { + const big = { id: 9, repoId: 'big/repo', snaps: Array.from({ length: 50 }, (_, i) => ({ ts: `2026-06-01T00:00:${String(i).padStart(2, '0')}.000Z`, health: i, fit: 'solid', stars: 0, flags: [] })) }; + const { value } = validateBackup({ format: 'repolens-backup', version: BACKUP_VERSION, snapshots: [big] }); + expect(value.snapshots[0].snaps).toHaveLength(30); + expect(value.snapshots[0].snaps[0].health).toBe(20); // oldest 20 dropped, keeps the most recent 30 (20..49) + expect(value.snapshots[0].snaps[29].health).toBe(49); + }); + it('buildBackup includes snapshots and counts them', () => { const b = buildBackup({ snapshots: [snapRow], exportedAt: '2026-06-15T00:00:00.000Z' }); expect(b.version).toBe(BACKUP_VERSION); diff --git a/tests/store.test.js b/tests/store.test.js index 20c3d6b..98fb06b 100644 --- a/tests/store.test.js +++ b/tests/store.test.js @@ -115,6 +115,11 @@ describe('store — graph', () => { import { appendScanSnapshot, listSnapshots, listAllSnapshots } from '../store.js'; describe('scan ledger', () => { + beforeEach(async () => { + await idbClear('snapshots'); + await idbClear('repos'); + }); + it('saveRepo records a snapshot and re-scan appends a second point', async () => { await saveRepo({ repoId: 'led/one', health: 70, stars: 10, red_flags: [] }); await saveRepo({ repoId: 'led/one', health: 90, stars: 20, red_flags: [] });