diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json deleted file mode 100644 index 55b4353c..00000000 --- a/.agents/plugins/marketplace.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "subminer-local", - "interface": { - "displayName": "SubMiner Local" - }, - "plugins": [ - { - "name": "subminer-workflow", - "source": { - "source": "local", - "path": "./plugins/subminer-workflow" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - } - ] -} diff --git a/.agents/skills/subminer-change-verification/SKILL.md b/.agents/skills/subminer-change-verification/SKILL.md index 63771261..24f44f23 100644 --- a/.agents/skills/subminer-change-verification/SKILL.md +++ b/.agents/skills/subminer-change-verification/SKILL.md @@ -1,22 +1,45 @@ --- -name: 'subminer-change-verification' -description: 'Compatibility shim. Canonical SubMiner change verification workflow now lives in the repo-local subminer-workflow plugin.' +name: subminer-change-verification +description: Verify SubMiner changes with repo-native cheap-first test lanes. Use after code, config, launcher, plugin, runtime, stats, documentation, or workflow changes; do not use for read-only questions. --- -# Compatibility Shim +# SubMiner Change Verification -Canonical source: +Verify the behavior claimed by a change without running unrelated expensive checks by default. -- `plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md` +## Workflow -Canonical helper scripts: +1. Inspect the requested scope and changed paths with `git status --short` and `git diff`. +2. Read `docs/workflow/verification.md` as the source of truth for maintained lanes. +3. Run the cheapest lane or lanes that cover the changed behavior. +4. Escalate to the full handoff gate only for substantial or cross-boundary changes. +5. Report exact commands, results, skipped checks, blockers, and remaining risk. -- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh` -- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh` +Do not use hidden wrapper commands. Verification commands are owned by `package.json` and the workflow documentation. -When this shim is invoked: +## Lane Selection -1. Read the canonical plugin-owned skill. -2. Follow the plugin-owned skill as the source of truth. -3. Use the wrapper scripts in this shim directory only for compatibility with existing commands and docs. -4. Do not duplicate workflow changes here; update the plugin-owned skill and scripts instead. +- Internal docs, `AGENTS.md`, or `.agents/skills/**`: `bun run test:docs:kb` +- User-facing `docs-site/**`: `bun run docs:test`, then `bun run docs:build` +- Config/schema/defaults: `bun run test:config` + - If defaults or templates changed, also run `bun run generate:config-example` and `bun run verify:config-example`. +- General TypeScript source: `bun run typecheck`, then `bun run test:fast` +- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed +- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat` +- Stats dashboard: `bun run test:stats` +- Build/release scripts: `bun run test:scripts` + +For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`. + +## Runtime Escalation + +Real runtime checks are required when the claim depends on actual Electron, mpv, overlay, focus, window tracking, launch, or socket behavior. Run the relevant application flow when the environment supports it. Otherwise, report the missing runtime dependency and do not present cheaper checks as authoritative runtime validation. + +## Pre-Handoff Checks + +Before handoff, reconcile both questions: + +1. Do behavior, defaults, flags, shortcuts, ports, APIs, architecture, or workflow changes require documentation updates? +2. Does the change require a current-outcome fragment under `changes/` according to `changes/README.md`? + +Complete required updates before handoff or report the blocker. diff --git a/.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh b/.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh deleted file mode 100755 index 4c7acce1..00000000 --- a/.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd) -TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh" - -if [[ ! -x "$TARGET" ]]; then - echo "Missing canonical script: $TARGET" >&2 - exit 1 -fi - -exec "$TARGET" "$@" diff --git a/.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh b/.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh deleted file mode 100755 index 58cdd64d..00000000 --- a/.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd) -TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh" - -if [[ ! -x "$TARGET" ]]; then - echo "Missing canonical script: $TARGET" >&2 - exit 1 -fi - -exec "$TARGET" "$@" diff --git a/.agents/skills/subminer-scrum-master/SKILL.md b/.agents/skills/subminer-scrum-master/SKILL.md deleted file mode 100644 index 94dad073..00000000 --- a/.agents/skills/subminer-scrum-master/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: 'subminer-scrum-master' -description: 'Compatibility shim. Canonical SubMiner scrum-master workflow now lives in the repo-local subminer-workflow plugin.' ---- - -# Compatibility Shim - -Canonical source: - -- `plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md` - -When this shim is invoked: - -1. Read the canonical plugin-owned skill. -2. Follow the plugin-owned skill as the source of truth. -3. Do not duplicate workflow changes here; update the plugin-owned skill instead. - -This shim exists so existing repo references and prompts keep resolving during the migration to the repo-local plugin workflow. diff --git a/AGENTS.md b/AGENTS.md index 7be4dee1..ea2191a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# AGENTS.MD +# AGENTS.md ## Internal Docs @@ -13,7 +13,7 @@ Start here, then leave this file. `docs-site/` is user-facing. Do not treat it as the canonical internal source of truth. -`CLAUDE.md` is a symlink to this file — there is one project instruction file, not two. +`CLAUDE.md` is a symlink to this file; there is one project instruction file, not two. ## Quick Start @@ -25,8 +25,9 @@ Start here, then leave this file. ## Build / Test -- Runtime/package manager: Bun (`packageManager: bun@1.3.5`) -- Default handoff gate: +- Runtime/package manager: Bun; use the version pinned by `package.json`. +- Follow [`docs/workflow/verification.md`](./docs/workflow/verification.md) and start with the cheapest sufficient lane. +- Full handoff gate for substantial changes: `bun run typecheck` `bun run test:fast` `bun run test:env` @@ -44,13 +45,15 @@ Start here, then leave this file. - Runtime-compat / dist-sensitive: `bun run test:runtime:compat` - Stats dashboard UI (`stats/`): `bun run test:stats` - Build/release scripts (`scripts/**`): `bun run test:scripts` -- Docs-only: `bun run docs:test`, then `bun run docs:build` +- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb` +- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build` +- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts` - Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json` ## Docs Upkeep - Docs ship with the change, not after. If a change alters behavior, defaults, flags, shortcuts, ports, or APIs, update the matching docs in the same PR. Touching code without reconciling its docs is an incomplete change. -- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it — and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update. +- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it, and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update. - Trigger map (touch left → update right): - `src/config/definitions/**` (schema/defaults/template) → `bun run generate:config-example`, then reconcile `docs-site/configuration.md` + any feature doc that cites that default - shortcuts/keybindings (`shortcuts.*`, `keybindings`, `stats.*Key`, `subtitleSidebar.toggleKey`, controller bindings) → `docs-site/shortcuts.md` @@ -71,16 +74,10 @@ Start here, then leave this file. ## Release / PR Notes -- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out) +- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md`. Format and rules live in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out). - User-visible docs changes get a `type: docs` fragment - CI enforces `bun run changelog:lint` and `bun run changelog:pr-check` - PR review helpers: - - `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'` + - `gh pr view --json number,title --jq '"PR #\\(.number): \\(.title)"'` - `gh api repos/:owner/:repo/pulls//comments --paginate` - -## Runtime Notes - -- Use Codex background for long jobs; tmux only when persistence/interaction is required -- CI red: `gh run list/view`, rerun, fix, repeat until green -- TypeScript: keep files small; follow existing patterns -- Only Swift is the `scripts/get-mpv-window-macos.swift` helper (macOS mpv window detection); validate via `bun test scripts/get-mpv-window-macos.test.ts` +- For CI debugging, inspect runs with `gh run list/view`; rerun or fix only within the requested scope. diff --git a/docs/knowledge-base/catalog.md b/docs/knowledge-base/catalog.md index d1235037..352865b6 100644 --- a/docs/knowledge-base/catalog.md +++ b/docs/knowledge-base/catalog.md @@ -3,7 +3,7 @@ # Documentation Catalog Status: active -Last verified: 2026-05-23 +Last verified: 2026-08-13 Owner: Kyle Yasuda Read when: finding internal docs or checking verification status @@ -17,10 +17,10 @@ Read when: finding internal docs or checking verification status | KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy | | Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles | | Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps | -| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map | +| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map | | Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans | -| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership | -| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes | +| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership | +| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes | | Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist | ## Update Rules diff --git a/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md b/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md deleted file mode 100644 index 7fb37fe4..00000000 --- a/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md +++ /dev/null @@ -1,1347 +0,0 @@ -# Library Summary Replaces Per-Day Trends — 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:** Replace the six noisy "Library — Per Day" stacked-area charts on the stats Trends tab with a single "Library — Summary" section containing a top-10 watch-time leaderboard and a sortable per-title table, both scoped to the existing date range. - -**Architecture:** Backend adds a `librarySummary: LibrarySummaryRow[]` field to the existing `/api/stats/trends/dashboard` response (aggregated from `imm_daily_rollups` + `imm_sessions` joined to `imm_videos`/`imm_anime`) and drops the now-unused `animePerDay` field. Frontend adds a new `LibrarySummarySection` React component (Recharts horizontal bar + sortable HTML table), replaces the per-day section in `TrendsTab.tsx`, and updates all test fixtures. - -**Tech Stack:** TypeScript, Bun test runner (backend), Node test runner (frontend), Recharts, React, Tailwind, SQLite (better-sqlite3 via `./sqlite` wrapper). - -**Spec:** `docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md` - ---- - -## File Structure - -**Backend (`src/core/services/immersion-tracker/`):** -- `query-trends.ts` — add `LibrarySummaryRow` type, `buildLibrarySummary` helper, wire into `getTrendsDashboard`, drop `animePerDay` from `TrendsDashboardQueryResult`, delete now-unused `buildPerAnimeFromSessions` and `buildLookupsPerHundredPerAnime`. -- `__tests__/query.test.ts` — update existing `getTrendsDashboard` test (drop `animePerDay` assertion, add `librarySummary` assertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred). - -**Backend test fixtures:** -- `src/core/services/__tests__/stats-server.test.ts` — update `TRENDS_DASHBOARD` fixture (remove `animePerDay`, add `librarySummary`), fix `assert.deepEqual` that references `body.animePerDay.watchTime`. - -**Frontend (`stats/src/`):** -- `types/stats.ts` — add `LibrarySummaryRow` interface, add `librarySummary` field to `TrendsDashboardData`, remove `animePerDay` field. -- `lib/api-client.test.ts` — update the two inline fetch-mock fixtures (remove `animePerDay`, add `librarySummary`). -- `components/trends/LibrarySummarySection.tsx` — **new** file. Owns the header content: leaderboard Recharts chart + sortable HTML table. Takes `{ rows, hiddenTitles }` as props. -- `components/trends/TrendsTab.tsx` — delete the "Library — Per Day" block (lines 224-254 and the filtered data locals 137-146), add `LibrarySummarySection` import + usage, update `buildAnimeVisibilityOptions` call to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays. -- `components/trends/anime-visibility.ts` — unchanged. The existing helpers operate on `PerAnimeDataPoint[]`; we'll adapt by passing a derived `PerAnimeDataPoint[]` built from `librarySummary` (or add an overload — see Task 7 for the final decision). - -**Changelog:** -- `changes/stats-library-summary.md` — **new** changelog fragment. - ---- - -## Task 1: Backend — Add `LibrarySummaryRow` type and empty stub field - -**Files:** -- Modify: `src/core/services/immersion-tracker/query-trends.ts` - -- [ ] **Step 1: Add the row type and add `librarySummary: []` to the returned object** - -Edit `src/core/services/immersion-tracker/query-trends.ts`. After the existing `TrendPerAnimePoint` interface (around line 24), add: - -```ts -export interface LibrarySummaryRow { - title: string; - watchTimeMin: number; - videos: number; - sessions: number; - cards: number; - words: number; - lookups: number; - lookupsPerHundred: number | null; - firstWatched: number; - lastWatched: number; -} -``` - -In the same file, add a new field to `TrendsDashboardQueryResult` (around line 45-82), alongside `animePerDay`: - -```ts -librarySummary: LibrarySummaryRow[]; -``` - -In `getTrendsDashboard` (around line 622), add `librarySummary: []` to the returned object literal (inside the final `return { ... }`). Keep everything else as-is for now. - -- [ ] **Step 2: Run typecheck** - -Run: `bun run typecheck` -Expected: PASS (empty array satisfies the new field; no downstream consumer yet). - -- [ ] **Step 3: Commit** - -```bash -git add src/core/services/immersion-tracker/query-trends.ts -git commit -m "feat(stats): scaffold LibrarySummaryRow type and empty field" -``` - ---- - -## Task 2: Backend — TDD the `buildLibrarySummary` helper - -**Files:** -- Modify: `src/core/services/immersion-tracker/query-trends.ts` -- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts` - -- [ ] **Step 1: Write a failing unit test for the happy path** - -Open `src/core/services/immersion-tracker/__tests__/query.test.ts` and add a new test at the end of the file (before the last closing brace, or after the last `test(...)` block — verify by reading the end of the file). Use the same imports/helpers the existing `getTrendsDashboard` tests use (`makeDbPath`, `ensureSchema`, `getOrCreateVideoRecord`, `getOrCreateAnimeRecord`, `linkVideoToAnimeRecord`, `startSessionRecord`, `createTrackerPreparedStatements`, `Database`, `cleanupDbPath`, `getTrendsDashboard`, `SOURCE_TYPE_LOCAL`). - -```ts -test('getTrendsDashboard builds librarySummary with per-title aggregates', () => { - const dbPath = makeDbPath(); - const db = new Database(dbPath); - - try { - ensureSchema(db); - const stmts = createTrackerPreparedStatements(db); - - const videoId = getOrCreateVideoRecord(db, 'local:/tmp/library-summary-test.mkv', { - canonicalTitle: 'Library Summary Test', - sourcePath: '/tmp/library-summary-test.mkv', - sourceUrl: null, - sourceType: SOURCE_TYPE_LOCAL, - }); - const animeId = getOrCreateAnimeRecord(db, { - parsedTitle: 'Summary Anime', - canonicalTitle: 'Summary Anime', - anilistId: null, - titleRomaji: null, - titleEnglish: null, - titleNative: null, - metadataJson: null, - }); - linkVideoToAnimeRecord(db, videoId, { - animeId, - parsedBasename: 'library-summary-test.mkv', - parsedTitle: 'Summary Anime', - parsedSeason: 1, - parsedEpisode: 1, - parserSource: 'test', - parserConfidence: 1, - parseMetadataJson: null, - }); - - const dayOneStart = 1_700_000_000_000; - const dayTwoStart = dayOneStart + 86_400_000; - - const sessionOne = startSessionRecord(db, videoId, dayOneStart); - const sessionTwo = startSessionRecord(db, videoId, dayTwoStart); - - for (const [sessionId, startedAtMs, activeMs, cards, tokens, lookups] of [ - [sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8], - [sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10], - ] as const) { - stmts.telemetryInsertStmt.run( - sessionId, - `${startedAtMs + 60_000}`, - activeMs, - activeMs, - 10, - tokens, - cards, - 0, - 0, - lookups, - 0, - 0, - 0, - 0, - `${startedAtMs + 60_000}`, - `${startedAtMs + 60_000}`, - ); - - db.prepare( - ` - UPDATE imm_sessions - SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?, - lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ? - WHERE session_id = ? - `, - ).run( - `${startedAtMs + activeMs}`, - activeMs, - activeMs, - 10, - tokens, - cards, - lookups, - sessionId, - ); - } - - for (const [day, active, tokens, cards] of [ - [Math.floor(dayOneStart / 86_400_000), 30, 120, 2], - [Math.floor(dayTwoStart / 86_400_000), 45, 140, 3], - ] as const) { - db.prepare( - ` - INSERT INTO imm_daily_rollups ( - rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, - total_tokens_seen, total_cards - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ).run(day, videoId, 1, active, 10, tokens, cards); - } - - const dashboard = getTrendsDashboard(db, 'all', 'day'); - - assert.equal(dashboard.librarySummary.length, 1); - const row = dashboard.librarySummary[0]!; - assert.equal(row.title, 'Summary Anime'); - assert.equal(row.watchTimeMin, 75); - assert.equal(row.videos, 1); - assert.equal(row.sessions, 2); - assert.equal(row.cards, 5); - assert.equal(row.words, 260); - assert.equal(row.lookups, 18); - assert.equal(row.lookupsPerHundred, +((18 / 260) * 100).toFixed(1)); - assert.equal(row.firstWatched, Math.floor(dayOneStart / 86_400_000)); - assert.equal(row.lastWatched, Math.floor(dayTwoStart / 86_400_000)); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t "librarySummary with per-title aggregates"` -Expected: FAIL — `dashboard.librarySummary.length` is `0`, not `1`. - -- [ ] **Step 3: Implement the `buildLibrarySummary` helper** - -Open `src/core/services/immersion-tracker/query-trends.ts`. Add this helper function near the other builders (e.g., after `buildCumulativePerAnime`, before `getVideoAnimeTitleMap`): - -```ts -function buildLibrarySummary( - rollups: ImmersionSessionRollupRow[], - sessions: TrendSessionMetricRow[], - titlesByVideoId: Map, -): LibrarySummaryRow[] { - type Accum = { - watchTimeMin: number; - videos: Set; - cards: number; - words: number; - firstWatched: number; - lastWatched: number; - sessions: number; - lookups: number; - }; - - const byTitle = new Map(); - - const ensure = (title: string): Accum => { - const existing = byTitle.get(title); - if (existing) return existing; - const created: Accum = { - watchTimeMin: 0, - videos: new Set(), - cards: 0, - words: 0, - firstWatched: Number.POSITIVE_INFINITY, - lastWatched: Number.NEGATIVE_INFINITY, - sessions: 0, - lookups: 0, - }; - byTitle.set(title, created); - return created; - }; - - for (const rollup of rollups) { - if (rollup.videoId === null) continue; - const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId); - const acc = ensure(title); - acc.watchTimeMin += rollup.totalActiveMin; - acc.cards += rollup.totalCards; - acc.words += rollup.totalTokensSeen; - acc.videos.add(rollup.videoId); - if (rollup.rollupDayOrMonth < acc.firstWatched) { - acc.firstWatched = rollup.rollupDayOrMonth; - } - if (rollup.rollupDayOrMonth > acc.lastWatched) { - acc.lastWatched = rollup.rollupDayOrMonth; - } - } - - for (const session of sessions) { - const title = resolveTrendAnimeTitle(session); - if (!byTitle.has(title)) continue; - const acc = byTitle.get(title)!; - acc.sessions += 1; - acc.lookups += session.yomitanLookupCount; - } - - const rows: LibrarySummaryRow[] = []; - for (const [title, acc] of byTitle) { - if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) { - continue; - } - rows.push({ - title, - watchTimeMin: Math.round(acc.watchTimeMin), - videos: acc.videos.size, - sessions: acc.sessions, - cards: acc.cards, - words: acc.words, - lookups: acc.lookups, - lookupsPerHundred: - acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null, - firstWatched: acc.firstWatched, - lastWatched: acc.lastWatched, - }); - } - - rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title)); - return rows; -} -``` - -- [ ] **Step 4: Wire `buildLibrarySummary` into `getTrendsDashboard`** - -Still in `query-trends.ts`, inside `getTrendsDashboard`, replace the stub `librarySummary: []` line with: - -```ts -librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId), -``` - -Place it at the same spot in the return object (keep existing fields otherwise unchanged). - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t "librarySummary with per-title aggregates"` -Expected: PASS. - -- [ ] **Step 6: Run the full query test file to ensure no regressions** - -Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` -Expected: PASS for all tests. - -- [ ] **Step 7: Commit** - -```bash -git add src/core/services/immersion-tracker/query-trends.ts \ - src/core/services/immersion-tracker/__tests__/query.test.ts -git commit -m "feat(stats): build per-title librarySummary from daily rollups and sessions" -``` - ---- - -## Task 3: Backend — Add null-lookupsPerHundred and empty-window tests - -**Files:** -- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts` - -- [ ] **Step 1: Write a failing test for `lookupsPerHundred: null` when words == 0** - -Append to `src/core/services/immersion-tracker/__tests__/query.test.ts`: - -```ts -test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => { - const dbPath = makeDbPath(); - const db = new Database(dbPath); - - try { - ensureSchema(db); - const stmts = createTrackerPreparedStatements(db); - - const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lib-summary-null.mkv', { - canonicalTitle: 'Null Lookups Title', - sourcePath: '/tmp/lib-summary-null.mkv', - sourceUrl: null, - sourceType: SOURCE_TYPE_LOCAL, - }); - const animeId = getOrCreateAnimeRecord(db, { - parsedTitle: 'Null Lookups Anime', - canonicalTitle: 'Null Lookups Anime', - anilistId: null, - titleRomaji: null, - titleEnglish: null, - titleNative: null, - metadataJson: null, - }); - linkVideoToAnimeRecord(db, videoId, { - animeId, - parsedBasename: 'lib-summary-null.mkv', - parsedTitle: 'Null Lookups Anime', - parsedSeason: 1, - parsedEpisode: 1, - parserSource: 'test', - parserConfidence: 1, - parseMetadataJson: null, - }); - - const startMs = 1_700_000_000_000; - const session = startSessionRecord(db, videoId, startMs); - stmts.telemetryInsertStmt.run( - session.sessionId, - `${startMs + 60_000}`, - 20 * 60_000, - 20 * 60_000, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - `${startMs + 60_000}`, - `${startMs + 60_000}`, - ); - db.prepare( - ` - UPDATE imm_sessions - SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?, - lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ? - WHERE session_id = ? - `, - ).run( - `${startMs + 20 * 60_000}`, - 20 * 60_000, - 20 * 60_000, - 5, - 0, - 0, - 0, - session.sessionId, - ); - - db.prepare( - ` - INSERT INTO imm_daily_rollups ( - rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, - total_tokens_seen, total_cards - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ).run(Math.floor(startMs / 86_400_000), videoId, 1, 20, 5, 0, 0); - - const dashboard = getTrendsDashboard(db, 'all', 'day'); - assert.equal(dashboard.librarySummary.length, 1); - assert.equal(dashboard.librarySummary[0]!.lookupsPerHundred, null); - assert.equal(dashboard.librarySummary[0]!.words, 0); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - -test('getTrendsDashboard librarySummary is empty when no rollups exist', () => { - const dbPath = makeDbPath(); - const db = new Database(dbPath); - - try { - ensureSchema(db); - const dashboard = getTrendsDashboard(db, 'all', 'day'); - assert.deepEqual(dashboard.librarySummary, []); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); -``` - -- [ ] **Step 2: Run the new tests** - -Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t "librarySummary"` -Expected: PASS for all three librarySummary tests (the helper implemented in Task 2 already handles these cases). - -- [ ] **Step 3: Commit** - -```bash -git add src/core/services/immersion-tracker/__tests__/query.test.ts -git commit -m "test(stats): cover librarySummary null-lookups and empty-window cases" -``` - ---- - -## Task 4: Backend — Drop `animePerDay` from the response type and clean up dead helpers - -**Files:** -- Modify: `src/core/services/immersion-tracker/query-trends.ts` -- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts` -- Modify: `src/core/services/__tests__/stats-server.test.ts` - -- [ ] **Step 1: Remove `animePerDay` from `TrendsDashboardQueryResult`** - -In `src/core/services/immersion-tracker/query-trends.ts`, delete the `animePerDay` block from the interface (lines ~64-71): - -```ts -// Delete this block: -animePerDay: { - episodes: TrendPerAnimePoint[]; - watchTime: TrendPerAnimePoint[]; - cards: TrendPerAnimePoint[]; - words: TrendPerAnimePoint[]; - lookups: TrendPerAnimePoint[]; - lookupsPerHundred: TrendPerAnimePoint[]; -}; -``` - -- [ ] **Step 2: Scope the intermediate `animePerDay` to a local variable and drop it from the return** - -In `getTrendsDashboard` (around lines 649-668 and 694-699), keep the internal `animePerDay` construction (it's still used by `animeCumulative`) but do NOT include it in the returned object. Also drop the now-unused `lookups` and `lookupsPerHundred` fields from the internal `animePerDay` object. Replace the block starting with `const animePerDay = {` through the return statement: - -```ts - const animePerDay = { - episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId), - watchTime: buildPerAnimeFromDailyRollups( - dailyRollups, - titlesByVideoId, - (rollup) => rollup.totalActiveMin, - ), - cards: buildPerAnimeFromDailyRollups( - dailyRollups, - titlesByVideoId, - (rollup) => rollup.totalCards, - ), - words: buildPerAnimeFromDailyRollups( - dailyRollups, - titlesByVideoId, - (rollup) => rollup.totalTokensSeen, - ), - }; - - return { - activity, - progress: { - watchTime: accumulatePoints(activity.watchTime), - sessions: accumulatePoints(activity.sessions), - words: accumulatePoints(activity.words), - newWords: accumulatePoints( - useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs), - ), - cards: accumulatePoints(activity.cards), - episodes: accumulatePoints( - useMonthlyBuckets - ? buildEpisodesPerMonthFromRollups(monthlyRollups) - : buildEpisodesPerDayFromDailyRollups(dailyRollups), - ), - lookups: accumulatePoints( - useMonthlyBuckets - ? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount) - : buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount), - ), - }, - ratios: { - lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), - }, - librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId), - animeCumulative: { - watchTime: buildCumulativePerAnime(animePerDay.watchTime), - episodes: buildCumulativePerAnime(animePerDay.episodes), - cards: buildCumulativePerAnime(animePerDay.cards), - words: buildCumulativePerAnime(animePerDay.words), - }, - patterns: { - watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), - watchTimeByHour: buildWatchTimeByHour(sessions), - }, - }; -``` - -- [ ] **Step 3: Delete now-unused helpers** - -In the same file, delete the functions `buildPerAnimeFromSessions` (around lines 304-325) and `buildLookupsPerHundredPerAnime` (around lines 327-357). Nothing else references them after Step 2. - -- [ ] **Step 4: Update the existing `getTrendsDashboard` test assertion that references `animePerDay`** - -In `src/core/services/immersion-tracker/__tests__/query.test.ts`, find the test `getTrendsDashboard returns chart-ready aggregated series`. Replace the line: - -```ts -assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime'); -``` - -with: - -```ts -assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime'); -``` - -- [ ] **Step 5: Update the stats-server test fixture** - -In `src/core/services/__tests__/stats-server.test.ts`, find `TRENDS_DASHBOARD` (around line 150). Remove the entire `animePerDay: { ... }` block (lines ~169-176). Add a `librarySummary` field inside the fixture (anywhere appropriate — before `animeCumulative` is fine): - -```ts - librarySummary: [ - { - title: 'Little Witch Academia', - watchTimeMin: 25, - videos: 1, - sessions: 1, - cards: 5, - words: 300, - lookups: 15, - lookupsPerHundred: 5, - firstWatched: 20_000, - lastWatched: 20_000, - }, - ], -``` - -Then find the assertion around line 601: - -```ts -assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime); -``` - -Replace it with: - -```ts -assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary); -``` - -- [ ] **Step 6: Run typecheck + backend tests** - -Run: `bun run typecheck` -Expected: PASS. - -Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` -Expected: PASS. - -Run: `bun test src/core/services/__tests__/stats-server.test.ts` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/core/services/immersion-tracker/query-trends.ts \ - src/core/services/immersion-tracker/__tests__/query.test.ts \ - src/core/services/__tests__/stats-server.test.ts -git commit -m "refactor(stats): drop animePerDay from trends response in favor of librarySummary" -``` - ---- - -## Task 5: Frontend — Update types and api-client test fixtures - -**Files:** -- Modify: `stats/src/types/stats.ts` -- Modify: `stats/src/lib/api-client.test.ts` - -- [ ] **Step 1: Add `LibrarySummaryRow` and update `TrendsDashboardData` in `stats/src/types/stats.ts`** - -Add above `TrendsDashboardData` (around line 291): - -```ts -export interface LibrarySummaryRow { - title: string; - watchTimeMin: number; - videos: number; - sessions: number; - cards: number; - words: number; - lookups: number; - lookupsPerHundred: number | null; - firstWatched: number; - lastWatched: number; -} -``` - -Inside `TrendsDashboardData`, delete the `animePerDay` block (lines ~310-317) and add `librarySummary: LibrarySummaryRow[];` (place it before `animeCumulative`): - -```ts -export interface TrendsDashboardData { - activity: { - watchTime: TrendChartPoint[]; - cards: TrendChartPoint[]; - words: TrendChartPoint[]; - sessions: TrendChartPoint[]; - }; - progress: { - watchTime: TrendChartPoint[]; - sessions: TrendChartPoint[]; - words: TrendChartPoint[]; - newWords: TrendChartPoint[]; - cards: TrendChartPoint[]; - episodes: TrendChartPoint[]; - lookups: TrendChartPoint[]; - }; - ratios: { - lookupsPerHundred: TrendChartPoint[]; - }; - librarySummary: LibrarySummaryRow[]; - animeCumulative: { - watchTime: TrendPerAnimePoint[]; - episodes: TrendPerAnimePoint[]; - cards: TrendPerAnimePoint[]; - words: TrendPerAnimePoint[]; - }; - patterns: { - watchTimeByDayOfWeek: TrendChartPoint[]; - watchTimeByHour: TrendChartPoint[]; - }; -} -``` - -- [ ] **Step 2: Update the two inline fixtures in `stats/src/lib/api-client.test.ts`** - -Find both inline `JSON.stringify({ ... })` fetch-mock bodies (around lines 75-107 and 123-150). In **both** blocks, delete the `animePerDay: { ... }` object and replace with: - -```ts -librarySummary: [], -``` - -(Insert before `animeCumulative`.) - -- [ ] **Step 3: Run frontend typecheck and tests** - -Run: `cd stats && bun run typecheck` -Expected: FAIL — `TrendsTab.tsx` still references `data.animePerDay`. That's expected; we fix it in Task 8. Continue. - -Run: `cd stats && bun test src/lib/api-client.test.ts` -Expected: PASS (the test only asserts URL construction, not response shape). - -- [ ] **Step 4: Commit** - -```bash -git add stats/src/types/stats.ts stats/src/lib/api-client.test.ts -git commit -m "refactor(stats): replace animePerDay type with librarySummary" -``` - ---- - -## Task 6: Frontend — Create `LibrarySummarySection` skeleton with empty state - -**Files:** -- Create: `stats/src/components/trends/LibrarySummarySection.tsx` - -- [ ] **Step 1: Create the file with the empty state and props plumbing** - -Create `stats/src/components/trends/LibrarySummarySection.tsx`: - -```tsx -import type { LibrarySummaryRow } from '../../types/stats'; - -interface LibrarySummarySectionProps { - rows: LibrarySummaryRow[]; - hiddenTitles: ReadonlySet; -} - -export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) { - const visibleRows = rows.filter((row) => !hiddenTitles.has(row.title)); - - if (visibleRows.length === 0) { - return ( -
-
No library activity in the selected window.
-
- ); - } - - return ( - <> - {/* Leaderboard + table cards added in Tasks 7 and 8 */} -
-
- Library summary: {visibleRows.length} titles -
-
- - ); -} -``` - -- [ ] **Step 2: Run typecheck (it will still fail in `TrendsTab.tsx`, but the new file should typecheck cleanly)** - -Run: `cd stats && bun run typecheck 2>&1 | grep -E 'LibrarySummarySection\.tsx'` -Expected: no output (new file has no type errors). `TrendsTab.tsx` still errors — ignore until Task 8. - -- [ ] **Step 3: Commit** - -```bash -git add stats/src/components/trends/LibrarySummarySection.tsx -git commit -m "feat(stats): scaffold LibrarySummarySection with empty state" -``` - ---- - -## Task 7: Frontend — Add the leaderboard bar chart to `LibrarySummarySection` - -**Files:** -- Modify: `stats/src/components/trends/LibrarySummarySection.tsx` - -- [ ] **Step 1: Replace the skeleton body with the leaderboard chart** - -Replace the entire contents of `stats/src/components/trends/LibrarySummarySection.tsx` with: - -```tsx -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import type { LibrarySummaryRow } from '../../types/stats'; -import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; - -interface LibrarySummarySectionProps { - rows: LibrarySummaryRow[]; - hiddenTitles: ReadonlySet; -} - -const LEADERBOARD_LIMIT = 10; -const LEADERBOARD_HEIGHT = 260; -const LEADERBOARD_BAR_COLOR = '#8aadf4'; - -function truncateTitle(title: string, maxChars: number): string { - if (title.length <= maxChars) return title; - return `${title.slice(0, maxChars - 1)}…`; -} - -export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) { - const visibleRows = rows.filter((row) => !hiddenTitles.has(row.title)); - - if (visibleRows.length === 0) { - return ( -
-
No library activity in the selected window.
-
- ); - } - - const leaderboard = [...visibleRows] - .sort((a, b) => b.watchTimeMin - a.watchTimeMin) - .slice(0, LEADERBOARD_LIMIT) - .map((row) => ({ - title: row.title, - displayTitle: truncateTitle(row.title, 24), - watchTimeMin: row.watchTimeMin, - })); - - return ( - <> -
-

- Top Titles by Watch Time (min) -

- - - - - - [`${value} min`, 'Watch Time']} - labelFormatter={(_label, payload) => { - const datum = payload?.[0]?.payload as { title?: string } | undefined; - return datum?.title ?? ''; - }} - /> - - - -
- {/* Table card added in Task 8 */} - - ); -} -``` - -- [ ] **Step 2: Typecheck (component in isolation)** - -Run: `cd stats && bun run typecheck 2>&1 | grep -E 'LibrarySummarySection\.tsx'` -Expected: no output — new component typechecks. `TrendsTab.tsx` errors remain (fixed in Task 9). - -- [ ] **Step 3: Commit** - -```bash -git add stats/src/components/trends/LibrarySummarySection.tsx -git commit -m "feat(stats): add top-titles leaderboard chart to LibrarySummarySection" -``` - ---- - -## Task 8: Frontend — Add the sortable table to `LibrarySummarySection` - -**Files:** -- Modify: `stats/src/components/trends/LibrarySummarySection.tsx` - -- [ ] **Step 1: Add sort state, column definitions, and the table markup** - -Replace the entire file with the version below. The change vs. Task 7: imports `useState`, `useMemo`, and `formatDuration` + `epochDayToDate`; adds `SortColumn`, `SortDirection`, `COLUMNS`; adds a `` card after the leaderboard card. - -```tsx -import { useMemo, useState } from 'react'; -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import type { LibrarySummaryRow } from '../../types/stats'; -import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; -import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters'; - -interface LibrarySummarySectionProps { - rows: LibrarySummaryRow[]; - hiddenTitles: ReadonlySet; -} - -const LEADERBOARD_LIMIT = 10; -const LEADERBOARD_HEIGHT = 260; -const LEADERBOARD_BAR_COLOR = '#8aadf4'; -const TABLE_MAX_HEIGHT = 480; - -type SortColumn = - | 'title' - | 'watchTimeMin' - | 'videos' - | 'sessions' - | 'cards' - | 'words' - | 'lookups' - | 'lookupsPerHundred' - | 'firstWatched'; - -type SortDirection = 'asc' | 'desc'; - -interface ColumnDef { - id: SortColumn; - label: string; - align: 'left' | 'right'; -} - -const COLUMNS: ColumnDef[] = [ - { id: 'title', label: 'Title', align: 'left' }, - { id: 'watchTimeMin', label: 'Watch Time', align: 'right' }, - { id: 'videos', label: 'Videos', align: 'right' }, - { id: 'sessions', label: 'Sessions', align: 'right' }, - { id: 'cards', label: 'Cards', align: 'right' }, - { id: 'words', label: 'Words', align: 'right' }, - { id: 'lookups', label: 'Lookups', align: 'right' }, - { id: 'lookupsPerHundred', label: 'Lookups/100w', align: 'right' }, - { id: 'firstWatched', label: 'Date Range', align: 'right' }, -]; - -function truncateTitle(title: string, maxChars: number): string { - if (title.length <= maxChars) return title; - return `${title.slice(0, maxChars - 1)}…`; -} - -function formatDateRange(firstEpochDay: number, lastEpochDay: number): string { - const fmt = (epochDay: number) => - epochDayToDate(epochDay).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }); - if (firstEpochDay === lastEpochDay) return fmt(firstEpochDay); - return `${fmt(firstEpochDay)} → ${fmt(lastEpochDay)}`; -} - -function formatWatchTime(min: number): string { - return formatDuration(min * 60_000); -} - -function compareRows( - a: LibrarySummaryRow, - b: LibrarySummaryRow, - column: SortColumn, - direction: SortDirection, -): number { - const sign = direction === 'asc' ? 1 : -1; - - if (column === 'title') { - return a.title.localeCompare(b.title) * sign; - } - - if (column === 'firstWatched') { - return (a.firstWatched - b.firstWatched) * sign; - } - - if (column === 'lookupsPerHundred') { - // Null sorts as lowest in both directions (treated as "no data"). - const aVal = a.lookupsPerHundred; - const bVal = b.lookupsPerHundred; - if (aVal === null && bVal === null) return 0; - if (aVal === null) return 1; - if (bVal === null) return -1; - return (aVal - bVal) * sign; - } - - const aVal = a[column] as number; - const bVal = b[column] as number; - return (aVal - bVal) * sign; -} - -export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) { - const [sortColumn, setSortColumn] = useState('watchTimeMin'); - const [sortDirection, setSortDirection] = useState('desc'); - - const visibleRows = useMemo( - () => rows.filter((row) => !hiddenTitles.has(row.title)), - [rows, hiddenTitles], - ); - - const sortedRows = useMemo( - () => [...visibleRows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)), - [visibleRows, sortColumn, sortDirection], - ); - - const leaderboard = useMemo( - () => - [...visibleRows] - .sort((a, b) => b.watchTimeMin - a.watchTimeMin) - .slice(0, LEADERBOARD_LIMIT) - .map((row) => ({ - title: row.title, - displayTitle: truncateTitle(row.title, 24), - watchTimeMin: row.watchTimeMin, - })), - [visibleRows], - ); - - if (visibleRows.length === 0) { - return ( -
-
- No library activity in the selected window. -
-
- ); - } - - const handleHeaderClick = (column: SortColumn) => { - if (column === sortColumn) { - setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); - } else { - setSortColumn(column); - setSortDirection(column === 'title' ? 'asc' : 'desc'); - } - }; - - return ( - <> -
-

- Top Titles by Watch Time (min) -

- - - - - - [`${value} min`, 'Watch Time']} - labelFormatter={(_label, payload) => { - const datum = payload?.[0]?.payload as { title?: string } | undefined; - return datum?.title ?? ''; - }} - /> - - - -
-
-

Per-Title Summary

-
-
- - - {COLUMNS.map((column) => { - const isActive = column.id === sortColumn; - const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : ''; - return ( - - ); - })} - - - - {sortedRows.map((row) => ( - - - - - - - - - - - - ))} - -
handleHeaderClick(column.id)} - > - {column.label} - {indicator} -
- {row.title} - - {formatWatchTime(row.watchTimeMin)} - - {formatNumber(row.videos)} - - {formatNumber(row.sessions)} - - {formatNumber(row.cards)} - - {formatNumber(row.words)} - - {formatNumber(row.lookups)} - - {row.lookupsPerHundred === null - ? '—' - : row.lookupsPerHundred.toFixed(1)} - - {formatDateRange(row.firstWatched, row.lastWatched)} -
- - - - ); -} -``` - -- [ ] **Step 2: Typecheck the new component** - -Run: `cd stats && bun run typecheck 2>&1 | grep -E 'LibrarySummarySection\.tsx'` -Expected: no output. `TrendsTab.tsx` errors still remain — next task fixes them. - -- [ ] **Step 3: Commit** - -```bash -git add stats/src/components/trends/LibrarySummarySection.tsx -git commit -m "feat(stats): add sortable per-title table to LibrarySummarySection" -``` - ---- - -## Task 9: Frontend — Wire `LibrarySummarySection` into `TrendsTab` and remove the per-day block - -**Files:** -- Modify: `stats/src/components/trends/TrendsTab.tsx` - -- [ ] **Step 1: Delete the per-day filtered locals and imports** - -In `stats/src/components/trends/TrendsTab.tsx`: - -Delete these locals (currently lines ~129-146): - -```ts -const filteredEpisodesPerAnime = filterHiddenAnimeData( - data.animePerDay.episodes, - activeHiddenAnime, -); -const filteredWatchTimePerAnime = filterHiddenAnimeData( - data.animePerDay.watchTime, - activeHiddenAnime, -); -const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime); -const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime); -const filteredLookupsPerAnime = filterHiddenAnimeData( - data.animePerDay.lookups, - activeHiddenAnime, -); -const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData( - data.animePerDay.lookupsPerHundred, - activeHiddenAnime, -); -``` - -- [ ] **Step 2: Update `buildAnimeVisibilityOptions` to use `librarySummary` titles** - -Replace the existing `const animeTitles = buildAnimeVisibilityOptions([...])` block (currently lines 116-126) with: - -```ts -const librarySummaryAsPoints = data.librarySummary.map((row) => ({ - epochDay: 0, - animeTitle: row.title, - value: row.watchTimeMin, -})); - -const animeTitles = buildAnimeVisibilityOptions([ - librarySummaryAsPoints, - data.animeCumulative.episodes, - data.animeCumulative.cards, - data.animeCumulative.words, - data.animeCumulative.watchTime, -]); -``` - -This reuses the existing `PerAnimeDataPoint`-shaped helper without modifying it — the `epochDay: 0` is a placeholder the helper never inspects. - -- [ ] **Step 3: Import `LibrarySummarySection` at the top of the file** - -Add to the imports at the top (near the other `./` imports on line 5): - -```ts -import { LibrarySummarySection } from './LibrarySummarySection'; -``` - -- [ ] **Step 4: Replace the "Library — Per Day" JSX block** - -Find lines 224-254 (the block starting with `Library — Per Day`). Replace the entire block through the final `/>` of `Lookups/100w per Title` with: - -```tsx -Library — Summary - setHiddenAnime(new Set())} - onHideAll={() => setHiddenAnime(new Set(animeTitles))} - onToggleAnime={(title) => - setHiddenAnime((current) => { - const next = new Set(current); - if (next.has(title)) { - next.delete(title); - } else { - next.add(title); - } - return next; - }) - } -/> - -``` - -(The `AnimeVisibilityFilter` moves from the per-day section into the summary section — same component, same props pattern.) - -- [ ] **Step 5: Verify `StackedTrendChart` and `filterHiddenAnimeData` are still imported** - -Those imports are still needed by the "Library — Cumulative" section (lines 256-264 — make sure you did NOT delete them). If the linter reports them as unused, they aren't. Do not touch them. - -- [ ] **Step 6: Run frontend typecheck** - -Run: `cd stats && bun run typecheck` -Expected: PASS (no more `animePerDay` references). - -- [ ] **Step 7: Run the full fast test suite** - -Run: `bun run test:fast` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add stats/src/components/trends/TrendsTab.tsx -git commit -m "feat(stats): replace per-day trends section with library summary" -``` - ---- - -## Task 10: Add changelog fragment and run the full handoff gate - -**Files:** -- Create: `changes/stats-library-summary.md` - -- [ ] **Step 1: Check the existing changelog fragment format** - -Run: `ls changes/ && head -20 changes/*.md | head -60` -Inspect a recent fragment to match the exact format (frontmatter, section headings). Base the new fragment on whatever convention you see — do not guess. - -- [ ] **Step 2: Write the fragment** - -Create `changes/stats-library-summary.md` using the format you just observed. The body should say something like: - -> Replaced the noisy "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector. - -If you are uncertain about the format, copy the most recent fragment's structure exactly and replace only the body text and category. - -- [ ] **Step 3: Run the default handoff gate** - -Run the commands in sequence (stop and fix if any fails): - -```bash -bun run typecheck -bun run test:fast -bun run test:env -bun run changelog:lint -``` - -Expected: all PASS. - -- [ ] **Step 4: Run the full build + smoke test** - -```bash -bun run build -bun run test:smoke:dist -``` - -Expected: PASS. - -- [ ] **Step 5: Commit the fragment** - -```bash -git add changes/stats-library-summary.md -git commit -m "docs(changelog): summarize library summary replacing per-day trends" -``` - ---- - -## Verification Checklist - -After all tasks complete, manually verify: - -- [ ] The "Library — Per Day" section is gone from the Trends tab. -- [ ] A new "Library — Summary" section appears with a top-10 watch-time bar chart above a per-title table. -- [ ] Clicking table column headers sorts the table; clicking twice reverses direction. -- [ ] The shared Anime Visibility filter still hides titles from both the leaderboard, the table, and the Cumulative section below. -- [ ] Changing the date range selector (7d/30d/90d/365d/all) updates the summary. -- [ ] Titles with `words === 0` show `—` in the Lookups/100w column. -- [ ] Empty window shows "No library activity in the selected window." -- [ ] The "Library — Cumulative" section below is unchanged. -- [ ] `git log --oneline` shows small, focused commits per task. diff --git a/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md b/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md deleted file mode 100644 index 9e754a59..00000000 --- a/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md +++ /dev/null @@ -1,1609 +0,0 @@ -# Stats Dashboard Feedback Pass 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:** Land seven UX/correctness improvements to the SubMiner stats dashboard in a single PR with one logical commit per task. - -**Architecture:** All work lives in `stats/src/` (React + Vite + Tailwind UI) and `src/core/services/immersion-tracker/` (sqlite-backed query layer for the 365d range only). No schema changes, no migrations. Each task is independently testable; helpers get their own files with focused unit tests. - -**Tech Stack:** Bun, TypeScript, React 19, Recharts, Tailwind, sqlite via `node:sqlite`, `bun:test`. - -**Spec:** `docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md` - ---- - -## Working agreements - -- One commit per task. Commit message conventions follow recent history (`feat(stats):` / `fix(stats):` / `docs:`). -- Run `bun run typecheck` after every commit; run `bun run typecheck:stats` after stats UI commits. -- Use Bun, not Node: `bun test `, not `npx jest`. -- Ranges or files cited as `path:line` are valid as of branch `stats-update`. If something has moved, re-grep before editing. -- Tests for stats UI files live alongside their source (e.g. `LibraryTab.tsx` → `LibraryTab.test.tsx`). Use `bun test stats/src/path/to/file.test.tsx` to run a single file. -- Frequently committing means: each task is its own commit. Don't squash. - -## Pre-flight (do this once before Task 1) - -- [ ] **Step 1: Verify branch and clean tree** - - Run: `git status && git log --oneline -3` - Expected: branch `stats-update`, working tree clean except for whatever you're about to do, last commit is the spec commit `82d58a57`. - -- [ ] **Step 2: Verify baseline tests pass** - - Run: `bun run typecheck && bun run typecheck:stats` - Expected: both succeed. - -- [ ] **Step 3: Confirm bun test runs single stats files** - - Run: `bun test stats/src/lib/api-client.test.ts` - Expected: tests pass. - ---- - -## Task 1: 365d range — backend type extension - -**Files:** -- Modify: `src/core/services/immersion-tracker/query-trends.ts:16` and `src/core/services/immersion-tracker/query-trends.ts:84-88` -- Test: `src/core/services/immersion-tracker/__tests__/query.test.ts` - -- [ ] **Step 1: Read the existing range table test** - - Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t '365d' 2>&1 | head -20` - Expected: no test named `365d`. Locate existing range coverage by reading the file (`grep -n '7d\|30d\|90d' src/core/services/immersion-tracker/__tests__/query.test.ts`) so you can mirror its style. - -- [ ] **Step 2: Add a failing test that asserts `365d` returns up to 365 day buckets** - - Edit `src/core/services/immersion-tracker/__tests__/query.test.ts`. Find an existing trend range test (search for `'90d'`) and add a new sibling test that: - - Seeds 400 days of synthetic daily activity (or whatever the existing helpers use). - - Calls the trends query with `range: '365d', groupBy: 'day'`. - - Asserts the returned `watchTimeByDay.length === 365`. - - Mirrors the assertions style of the existing 90d test exactly. - -- [ ] **Step 3: Run the new test to verify it fails** - - Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t '365d'` - Expected: TypeScript compile error or runtime failure because `'365d'` is not assignable to `TrendRange`. - -- [ ] **Step 4: Extend `TrendRange` and `TREND_DAY_LIMITS`** - - In `src/core/services/immersion-tracker/query-trends.ts`: - - Line 16: change to `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';` - - Line 84-88: add `'365d': 365,` so the map becomes: - ```ts - const TREND_DAY_LIMITS: Record, number> = { - '7d': 7, - '30d': 30, - '90d': 90, - '365d': 365, - }; - ``` - -- [ ] **Step 5: Run the new test to verify it passes** - - Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts -t '365d'` - Expected: PASS. - -- [ ] **Step 6: Run the full query test file to verify no regressions** - - Run: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` - Expected: all tests pass. - -- [ ] **Step 7: Commit** - - ```bash - git add src/core/services/immersion-tracker/query-trends.ts \ - src/core/services/immersion-tracker/__tests__/query.test.ts - git commit -m "feat(stats): support 365d range in trends query" - ``` - ---- - -## Task 2: 365d range — server route allow-list - -**Files:** -- Modify: `src/core/services/stats-server.ts` (search for trends route handler — look for `/api/stats/trends` or `getTrendsDashboard`) -- Test: `src/core/services/__tests__/stats-server.test.ts` - -- [ ] **Step 1: Locate the trends route in `stats-server.ts`** - - Run: `grep -n 'trends\|TrendRange' src/core/services/stats-server.ts` - Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there *is* an allow-list (e.g. a `validRanges` array), continue. - -- [ ] **Step 2: Add a failing test for `range=365d`** - - In `src/core/services/__tests__/stats-server.test.ts`, find the existing trends route test (search for `'/api/stats/trends'`). Add a sibling case that issues a request with `range=365d` and asserts the response is 200 (not 400). - -- [ ] **Step 3: Run the test to verify it fails** - - Run: `bun test src/core/services/__tests__/stats-server.test.ts -t '365d'` - Expected: FAIL because `365d` isn't in the allow-list. - -- [ ] **Step 4: Extend the allow-list** - - Add `'365d'` to the `validRanges`/`allowedRanges` array (whatever it is named) so it sits next to `'90d'`. - -- [ ] **Step 5: Re-run the test** - - Run: `bun test src/core/services/__tests__/stats-server.test.ts -t '365d'` - Expected: PASS. - -- [ ] **Step 6: Run the full server test file** - - Run: `bun test src/core/services/__tests__/stats-server.test.ts` - Expected: all tests pass. - -- [ ] **Step 7: Commit (only if step 1 found an allow-list)** - - ```bash - git add src/core/services/stats-server.ts \ - src/core/services/__tests__/stats-server.test.ts - git commit -m "feat(stats): allow 365d trends range in HTTP route" - ``` - ---- - -## Task 3: 365d range — frontend client and selector - -**Files:** -- Modify: `stats/src/lib/api-client.ts` -- Modify: `stats/src/lib/api-client.test.ts` -- Modify: `stats/src/hooks/useTrends.ts:5` -- Modify: `stats/src/components/trends/DateRangeSelector.tsx:56` - -- [ ] **Step 1: Locate range usage in the api-client** - - Run: `grep -n 'TrendRange\|range\|7d\|90d' stats/src/lib/api-client.ts | head -20` - Identify whether the client validates ranges or simply passes them through. Mirror your test/edit accordingly. - -- [ ] **Step 2: Add a failing test in `api-client.test.ts`** - - Add a test case that calls `apiClient.getTrendsDashboard('365d', 'day')` (or whatever the public method is named), stubs `fetch`, and asserts the URL contains `range=365d`. Mirror the existing 90d test if there is one. - -- [ ] **Step 3: Run the new test to verify it fails** - - Run: `bun test stats/src/lib/api-client.test.ts -t '365d'` - Expected: FAIL on type-narrowing. - -- [ ] **Step 4: Widen the client `TrendRange` union** - - In `stats/src/lib/api-client.ts`, find any `TrendRange`-shaped union and add `'365d'`. If the client re-imports the type from elsewhere, no edit needed beyond the consumer test. - -- [ ] **Step 5: Update `useTrends.ts:5`** - - Change `export type TimeRange = '7d' | '30d' | '90d' | 'all';` to `export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';`. - -- [ ] **Step 6: Add `365d` to the `DateRangeSelector` segmented control** - - In `stats/src/components/trends/DateRangeSelector.tsx:56`, change: - ```tsx - options={['7d', '30d', '90d', 'all'] as TimeRange[]} - ``` - to: - ```tsx - options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]} - ``` - -- [ ] **Step 7: Run the new client test** - - Run: `bun test stats/src/lib/api-client.test.ts -t '365d'` - Expected: PASS. - -- [ ] **Step 8: Typecheck the stats UI** - - Run: `bun run typecheck:stats` - Expected: succeeds. - -- [ ] **Step 9: Commit** - - ```bash - git add stats/src/lib/api-client.ts stats/src/lib/api-client.test.ts \ - stats/src/hooks/useTrends.ts stats/src/components/trends/DateRangeSelector.tsx - git commit -m "feat(stats): expose 365d trends range in dashboard UI" - ``` - ---- - -## Task 4: Vocabulary Top 50 — collapse word/reading column - -**Files:** -- Modify: `stats/src/components/vocabulary/FrequencyRankTable.tsx:110-144` -- Test: create `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` if not present (check first with `ls stats/src/components/vocabulary/`) - -- [ ] **Step 1: Check whether a test file exists** - - Run: `ls stats/src/components/vocabulary/FrequencyRankTable.test.tsx 2>/dev/null || echo "missing"` - If missing, you'll create it in step 2. - -- [ ] **Step 2: Write the failing test** - - Create or extend `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` with: - ```tsx - import { render, screen } from '@testing-library/react'; - import { describe, it, expect } from 'bun:test'; - import { FrequencyRankTable } from './FrequencyRankTable'; - import type { VocabularyEntry } from '../../types/stats'; - - function makeEntry(over: Partial): VocabularyEntry { - return { - wordId: 1, - headword: '日本語', - word: '日本語', - reading: 'にほんご', - frequency: 5, - frequencyRank: 100, - animeCount: 1, - partOfSpeech: null, - firstSeen: 0, - lastSeen: 0, - ...over, - } as VocabularyEntry; - } - - describe('FrequencyRankTable', () => { - it('renders headword and reading inline in a single column (no separate Reading header)', () => { - const entry = makeEntry({}); - render(); - // Reading should be visually associated with the headword, not in its own column. - expect(screen.queryByRole('columnheader', { name: 'Reading' })).toBeNull(); - expect(screen.getByText('日本語')).toBeTruthy(); - expect(screen.getByText(/にほんご/)).toBeTruthy(); - }); - - it('omits reading when reading equals headword', () => { - const entry = makeEntry({ headword: 'カレー', word: 'カレー', reading: 'カレー' }); - render(); - // Headword still renders; no bracketed reading line for the duplicate. - expect(screen.getByText('カレー')).toBeTruthy(); - expect(screen.queryByText(/【カレー】/)).toBeNull(); - }); - }); - ``` - - Note: this assumes `@testing-library/react` is already a dev dep — confirm with `grep '@testing-library/react' stats/package.json /Users/sudacode/projects/japanese/SubMiner/package.json`. If it's not in `stats/`, run the test with `bun test` from repo root since tooling may resolve from the parent. If the project's existing component tests use a different render helper (check `MediaDetailView.test.tsx` for the pattern), copy that pattern instead. - -- [ ] **Step 3: Run the new test to verify it fails** - - Run: `bun test stats/src/components/vocabulary/FrequencyRankTable.test.tsx` - Expected: FAIL — the current component still renders a Reading ``. - -- [ ] **Step 4: Modify `FrequencyRankTable.tsx`** - - Replace the `Reading` header column and the corresponding `` in the body. The new shape: - - Header (around line 113-119): - ```tsx - - - Rank - Word - POS - Seen - - - ``` - - Body row (around line 122-141): - ```tsx - onSelectWord?.(w)} - className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors" - > - - #{w.frequencyRank!.toLocaleString()} - - - {w.headword} - {(() => { - const reading = fullReading(w.headword, w.reading); - if (!reading || reading === w.headword) return null; - return ( - - 【{reading}】 - - ); - })()} - - - {w.partOfSpeech && } - - - {w.frequency}x - - - ``` - -- [ ] **Step 5: Run the test to verify it passes** - - Run: `bun test stats/src/components/vocabulary/FrequencyRankTable.test.tsx` - Expected: PASS. - -- [ ] **Step 6: Typecheck** - - Run: `bun run typecheck:stats` - Expected: succeeds. - -- [ ] **Step 7: Commit** - - ```bash - git add stats/src/components/vocabulary/FrequencyRankTable.tsx \ - stats/src/components/vocabulary/FrequencyRankTable.test.tsx - git commit -m "fix(stats): collapse word and reading into one column in Top 50 table" - ``` - ---- - -## Task 5: Episode detail — filter Anki-deleted cards - -**Files:** -- Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147` -- Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present - -- [ ] **Step 1: Confirm `ankiNotesInfo` is only consumed in `EpisodeDetail.tsx`** - - Run: `grep -rn 'ankiNotesInfo' stats/src` - Expected: only `EpisodeDetail.tsx`. If anything else turns up, this task must also patch that consumer. - -- [ ] **Step 2: Write the failing test** - - Create `stats/src/components/anime/EpisodeDetail.test.tsx` (copy the import/setup pattern from the closest existing component test like `MediaDetailView.test.tsx`): - - ```tsx - import { render, screen, waitFor } from '@testing-library/react'; - import { describe, it, expect, mock, beforeEach } from 'bun:test'; - import { EpisodeDetail } from './EpisodeDetail'; - - // Mock the stats client. Mirror the mocking style used in MediaDetailView.test.tsx. - // The key behavior: ankiNotesInfo only returns one of the two requested noteIds. - - describe('EpisodeDetail card filtering', () => { - beforeEach(() => { - // reset mocks - }); - - it('hides card events whose Anki notes have been deleted', async () => { - // Stub getStatsClient().getEpisodeDetail to return two cardEvents, - // each with one noteId. - // Stub ankiNotesInfo to return only the first noteId. - // Render . - // Wait for the cards to load. - // Assert exactly one card row is visible. - // Assert the surviving event's expression renders. - }); - }); - ``` - - Implementation note: the existing `EpisodeDetail.tsx` calls `getStatsClient()` directly. Look at how `MediaDetailView.test.tsx` handles this — there's already an established mocking pattern. Copy it. If it uses `mock.module('../../hooks/useStatsApi', ...)`, do the same. - -- [ ] **Step 3: Run the test to verify it fails** - - Run: `bun test stats/src/components/anime/EpisodeDetail.test.tsx` - Expected: FAIL — both card rows currently render even when one note is missing. - -- [ ] **Step 4: Add the filter to `EpisodeDetail.tsx`** - - At the top of the render section (after `const { sessions, cardEvents } = data;` around line 73), insert: - - ```tsx - const filteredCardEvents = cardEvents - .map((ev) => { - if (ev.noteIds.length === 0) { - // Legacy rollup events with no noteIds — leave alone. - return ev; - } - const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id)); - return { ...ev, noteIds: survivingNoteIds }; - }) - .filter((ev) => { - // Drop events that originally had noteIds but lost them all. - return ev.noteIds.length > 0 || ev.cardsDelta > 0; - }); - - // Track how many were hidden so we can surface a small footer. - const hiddenCardCount = cardEvents.reduce((acc, ev) => { - if (ev.noteIds.length === 0) return acc; - const dropped = ev.noteIds.filter((id) => !noteInfos.has(id)).length; - return acc + dropped; - }, 0); - ``` - - Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `` closing the cards-mined section, add: - - ```tsx - {hiddenCardCount > 0 && ( -
- {hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki) -
- )} - ``` - - Place that footer immediately before the closing `` of the bordered cards-mined section, so it stays scoped to that block. - - **Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`. - - Concrete change near line 22: - ```tsx - const [noteInfos, setNoteInfos] = useState>(new Map()); - const [noteInfosLoaded, setNoteInfosLoaded] = useState(false); - ``` - - Inside the existing `useEffect` (around line 36-46), set the loaded flag: - ```tsx - if (allNoteIds.length > 0) { - getStatsClient() - .ankiNotesInfo(allNoteIds) - .then((notes) => { - if (cancelled) return; - const map = new Map(); - for (const note of notes) { - const expr = note.preview?.word ?? ''; - map.set(note.noteId, { noteId: note.noteId, expression: expr }); - } - setNoteInfos(map); - setNoteInfosLoaded(true); - }) - .catch((err) => { - console.warn('Failed to fetch Anki note info:', err); - if (!cancelled) setNoteInfosLoaded(true); // unblock so we don't hide everything - }); - } else { - setNoteInfosLoaded(true); - } - ``` - - And gate the filter: - ```tsx - const filteredCardEvents = noteInfosLoaded - ? cardEvents - .map((ev) => { - if (ev.noteIds.length === 0) return ev; - const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id)); - return { ...ev, noteIds: survivingNoteIds }; - }) - .filter((ev) => ev.noteIds.length > 0 || ev.cardsDelta > 0) - : cardEvents; - ``` - -- [ ] **Step 5: Run the test to verify it passes** - - Run: `bun test stats/src/components/anime/EpisodeDetail.test.tsx` - Expected: PASS. - -- [ ] **Step 6: Add a second test for the loading-state guard** - - Extend the test to assert that, before `ankiNotesInfo` resolves, both card rows still appear (so we don't briefly flash an empty list). Then verify that after resolution, the deleted one disappears. - -- [ ] **Step 7: Run both tests** - - Run: `bun test stats/src/components/anime/EpisodeDetail.test.tsx` - Expected: PASS. - -- [ ] **Step 8: Typecheck** - - Run: `bun run typecheck:stats` - Expected: succeeds. - -- [ ] **Step 9: Commit** - - ```bash - git add stats/src/components/anime/EpisodeDetail.tsx \ - stats/src/components/anime/EpisodeDetail.test.tsx - git commit -m "fix(stats): hide cards deleted from Anki in episode detail" - ``` - ---- - -## Task 6: Library detail — delete episode action - -**Files:** -- Modify: `stats/src/components/library/MediaHeader.tsx` -- Modify: `stats/src/components/library/MediaDetailView.tsx` -- Modify: `stats/src/hooks/useMediaLibrary.ts` -- Modify: `stats/src/components/library/LibraryTab.tsx` -- Test: extend `stats/src/components/library/MediaDetailView.test.tsx` -- Test: extend or create `stats/src/hooks/useMediaLibrary.test.ts` - -- [ ] **Step 1: Add a failing test for the delete button in `MediaDetailView.test.tsx`** - - Read `stats/src/components/library/MediaDetailView.test.tsx` first to see the test scaffolding. Then add a new test: - - ```tsx - it('deletes the episode and calls onBack when the delete button is clicked', async () => { - const onBack = mock(() => {}); - const deleteVideo = mock(async () => {}); - // Stub apiClient.deleteVideo with the mock above (mirror existing stub patterns). - // Stub useMediaDetail to return a populated detail object. - // Stub window.confirm to return true. - render(); - // Wait for the header to render. - const button = await screen.findByRole('button', { name: /delete episode/i }); - button.click(); - await waitFor(() => expect(deleteVideo).toHaveBeenCalledWith(42)); - await waitFor(() => expect(onBack).toHaveBeenCalled()); - }); - ``` - -- [ ] **Step 2: Run the failing test** - - Run: `bun test stats/src/components/library/MediaDetailView.test.tsx -t 'delete'` - Expected: FAIL because no delete button exists. - -- [ ] **Step 3: Add `onDeleteEpisode` prop to `MediaHeader`** - - In `stats/src/components/library/MediaHeader.tsx`: - - ```tsx - interface MediaHeaderProps { - detail: NonNullable; - initialKnownWordsSummary?: { - totalUniqueWords: number; - knownWordCount: number; - } | null; - onDeleteEpisode?: () => void; - } - - export function MediaHeader({ - detail, - initialKnownWordsSummary = null, - onDeleteEpisode, - }: MediaHeaderProps) { - ``` - - Inside the right-hand `
`, immediately after the `

` title row, add a flex container so the delete button can sit on the far right of the header. Easier: put the button at the top-right of the title row by wrapping the title in a flex layout: - - ```tsx -
-

- {detail.canonicalTitle} -

- {onDeleteEpisode && ( - - )} -
- ``` - -- [ ] **Step 4: Wire `onDeleteEpisode` in `MediaDetailView.tsx`** - - Add a handler near the existing `handleDeleteSession`: - - ```tsx - const handleDeleteEpisode = async () => { - const title = data.detail.canonicalTitle; - if (!confirmEpisodeDelete(title)) return; - setDeleteError(null); - try { - await apiClient.deleteVideo(videoId); - onBack(); - } catch (err) { - setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.'); - } - }; - ``` - - Add `confirmEpisodeDelete` to the existing `delete-confirm` import line. - - Pass the handler down: ``. - -- [ ] **Step 5: Run the test to verify it passes** - - Run: `bun test stats/src/components/library/MediaDetailView.test.tsx -t 'delete'` - Expected: PASS. - -- [ ] **Step 6: Add `refresh` to `useMediaLibrary`** - - In `stats/src/hooks/useMediaLibrary.ts`, hoist the `load` function out of `useEffect` using `useCallback`, and return a `refresh` function: - - ```tsx - import { useState, useEffect, useCallback } from 'react'; - - export function useMediaLibrary() { - const [media, setMedia] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [version, setVersion] = useState(0); - - const refresh = useCallback(() => setVersion((v) => v + 1), []); - - useEffect(() => { - let cancelled = false; - let retryCount = 0; - let retryTimer: ReturnType | null = null; - - const load = (isInitial = false) => { - if (isInitial) { - setLoading(true); - setError(null); - } - getStatsClient() - .getMediaLibrary() - .then((rows) => { - if (cancelled) return; - setMedia(rows); - if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) { - retryCount += 1; - retryTimer = setTimeout(() => { - retryTimer = null; - load(false); - }, MEDIA_LIBRARY_REFRESH_DELAY_MS); - } - }) - .catch((err: Error) => { - if (cancelled) return; - setError(err.message); - }) - .finally(() => { - if (cancelled || !isInitial) return; - setLoading(false); - }); - }; - - load(true); - return () => { - cancelled = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [version]); - - return { media, loading, error, refresh }; - } - ``` - -- [ ] **Step 7: Add a focused test for `refresh`** - - In `stats/src/hooks/useMediaLibrary.test.ts`, add a test that: - - Mounts the hook with `renderHook` from `@testing-library/react`. - - Asserts `getMediaLibrary` was called once. - - Calls `result.current.refresh()` inside `act`. - - Asserts `getMediaLibrary` was called twice. - - If the file doesn't have `renderHook` patterns, mirror whichever helper the existing tests use. Look at the existing test file first. - -- [ ] **Step 8: Wire `refresh` from `LibraryTab.tsx`** - - In `stats/src/components/library/LibraryTab.tsx`: - - ```tsx - const { media, loading, error, refresh } = useMediaLibrary(); - ``` - - And update the early-return that mounts the detail view: - - ```tsx - if (selectedVideoId !== null) { - return ( - { - setSelectedVideoId(null); - refresh(); - }} - /> - ); - } - ``` - -- [ ] **Step 9: Run the new tests** - - Run: `bun test stats/src/hooks/useMediaLibrary.test.ts && bun test stats/src/components/library/MediaDetailView.test.tsx` - Expected: PASS. - -- [ ] **Step 10: Typecheck** - - Run: `bun run typecheck:stats` - Expected: succeeds. - -- [ ] **Step 11: Commit** - - ```bash - git add stats/src/components/library/MediaHeader.tsx \ - stats/src/components/library/MediaDetailView.tsx \ - stats/src/components/library/MediaDetailView.test.tsx \ - stats/src/components/library/LibraryTab.tsx \ - stats/src/hooks/useMediaLibrary.ts \ - stats/src/hooks/useMediaLibrary.test.ts - git commit -m "feat(stats): delete episode from library detail view" - ``` - ---- - -## Task 7: Library — collapsible series groups - -**Files:** -- Modify: `stats/src/components/library/LibraryTab.tsx` -- Test: create `stats/src/components/library/LibraryTab.test.tsx` - -- [ ] **Step 1: Write the failing tests** - - Create `stats/src/components/library/LibraryTab.test.tsx`. Mirror the import/mocking pattern from `MediaDetailView.test.tsx`. Stub `useMediaLibrary` to return: - - One group with three videos (multi-video series). - - One group with one video (singleton). - - Add three tests: - - ```tsx - it('renders the multi-video group collapsed by default', async () => { - // Render LibraryTab with stubbed hook. - // Assert: the group header is visible. - // Assert: the three video MediaCards are NOT in the DOM (collapsed). - }); - - it('renders the single-video group expanded by default', async () => { - // Assert: the singleton's MediaCard IS in the DOM. - }); - - it('toggles the collapsed group when its header is clicked', async () => { - // Click the multi-video group header. - // Assert: the three MediaCards now appear. - // Click again. - // Assert: they disappear. - }); - ``` - - How to identify cards: each `MediaCard` should expose its title via the cover image alt text or a title element. Use `screen.queryAllByText()` to count them. - -- [ ] **Step 2: Run the failing tests** - - Run: `bun test stats/src/components/library/LibraryTab.test.tsx` - Expected: FAIL — current `LibraryTab` always shows all cards. - -- [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`** - - Modify imports: - ```tsx - import { useState, useMemo, useCallback } from 'react'; - ``` - - Inside the component, after the existing `useState` calls: - ```tsx - const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set()); - - // When grouped data changes, default-collapse groups with >1 video. - // We do this declaratively in a useMemo to keep state derived. - const effectiveCollapsed = useMemo(() => { - const next = new Set(collapsedGroups); - for (const group of grouped) { - // Only auto-collapse on first encounter; if user has interacted, leave alone. - // We do this by tracking which keys we've seen via a ref. Simpler approach: - // initialize on mount via useEffect below. - } - return next; - }, [collapsedGroups, grouped]); - ``` - - Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**: - ```tsx - const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set()); - const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false); - - useEffect(() => { - if (hasInitializedCollapsed || grouped.length === 0) return; - const initial = new Set<string>(); - for (const group of grouped) { - if (group.items.length > 1) initial.add(group.key); - } - setCollapsedGroups(initial); - setHasInitializedCollapsed(true); - }, [grouped, hasInitializedCollapsed]); - - const toggleGroup = useCallback((key: string) => { - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }, []); - ``` - - Don't forget to add `useEffect` to the import line. - -- [ ] **Step 4: Update the group rendering** - - Replace the section block (around line 64-115) so the header is a `<button>`: - - ```tsx - {grouped.map((group) => { - const isCollapsed = collapsedGroups.has(group.key); - const isSingleVideo = group.items.length === 1; - return ( - <section - key={group.key} - className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden" - > - <button - type="button" - onClick={() => !isSingleVideo && toggleGroup(group.key)} - aria-expanded={!isCollapsed} - aria-controls={`group-body-${group.key}`} - disabled={isSingleVideo} - className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${ - isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer' - }`} - > - {!isSingleVideo && ( - <span - aria-hidden="true" - className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${ - isCollapsed ? '' : 'rotate-90' - }`} - > - {'\u25B6'} - </span> - )} - <CoverImage - videoId={group.items[0]!.videoId} - title={group.title} - src={group.imageUrl} - className="w-16 h-16 rounded-2xl shrink-0" - /> - <div className="min-w-0 flex-1"> - <div className="flex items-center gap-2"> - <h3 className="text-base font-semibold text-ctp-text truncate"> - {group.title} - </h3> - </div> - {group.subtitle ? ( - <div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div> - ) : null} - <div className="text-xs text-ctp-overlay2 mt-2"> - {group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '} - {formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards - </div> - </div> - </button> - {!isCollapsed && ( - <div id={`group-body-${group.key}`} className="p-4"> - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> - {group.items.map((item) => ( - <MediaCard - key={item.videoId} - item={item} - onClick={() => setSelectedVideoId(item.videoId)} - /> - ))} - </div> - </div> - )} - </section> - ); - })} - ``` - - **Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption. - -- [ ] **Step 5: Run the tests to verify they pass** - - Run: `bun test stats/src/components/library/LibraryTab.test.tsx` - Expected: PASS. - -- [ ] **Step 6: Typecheck** - - Run: `bun run typecheck:stats` - Expected: succeeds. - -- [ ] **Step 7: Commit** - - ```bash - git add stats/src/components/library/LibraryTab.tsx \ - stats/src/components/library/LibraryTab.test.tsx - git commit -m "feat(stats): collapsible series groups in library tab" - ``` - ---- - -## Task 8: Session grouping helper - -**Files:** -- Create: `stats/src/lib/session-grouping.ts` -- Create: `stats/src/lib/session-grouping.test.ts` - -- [ ] **Step 1: Write the failing tests** - - Create `stats/src/lib/session-grouping.test.ts`: - - ```ts - import { describe, it, expect } from 'bun:test'; - import { groupSessionsByVideo } from './session-grouping'; - import type { SessionSummary } from '../types/stats'; - - function makeSession(over: Partial<SessionSummary>): SessionSummary { - return { - sessionId: 1, - videoId: 100, - canonicalTitle: 'Episode 1', - animeTitle: 'Show', - startedAtMs: 1_000_000, - activeWatchedMs: 60_000, - cardsMined: 1, - linesSeen: 10, - lookupCount: 5, - lookupHits: 3, - knownWordsSeen: 5, - // Add any other required fields by reading types/stats.ts. - ...over, - } as SessionSummary; - } - - describe('groupSessionsByVideo', () => { - it('returns an empty array for empty input', () => { - expect(groupSessionsByVideo([])).toEqual([]); - }); - - it('emits a singleton bucket for unique videoIds', () => { - const a = makeSession({ sessionId: 1, videoId: 100 }); - const b = makeSession({ sessionId: 2, videoId: 200 }); - const buckets = groupSessionsByVideo([a, b]); - expect(buckets).toHaveLength(2); - expect(buckets[0]!.sessions).toHaveLength(1); - expect(buckets[1]!.sessions).toHaveLength(1); - }); - - it('combines multiple sessions sharing a videoId into one bucket with summed totals', () => { - const a = makeSession({ - sessionId: 1, - videoId: 100, - startedAtMs: 1_000_000, - activeWatchedMs: 60_000, - cardsMined: 2, - }); - const b = makeSession({ - sessionId: 2, - videoId: 100, - startedAtMs: 2_000_000, - activeWatchedMs: 120_000, - cardsMined: 3, - }); - const buckets = groupSessionsByVideo([a, b]); - expect(buckets).toHaveLength(1); - const bucket = buckets[0]!; - expect(bucket.sessions).toHaveLength(2); - expect(bucket.totalActiveMs).toBe(180_000); - expect(bucket.totalCardsMined).toBe(5); - // Representative is the most-recent session. - expect(bucket.representativeSession.sessionId).toBe(2); - }); - - it('treats sessions with null/missing videoId as singletons keyed by sessionId', () => { - const a = makeSession({ sessionId: 1, videoId: null as unknown as number }); - const b = makeSession({ sessionId: 2, videoId: null as unknown as number }); - const buckets = groupSessionsByVideo([a, b]); - expect(buckets).toHaveLength(2); - expect(buckets[0]!.key).toContain('1'); - expect(buckets[1]!.key).toContain('2'); - }); - }); - ``` - -- [ ] **Step 2: Run the failing tests** - - Run: `bun test stats/src/lib/session-grouping.test.ts` - Expected: FAIL — module does not exist. - -- [ ] **Step 3: Implement the helper** - - Create `stats/src/lib/session-grouping.ts`: - - ```ts - import type { SessionSummary } from '../types/stats'; - - export interface SessionBucket { - key: string; - videoId: number | null; - sessions: SessionSummary[]; - totalActiveMs: number; - totalCardsMined: number; - representativeSession: SessionSummary; - } - - export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[] { - const byVideo = new Map<string, SessionSummary[]>(); - - for (const session of sessions) { - const hasVideoId = - typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0; - const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`; - const existing = byVideo.get(key); - if (existing) { - existing.push(session); - } else { - byVideo.set(key, [session]); - } - } - - const buckets: SessionBucket[] = []; - for (const [key, group] of byVideo) { - const sorted = [...group].sort((a, b) => b.startedAtMs - a.startedAtMs); - const representative = sorted[0]!; - buckets.push({ - key, - videoId: - typeof representative.videoId === 'number' && representative.videoId > 0 - ? representative.videoId - : null, - sessions: sorted, - totalActiveMs: sorted.reduce((sum, s) => sum + s.activeWatchedMs, 0), - totalCardsMined: sorted.reduce((sum, s) => sum + s.cardsMined, 0), - representativeSession: representative, - }); - } - - // Preserve insertion order — `byVideo` already keeps it (Map insertion order). - return buckets; - } - ``` - -- [ ] **Step 4: Run the tests to verify they pass** - - Run: `bun test stats/src/lib/session-grouping.test.ts` - Expected: PASS. - -- [ ] **Step 5: Typecheck** - - Run: `bun run typecheck:stats` - Expected: succeeds. - -- [ ] **Step 6: Commit** - - ```bash - git add stats/src/lib/session-grouping.ts stats/src/lib/session-grouping.test.ts - git commit -m "feat(stats): add groupSessionsByVideo helper for episode rollups" - ``` - ---- - -## Task 9: Sessions tab — episode rollup UI - -**Files:** -- Modify: `stats/src/components/sessions/SessionsTab.tsx` -- Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`) -- Modify: `stats/src/lib/delete-confirm.test.ts` -- Test: extend `stats/src/components/sessions/SessionsTab.test.tsx` if it exists; otherwise add a focused integration test on the new rollup behavior. - -- [ ] **Step 1: Add `confirmBucketDelete` with a failing test** - - In `stats/src/lib/delete-confirm.test.ts`, add: - - ```ts - it('confirmBucketDelete asks about merging multiple sessions of the same episode', () => { - // mock globalThis.confirm to capture the prompt and return true - const calls: string[] = []; - const original = globalThis.confirm; - globalThis.confirm = ((msg: string) => { - calls.push(msg); - return true; - }) as typeof globalThis.confirm; - try { - expect(confirmBucketDelete('My Episode', 3)).toBe(true); - expect(calls[0]).toContain('3'); - expect(calls[0]).toContain('My Episode'); - } finally { - globalThis.confirm = original; - } - }); - ``` - - Update the import line to also import `confirmBucketDelete`. - -- [ ] **Step 2: Run the failing test** - - Run: `bun test stats/src/lib/delete-confirm.test.ts -t 'confirmBucketDelete'` - Expected: FAIL — function doesn't exist. - -- [ ] **Step 3: Add the helper** - - Append to `stats/src/lib/delete-confirm.ts`: - - ```ts - export function confirmBucketDelete(title: string, count: number): boolean { - return globalThis.confirm( - `Delete all ${count} session${count === 1 ? '' : 's'} of "${title}" from this day?`, - ); - } - ``` - -- [ ] **Step 4: Re-run the test** - - Run: `bun test stats/src/lib/delete-confirm.test.ts` - Expected: PASS. - -- [ ] **Step 5: Add a failing test for the bucket UI** - - In a new or extended `stats/src/components/sessions/SessionsTab.test.tsx`, add a test that: - - Stubs `useSessions` to return three sessions on the same day, two of which share a `videoId`. - - Renders `<SessionsTab />`. - - Asserts the page contains a bucket header for the shared-video pair (e.g. text matching `2 sessions`). - - Asserts the singleton session's title appears once. - - Clicks the bucket header and verifies the underlying two sessions become visible. - -- [ ] **Step 6: Run the failing test** - - Run: `bun test stats/src/components/sessions/SessionsTab.test.tsx -t 'rollup'` - Expected: FAIL — current behavior renders three flat rows. - -- [ ] **Step 7: Restructure `SessionsTab.tsx` to use buckets** - - At the top of the component, import the helper: - - ```tsx - import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping'; - import { confirmBucketDelete } from '../../lib/delete-confirm'; - ``` - - Add a second expanded-state Set keyed by bucket key: - - ```tsx - const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(new Set()); - - const toggleBucket = (key: string) => { - setExpandedBuckets((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }; - ``` - - Replace the inner day-group loop. Instead of mapping `daySessions.map(...)` directly, run them through `groupSessionsByVideo` and render each bucket. Buckets with one session keep the existing `SessionRow` rendering. Buckets with multiple sessions render a `<SessionBucketRow>` (a small inline component or a JSX block — keep it inline if the file isn't getting too long). - - Skeleton: - - ```tsx - {Array.from(groups.entries()).map(([dayLabel, daySessions]) => { - const buckets = groupSessionsByVideo(daySessions); - return ( - <div key={dayLabel}> - <div className="flex items-center gap-3 mb-2"> - <h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0"> - {dayLabel} - </h3> - <div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" /> - </div> - <div className="space-y-2"> - {buckets.map((bucket) => { - if (bucket.sessions.length === 1) { - const s = bucket.sessions[0]!; - const detailsId = `session-details-${s.sessionId}`; - return ( - <div key={bucket.key}> - <SessionRow - session={s} - isExpanded={expandedId === s.sessionId} - detailsId={detailsId} - onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} - onDelete={() => void handleDeleteSession(s)} - deleteDisabled={deletingSessionId === s.sessionId} - onNavigateToMediaDetail={onNavigateToMediaDetail} - /> - {expandedId === s.sessionId && ( - <div id={detailsId}> - <SessionDetail session={s} /> - </div> - )} - </div> - ); - } - const isOpen = expandedBuckets.has(bucket.key); - return ( - <div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"> - <button - type="button" - onClick={() => toggleBucket(bucket.key)} - aria-expanded={isOpen} - className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors" - > - <span - aria-hidden="true" - className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`} - > - {'\u25B6'} - </span> - <div className="min-w-0 flex-1"> - <div className="text-sm text-ctp-text truncate"> - {bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'} - </div> - <div className="text-xs text-ctp-overlay2"> - {bucket.sessions.length} sessions ·{' '} - {formatDuration(bucket.totalActiveMs)} ·{' '} - {bucket.totalCardsMined} cards - </div> - </div> - <button - type="button" - onClick={(e) => { - e.stopPropagation(); - void handleDeleteBucket(bucket); - }} - className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors" - title="Delete all sessions in this group" - > - Delete - </button> - </button> - {isOpen && ( - <div className="pl-8 pr-2 pb-2 space-y-2"> - {bucket.sessions.map((s) => { - const detailsId = `session-details-${s.sessionId}`; - return ( - <div key={s.sessionId}> - <SessionRow - session={s} - isExpanded={expandedId === s.sessionId} - detailsId={detailsId} - onToggle={() => - setExpandedId(expandedId === s.sessionId ? null : s.sessionId) - } - onDelete={() => void handleDeleteSession(s)} - deleteDisabled={deletingSessionId === s.sessionId} - onNavigateToMediaDetail={onNavigateToMediaDetail} - /> - {expandedId === s.sessionId && ( - <div id={detailsId}> - <SessionDetail session={s} /> - </div> - )} - </div> - ); - })} - </div> - )} - </div> - ); - })} - </div> - </div> - ); - })} - ``` - - **Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics: - - ```tsx - <div className="flex items-center"> - <button type="button" onClick={() => toggleBucket(bucket.key)} ...> - ... - </button> - <button type="button" onClick={() => void handleDeleteBucket(bucket)} ...> - Delete - </button> - </div> - ``` - - Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones. - - Add `handleDeleteBucket`: - - ```tsx - const handleDeleteBucket = async (bucket: SessionBucket) => { - const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; - if (!confirmBucketDelete(title, bucket.sessions.length)) return; - setDeleteError(null); - const ids = bucket.sessions.map((s) => s.sessionId); - try { - await apiClient.deleteSessions(ids); - const idSet = new Set(ids); - setVisibleSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId))); - } catch (err) { - setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.'); - } - }; - ``` - - Add the `formatDuration` import at the top of the file if not present. - -- [ ] **Step 8: Run the bucket test** - - Run: `bun test stats/src/components/sessions/SessionsTab.test.tsx -t 'rollup'` - Expected: PASS. - -- [ ] **Step 9: Run all sessions tests** - - Run: `bun test stats/src/components/sessions/` - Expected: PASS. - -- [ ] **Step 10: Apply the same rollup to `MediaSessionList.tsx`** - - Read `stats/src/components/library/MediaSessionList.tsx` first. Inside a single video's detail view, all sessions share the same `videoId`, so `groupSessionsByVideo` would always produce one giant bucket. **That's wrong for this view.** Skip the bucket rendering here entirely — `MediaSessionList` still groups by day only. Document this in the commit message: "Rollup intentionally not applied in MediaSessionList because the view is already filtered to a single video." - -- [ ] **Step 11: Typecheck** - - Run: `bun run typecheck:stats` - Expected: succeeds. - -- [ ] **Step 12: Commit** - - ```bash - git add stats/src/components/sessions/SessionsTab.tsx \ - stats/src/components/sessions/SessionsTab.test.tsx \ - stats/src/lib/delete-confirm.ts stats/src/lib/delete-confirm.test.ts - git commit -m "feat(stats): roll up same-episode sessions within a day" - ``` - ---- - -## Task 10: Chart clarity pass - -**Files:** -- Modify: `stats/src/lib/chart-theme.ts` -- Modify: `stats/src/components/trends/TrendChart.tsx` -- Modify: `stats/src/components/trends/StackedTrendChart.tsx` -- Modify: `stats/src/components/overview/WatchTimeChart.tsx` -- Test: create `stats/src/lib/chart-theme.test.ts` if not present - -- [ ] **Step 1: Extend `chart-theme.ts`** - - Replace the file contents with: - - ```ts - export const CHART_THEME = { - tick: '#a5adcb', - tooltipBg: '#363a4f', - tooltipBorder: '#494d64', - tooltipText: '#cad3f5', - tooltipLabel: '#b8c0e0', - barFill: '#8aadf4', - grid: '#494d64', - axisLine: '#494d64', - } as const; - - export const CHART_DEFAULTS = { - height: 160, - tickFontSize: 11, - margin: { top: 8, right: 8, bottom: 0, left: 0 }, - grid: { strokeDasharray: '3 3', vertical: false }, - } as const; - - export const TOOLTIP_CONTENT_STYLE = { - background: CHART_THEME.tooltipBg, - border: `1px solid ${CHART_THEME.tooltipBorder}`, - borderRadius: 6, - color: CHART_THEME.tooltipText, - fontSize: 12, - }; - ``` - -- [ ] **Step 2: Add a snapshot/value test for the new constants** - - Create `stats/src/lib/chart-theme.test.ts`: - - ```ts - import { describe, it, expect } from 'bun:test'; - import { CHART_THEME, CHART_DEFAULTS, TOOLTIP_CONTENT_STYLE } from './chart-theme'; - - describe('chart-theme', () => { - it('exposes a grid color', () => { - expect(CHART_THEME.grid).toBe('#494d64'); - }); - - it('uses 11px ticks for legibility', () => { - expect(CHART_DEFAULTS.tickFontSize).toBe(11); - }); - - it('builds a tooltip content style with border + background', () => { - expect(TOOLTIP_CONTENT_STYLE.background).toBe(CHART_THEME.tooltipBg); - expect(TOOLTIP_CONTENT_STYLE.border).toContain(CHART_THEME.tooltipBorder); - }); - }); - ``` - -- [ ] **Step 3: Run the test** - - Run: `bun test stats/src/lib/chart-theme.test.ts` - Expected: PASS. - -- [ ] **Step 4: Update `TrendChart.tsx` to use the shared theme + add gridlines** - - Replace `stats/src/components/trends/TrendChart.tsx` with: - - ```tsx - import { - BarChart, - Bar, - LineChart, - Line, - XAxis, - YAxis, - Tooltip, - CartesianGrid, - ResponsiveContainer, - } from 'recharts'; - import { CHART_THEME, CHART_DEFAULTS, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; - - interface TrendChartProps { - title: string; - data: Array<{ label: string; value: number }>; - color: string; - type: 'bar' | 'line'; - formatter?: (value: number) => string; - onBarClick?: (label: string) => void; - } - - export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) { - const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]); - - return ( - <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> - <h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3> - <ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}> - {type === 'bar' ? ( - <BarChart data={data} margin={CHART_DEFAULTS.margin}> - <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> - <XAxis - dataKey="label" - tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} - axisLine={{ stroke: CHART_THEME.axisLine }} - tickLine={false} - /> - <YAxis - tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} - axisLine={{ stroke: CHART_THEME.axisLine }} - tickLine={false} - width={32} - tickFormatter={formatter} - /> - <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> - <Bar - dataKey="value" - fill={color} - radius={[2, 2, 0, 0]} - cursor={onBarClick ? 'pointer' : undefined} - onClick={ - onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined - } - /> - </BarChart> - ) : ( - <LineChart data={data} margin={CHART_DEFAULTS.margin}> - <CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} /> - <XAxis - dataKey="label" - tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} - axisLine={{ stroke: CHART_THEME.axisLine }} - tickLine={false} - /> - <YAxis - tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} - axisLine={{ stroke: CHART_THEME.axisLine }} - tickLine={false} - width={32} - tickFormatter={formatter} - /> - <Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} /> - <Line dataKey="value" stroke={color} strokeWidth={2} dot={false} /> - </LineChart> - )} - </ResponsiveContainer> - </div> - ); - } - ``` - -- [ ] **Step 5: Update `StackedTrendChart.tsx` and `WatchTimeChart.tsx`** - - Open each file. For each chart container, apply the same recipe: - 1. Import `CartesianGrid` from `recharts`. - 2. Import `CHART_THEME`, `CHART_DEFAULTS`, `TOOLTIP_CONTENT_STYLE` from `'../../lib/chart-theme'`. - 3. Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` as the first child of `<BarChart>`/`<LineChart>`. - 4. Bump `<XAxis tick fontSize>` and `<YAxis tick fontSize>` to `CHART_DEFAULTS.tickFontSize`. - 5. Add `axisLine={{ stroke: CHART_THEME.axisLine }}` to the Y axis. - 6. Replace inline tooltip styles with `contentStyle={TOOLTIP_CONTENT_STYLE}`. - 7. Bump `<ResponsiveContainer height>` from its current value to `CHART_DEFAULTS.height` (160) only if it's currently smaller than 160. Don't shrink anything. - - If either file already exposes formatter props for the Y axis, also pass `tickFormatter={formatter}` to `YAxis` so the unit suffix shows up. - -- [ ] **Step 6: Re-run the chart-theme test plus typecheck** - - Run: `bun test stats/src/lib/chart-theme.test.ts && bun run typecheck:stats` - Expected: PASS + clean. - -- [ ] **Step 7: Sanity-check the overview tab still mounts** - - Run: `bun run build:stats` - Expected: succeeds. - -- [ ] **Step 8: Commit** - - ```bash - git add stats/src/lib/chart-theme.ts stats/src/lib/chart-theme.test.ts \ - stats/src/components/trends/TrendChart.tsx \ - stats/src/components/trends/StackedTrendChart.tsx \ - stats/src/components/overview/WatchTimeChart.tsx - git commit -m "feat(stats): unify chart theme and add gridlines for legibility" - ``` - ---- - -## Task 11: Changelog fragment - -**Files:** -- Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md` - -- [ ] **Step 1: Read the existing changelog format** - - Run: `ls changes/ | head -5 && cat changes/$(ls changes/ | head -1)` - Mirror that format exactly. - -- [ ] **Step 2: Write the fragment** - - Create `changes/2026-04-09-stats-dashboard-feedback-pass.md` with content like: - - ```markdown - --- - type: feature - scope: stats - --- - - Stats dashboard polish: - - - Library now collapses multi-episode series under a clickable header. - - Sessions tab rolls up multiple sessions of the same episode within a day. - - Trends gain a 365d range option. - - Episodes can be deleted directly from the library detail view. - - Top 50 vocabulary tightens word/reading spacing. - - Cards deleted from Anki no longer appear in the episode detail card list. - - Trend and watch-time charts gain horizontal gridlines, larger ticks, and a shared theme. - ``` - - Adjust frontmatter keys/values to match whatever existing fragments use. - -- [ ] **Step 3: Validate** - - Run: `bun run changelog:lint && bun run changelog:pr-check` - Expected: PASS. - -- [ ] **Step 4: Commit** - - ```bash - git add changes/2026-04-09-stats-dashboard-feedback-pass.md - git commit -m "docs: add changelog fragment for stats dashboard feedback pass" - ``` - ---- - -## Final verification gate - -Run the project's standard handoff gate: - -- [ ] `bun run typecheck` -- [ ] `bun run typecheck:stats` -- [ ] `bun run test:fast` -- [ ] `bun run test:env` -- [ ] `bun run test:runtime:compat` -- [ ] `bun run build` -- [ ] `bun run test:smoke:dist` -- [ ] `bun run format:check:src` -- [ ] `bun run changelog:lint` -- [ ] `bun run changelog:pr-check` - -If any of those fail, fix the underlying issue and create a new commit (do NOT amend earlier task commits — keep the per-task history clean). - -Then push the branch and open the PR. Suggested PR title: - -``` -Stats dashboard polish: collapsible library, session rollups, 365d trends, chart legibility, episode delete -``` - -Body should link to the spec at `docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md` and summarize each task. - ---- - -## Risk callouts (for the implementing agent) - -- **Anki note-info loading-state guard (Task 5):** double-check the test case for the brief window before `ankiNotesInfo` resolves. Hiding everything during that window would be a regression. -- **Nested button trap (Task 9):** the bucket header must place the toggle button and the delete button as siblings, not nested. Final code must use sibling buttons; the skeleton in the plan flags this. -- **MediaSessionList (Task 9):** rollup is intentionally not applied there. Don't forget the commit message note. -- **`useMediaLibrary` retry behavior (Task 6):** the existing hook auto-refetches when youtube metadata is missing. The new `refresh()` must not break that loop. The `[version]` dependency on the existing `useEffect` triggers a brand-new mount of the inner closure each call, which resets `retryCount` — that's the intended behavior. -- **`bun test` resolves test files relative to repo root.** Always run from `/Users/sudacode/projects/japanese/SubMiner` (the worktree root), not from `stats/`. -- **No file in this plan grows past ~250 lines after edits.** If a file does, that's a signal to extract — flag it on the way through. diff --git a/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md b/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md deleted file mode 100644 index 22858bde..00000000 --- a/docs/superpowers/specs/2026-04-09-library-summary-replaces-per-day-design.md +++ /dev/null @@ -1,184 +0,0 @@ -# Library Summary Replaces Per-Day Trends — Design - -**Status:** Draft -**Date:** 2026-04-09 -**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend - -## Problem - -The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day. - -In practice these charts are not useful: - -- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline. -- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow. -- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally. -- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise. - -The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced. - -## Goal - -Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?" - -## Non-goals - -- Changing the "Library — Cumulative" section (stays as-is). -- Changing the "Activity", "Period Trends", or "Patterns" sections. -- Adding a new API endpoint — the existing dashboard endpoint is extended in place. -- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording. -- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab. - -## Solution Overview - -Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of: - -1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending). -2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range. - -Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative. - -## Backend - -### New type - -Add to `stats/src/types/stats.ts` and the backend query module: - -```ts -type LibrarySummaryRow = { - title: string; // display title — anime series, YouTube video title, etc. - watchTimeMin: number; // sum(total_active_min) across the window - videos: number; // distinct video_id count - sessions: number; // session count from imm_sessions - cards: number; // sum(total_cards) - words: number; // sum(total_tokens_seen) - lookups: number; // sum(lookup_count) from imm_sessions - lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0 - firstWatched: number; // min(rollup_day) as epoch day, within the window - lastWatched: number; // max(rollup_day) as epoch day, within the window -}; -``` - -### Query changes in `src/core/services/immersion-tracker/query-trends.ts` - -- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`. -- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos` → `imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1. -- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming. -- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches. -- Emit `lookupsPerHundred: null` when `words == 0`. - -### Removed from API response - -Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`). - -Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted. - -Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it. - -## Frontend - -### New component: `stats/src/components/trends/LibrarySummarySection.tsx` - -Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`. - -Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`. - -### Layout - -Replaces `TrendsTab.tsx:224-254`: - -``` -[SectionHeader: "Library — Summary"] -[AnimeVisibilityFilter — unchanged, shared with Cumulative below] -[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall] -[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll] -``` - -Both cards use the existing chart/card wrapper styling. - -### Leaderboard chart - -- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts). -- Top 10 titles by watch time. If fewer titles have activity, render what's there. -- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip. -- X-axis: minutes (number). -- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`. -- Single series color: `#8aadf4` (matching the existing Watch Time color). -- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard. -- Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed. - -### Table - -- Plain HTML `<table>` with Tailwind classes. No new deps. -- Columns, in order: - 1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover. - 2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`. - 3. **Videos** — integer. - 4. **Sessions** — integer. - 5. **Cards** — integer. - 6. **Words** — integer. - 7. **Lookups** — integer. - 8. **Lookups/100w** — one decimal place, `—` when null. - 9. **Date Range** — `Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window. -- Click a column header to sort; click again to reverse. Visual arrow on the active column. -- Numeric columns right-aligned. -- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data"). -- Row hover highlight; no row click action (read-only view). -- Empty state: "No library activity in the selected window." - -### Visibility filter integration - -Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`). - -### `TrendsTab.tsx` changes - -- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals. -- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section. -- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position. -- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place. -- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays. - -## Data flow - -1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`. -2. Response now includes `librarySummary` (sorted by watch time desc). -3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged). -4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied. -5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total). - -## Edge cases - -- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden. -- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing. -- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest. -- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown. -- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip. -- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly. - -## Testing - -### Backend (`query-trends.ts`) - -New unit tests, following the existing pattern: - -1. Empty window returns `librarySummary: []`. -2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window. -3. Multiple titles: rows returned sorted by watch time desc. -4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates. -5. Title with `words == 0`: `lookupsPerHundred` is `null`. -6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity. -7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry. - -### Frontend - -- Existing Trends tab smoke test should continue to pass after wiring. -- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path. - -## Release / docs - -- One fragment in `changes/*.md` summarizing the replacement. -- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation. - -## Open items - -None. diff --git a/docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md b/docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md deleted file mode 100644 index 0a8321ab..00000000 --- a/docs/superpowers/specs/2026-04-09-stats-dashboard-feedback-pass-design.md +++ /dev/null @@ -1,347 +0,0 @@ -# Stats Dashboard Feedback Pass — Design - -Date: 2026-04-09 -Scope: Stats dashboard UX follow-ups from user feedback (items 1–7). -Delivery: **Single PR**, broken into logically scoped commits. - -## Goals - -Address seven concrete pieces of feedback against the Statistics menu: - -1. Library — collapse episodes behind a per-series dropdown. -2. Sessions — roll up multiple sessions of the same episode within a day. -3. Trends — add a 365d range option. -4. Library — delete an episode (video) from its detail view. -5. Vocabulary — tighten spacing between word and reading in the Top 50 table. -6. Episode detail — hide cards whose Anki notes have been deleted. -7. Trend/watch charts — add gridlines, fix tick legibility, unify theming. - -Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively. - -## Files touched (inventory) - -Dashboard (`stats/src/`): -- `components/library/LibraryTab.tsx` — collapsible groups (item 1). -- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4). -- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2). -- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3). -- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5). -- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6). -- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7). -- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`. - -Backend (`src/core/services/`): -- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3). -- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3). -- `stats-server.ts` — passthrough if range validation lives here (check before editing). -- `__tests__/stats-server.test.ts` — 365d coverage (item 3). - -## Commit plan - -One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones. - -1. `feat(stats): add 365d range to trends dashboard` (item 3) -2. `fix(stats): tighten word/reading column in Top 50 table` (item 5) -3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6) -4. `feat(stats): delete episode from library detail view` (item 4) -5. `feat(stats): collapsible series groups in library` (item 1) -6. `feat(stats): roll up same-episode sessions within a day` (item 2) -7. `feat(stats): gridlines and unified theme for trend charts` (item 7) - -Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below. - ---- - -## Item 1 — Library collapsible series groups - -### Current behavior - -`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header. - -### Target behavior - -Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise). - -### Implementation - -- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`. -- Toggle helper: `toggleGroup(key: string)` adds/removes from the set. -- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button. -- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`. -- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative. - -### Tests - -- New `LibraryTab.test.tsx` (if not already present — check first) covering: - - Multi-video group renders collapsed on first mount. - - Single-video group renders expanded on first mount. - - Clicking the header toggles visibility. - - Header summary is visible in both states. - ---- - -## Item 2 — Sessions episode rollup within a day - -### Current behavior - -`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view. - -### Target behavior - -Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting). - -### Implementation - -- New helper in `stats/src/lib/session-grouping.ts`: - ```ts - export interface SessionBucket { - key: string; // videoId as string, or `s-${sessionId}` for singletons - videoId: number | null; - sessions: SessionSummary[]; - totalActiveMs: number; - totalCardsMined: number; - representativeSession: SessionSummary; // most recent, for header display - } - export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[]; - ``` - Sessions missing a `videoId` become singleton buckets. - -- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket: - - `sessions.length === 1`: existing `SessionRow` behavior, unchanged. - - `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header. -- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component. - -### Delete semantics - -- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`). -- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow. - -### Tests - -- `session-grouping.test.ts`: - - Empty input → empty output. - - All unique videos → N singleton buckets. - - Two sessions same videoId → one bucket with correct totals and representative (most recent start time). - - Missing videoId → singleton bucket keyed by sessionId. -- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set. - ---- - -## Item 3 — 365d trends range - -### Backend - -`src/core/services/immersion-tracker/query-trends.ts`: -- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';` -- Add `'365d': 365` to `TREND_DAY_LIMITS`. -- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic. - -`src/core/services/stats-server.ts`: -- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it. - -### Frontend - -- `hooks/useTrends.ts`: widen the `TimeRange` union. -- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`. -- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`. - -### Tests - -- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data. -- `stats-server.test.ts`: ensure the route accepts `range=365d`. -- `api-client.test.ts`: ensure the client emits the new range. - -### Change-specific checks - -- `bun run test:config` is not required here (no schema/defaults change). -- Run `bun run typecheck` + `bun run test:fast`. - ---- - -## Item 4 — Delete episode from library detail - -### Current behavior - -`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab. - -### Target behavior - -A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches. - -### Implementation - -- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided. -- In `MediaDetailView`: - - New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`. - - Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`. -- In `LibraryTab`: - - `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back. - - Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity. - -### Tests - -- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success. -- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it. - ---- - -## Item 5 — Vocabulary word/reading column collapse - -### Current behavior - -`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap. - -### Target behavior - -Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style. - -### Implementation - -- Drop the `<th>Reading</th>` header and cell. -- Word cell becomes: - ```tsx - <td className="py-1.5 pr-3"> - <span className="text-ctp-text font-medium">{w.headword}</span> - {reading && ( - <span className="text-ctp-subtext0 text-xs ml-1.5"> - 【{reading}】 - </span> - )} - </td> - ``` - where `reading = fullReading(w.headword, w.reading)` and differs from `headword`. -- Keep `fullReading` import from `reading-utils`. - -### Tests - -- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert: - - Headword renders. - - Reading renders when different from headword. - - Reading does not render when equal to headword. - ---- - -## Item 6 — Hide Anki-deleted cards in Cards Mined - -### Current behavior - -`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere. - -### Target behavior - -After `ankiNotesInfo` resolves: -- Drop `noteId`s that are not in the resolved map. -- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering. -- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone. - -### Implementation - -- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`. -- Iterate `filteredCardEvents` instead of `cardEvents` in the render. -- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**. - -### Tests - -- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered. - -### Other call sites - -- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter. - ---- - -## Item 7 — Trend/watch chart clarity pass - -### Current behavior - -`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with: -- No `CartesianGrid` → no horizontal reference lines. -- 9px axis ticks → borderline unreadable. -- Height 120 → cramped. -- Tooltip uses raw labels (`04/04` etc.). -- No shared theme object; each chart redefines colors and tooltip styles inline. - -`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers. - -### Target behavior - -All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips. - -### Implementation - -Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break): -```ts -export const CHART_THEME = { - tick: '#a5adcb', - tooltipBg: '#363a4f', - tooltipBorder: '#494d64', - tooltipText: '#cad3f5', - tooltipLabel: '#b8c0e0', - barFill: '#8aadf4', - grid: '#494d64', - axisLine: '#494d64', -} as const; - -export const CHART_DEFAULTS = { - height: 160, - tickFontSize: 11, - margin: { top: 8, right: 8, bottom: 0, left: 0 }, - grid: { strokeDasharray: '3 3', vertical: false }, -} as const; - -export const TOOLTIP_CONTENT_STYLE = { - background: CHART_THEME.tooltipBg, - border: `1px solid ${CHART_THEME.tooltipBorder}`, - borderRadius: 6, - color: CHART_THEME.tooltipText, - fontSize: 12, -}; -``` - -Apply to each chart: -- Import `CartesianGrid` from recharts. -- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container. -- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`. -- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`. -- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`. -- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`). - -### Unit formatters - -- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.). - -### Tests - -- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable). -- `TrendChart` snapshot/render tests: no regression, gridline element present. - ---- - -## Verification gate - -Before requesting code review, run: - -``` -bun run typecheck -bun run test:fast -bun run test:env -bun run test:runtime:compat # dist-sensitive check for the charts -bun run build -bun run test:smoke:dist -``` - -No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`. - -No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required. - -## Risks and open questions - -- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation. -- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible. -- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review. -- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit. - -## Changelog entry - -User-visible PR → needs a fragment under `changes/*.md`. Suggested title: -`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.` diff --git a/docs/workflow/README.md b/docs/workflow/README.md index 0f715782..b89a6d9b 100644 --- a/docs/workflow/README.md +++ b/docs/workflow/README.md @@ -3,7 +3,7 @@ # Workflow Status: active -Last verified: 2026-05-23 +Last verified: 2026-08-13 Owner: Kyle Yasuda Read when: planning or executing nontrivial work in this repo @@ -13,7 +13,7 @@ This section is the internal workflow map for contributors and agents. - [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan - [Verification](./verification.md) - maintained test/build lanes and handoff gate -- [Agent Plugins](./agent-plugins.md) - repo-local plugin ownership for agent workflow skills +- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership - [Release Guide](../RELEASING.md) - tagged release workflow ## Default Flow diff --git a/docs/workflow/agent-plugins.md b/docs/workflow/agent-plugins.md deleted file mode 100644 index f3b8baea..00000000 --- a/docs/workflow/agent-plugins.md +++ /dev/null @@ -1,27 +0,0 @@ -<!-- read_when: using or modifying repo-local agent plugins --> - -# Agent Plugins - -Status: active -Last verified: 2026-05-23 -Owner: Kyle Yasuda -Read when: packaging or migrating repo-local agent workflow skills into plugins - -## SubMiner Workflow Plugin - -- Canonical plugin path: `plugins/subminer-workflow/` -- Marketplace catalog: `.agents/plugins/marketplace.json` -- Canonical skill sources: - - `plugins/subminer-workflow/skills/subminer-scrum-master/` - - `plugins/subminer-workflow/skills/subminer-change-verification/` - -## Migration Rule - -- Plugin-owned skills are the source of truth. -- `.agents/skills/subminer-*` remain only as compatibility shims. -- Existing script entrypoints under `.agents/skills/subminer-change-verification/scripts/` stay as wrappers so historical commands do not break. - -## Verification - -- For plugin/docs-only changes, start with `bun run test:docs:kb`. -- Use the plugin-owned verifier when the change crosses from docs into scripts or workflow logic. diff --git a/docs/workflow/agent-skills.md b/docs/workflow/agent-skills.md new file mode 100644 index 00000000..5d87ec2e --- /dev/null +++ b/docs/workflow/agent-skills.md @@ -0,0 +1,31 @@ +<!-- read_when: using or modifying repo-local agent skills --> + +# Agent Skills + +Status: active +Last verified: 2026-08-13 +Owner: Kyle Yasuda +Read when: using, adding, or changing a repo-local agent workflow skill + +## Canonical Skills + +- `.agents/skills/subminer-change-verification/` + - Selects the cheapest sufficient repo-native verification lane. + - Defers command ownership to `package.json` and `docs/workflow/verification.md`. + +Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository. + +## Rules + +- Keep each skill focused on one repeatable repository task. +- Prefer instructions over helper scripts unless deterministic tooling provides clear value beyond existing package commands. +- Keep trigger descriptions narrow enough to avoid invoking skills for unrelated requests. +- Update this page and the documentation catalog when skill ownership changes. + +## Verification + +For skill or internal workflow documentation changes, run: + +```bash +bun run test:docs:kb +``` diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index efdd4c12..51d1b212 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -3,14 +3,14 @@ # Verification Status: active -Last verified: 2026-07-06 +Last verified: 2026-08-13 Owner: Kyle Yasuda Read when: selecting the right verification lane for a change ## Lane Infrastructure - Lane membership is defined once in `scripts/test-lanes.ts` and discovered by - directory — new test files join their lane automatically; never hand-list test + directory, so new test files join their lane automatically; never hand-list test files in `package.json`. - `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process (per-file isolation with a wall timeout) so a hanging test or leaked global in @@ -43,8 +43,8 @@ bun run docs:build ## Cheap-First Lane Selection -- Docs-only boundary/content changes: `bun run docs:test`, `bun run docs:build` -- Internal KB / `AGENTS.md` changes: `bun run test:docs:kb` +- User-facing `docs-site/` changes: `bun run docs:test`, `bun run docs:build` +- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb` - Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Runtime-compat / compiled behavior: `bun run test:runtime:compat` diff --git a/plugins/subminer-workflow/.codex-plugin/plugin.json b/plugins/subminer-workflow/.codex-plugin/plugin.json deleted file mode 100644 index 735be53f..00000000 --- a/plugins/subminer-workflow/.codex-plugin/plugin.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "subminer-workflow", - "version": "0.1.0", - "description": "Repo-local SubMiner agent workflow plugin for orchestration and change verification.", - "author": { - "name": "Kyle Yasuda", - "email": "suda@sudacode.com", - "url": "https://github.com/sudacode" - }, - "homepage": "https://github.com/sudacode/SubMiner/tree/main/plugins/subminer-workflow", - "repository": "https://github.com/sudacode/SubMiner", - "license": "GPL-3.0-or-later", - "keywords": ["subminer", "workflow", "verification", "skills"], - "skills": "./skills/", - "interface": { - "displayName": "SubMiner Workflow", - "shortDescription": "SubMiner orchestration and verification.", - "longDescription": "Canonical repo-local plugin for SubMiner agent workflow packaging. Owns the scrum-master and change-verification skills plus helper scripts used to plan, verify, and validate changes reproducibly inside this repo.", - "developerName": "Kyle Yasuda", - "category": "Productivity", - "capabilities": ["Interactive", "Write"], - "websiteURL": "https://github.com/sudacode/SubMiner", - "defaultPrompt": [ - "Use SubMiner workflow to plan and ship a feature.", - "Verify a SubMiner change with the plugin-owned verifier.", - "Plan and ship this SubMiner task." - ], - "brandColor": "#2F6B4F" - } -} diff --git a/plugins/subminer-workflow/README.md b/plugins/subminer-workflow/README.md deleted file mode 100644 index 09c07273..00000000 --- a/plugins/subminer-workflow/README.md +++ /dev/null @@ -1,40 +0,0 @@ -<!-- read_when: migrating or using the repo-local SubMiner workflow plugin --> - -# SubMiner Workflow Plugin - -Status: active -Last verified: 2026-03-26 -Owner: Kyle Yasuda -Read when: using or updating the repo-local plugin that owns SubMiner agent workflow skills - -This plugin is the canonical source of truth for the SubMiner agent workflow packaging. - -## Contents - -- `skills/subminer-scrum-master/` - - intake, planning, dispatch, and handoff workflow -- `skills/subminer-change-verification/` - - cheap-first verification workflow plus helper scripts - -## Compatibility - -- `.agents/skills/subminer-scrum-master/` is a compatibility shim that redirects to the plugin-owned skill. -- `.agents/skills/subminer-change-verification/` is a compatibility shim. -- `.agents/skills/subminer-change-verification/scripts/*.sh` remain as wrapper entrypoints so existing docs and shell history keep working. - -## Verification - -For plugin/doc/shim changes, prefer: - -```bash -bun run test:docs:kb -bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane docs --lane core \ - plugins/subminer-workflow \ - .agents/skills/subminer-scrum-master/SKILL.md \ - .agents/skills/subminer-change-verification/SKILL.md \ - .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \ - .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \ - .agents/plugins/marketplace.json \ - docs/workflow/README.md \ - docs/workflow/agent-plugins.md -``` diff --git a/plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md b/plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md deleted file mode 100644 index baf3b2e5..00000000 --- a/plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: 'subminer-change-verification' -description: 'Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation.' ---- - -# SubMiner Change Verification - -Canonical source: this plugin path. - -Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior. - -## Scripts - -- `scripts/classify_subminer_diff.sh` - - Emits suggested lanes and flags from explicit paths or current git changes. -- `scripts/verify_subminer_change.sh` - - Runs selected lanes, captures artifacts, and writes a compact summary. - -If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under: - -```bash -export SUBMINER_VERIFY_SKILL="<path-to-plugin-skill>" -``` - -## Default workflow - -1. Inspect the changed files or user-requested area. -2. Run the classifier unless you already know the right lane. -3. Run the verifier with the cheapest sufficient lane set. -4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes. -5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim. -6. Return: - - verification summary - - exact commands run - - artifact paths - - skipped lanes and blockers - -## Quick start - -Plugin-source quick start: - -```bash -bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh -``` - -Installed-skill quick start: - -```bash -bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh" -``` - -Compatibility entrypoint: - -```bash -bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh -``` - -Classify explicit files: - -```bash -bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \ - launcher/main.ts \ - plugin/subminer/lifecycle.lua \ - src/main/runtime/mpv-client-runtime-service.ts -``` - -Run automatic lane selection: - -```bash -bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh -``` - -Installed-skill form: - -```bash -bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh" -``` - -Compatibility entrypoint: - -```bash -bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh -``` - -Run targeted lanes: - -```bash -bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \ - --lane launcher-plugin \ - --lane runtime-compat -``` - -Dry-run to inspect planned commands and artifact layout: - -```bash -bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \ - --dry-run \ - launcher/main.ts \ - src/main.ts -``` - -## Lane guidance - -- `docs` - - For `docs-site/`, `docs/`, and doc-only edits. -- `config` - - For `src/config/` and config-template-sensitive edits. -- `stats` - - For `stats/` dashboard UI edits. -- `core` - - For general source changes where `typecheck` + `test:fast` is the best cheap signal. -- `launcher-plugin` - - For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work. -- `runtime-compat` - - For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior. -- `real-runtime` - - Only after deliberate escalation. - -## Real Runtime Escalation - -Escalate only when the change claim depends on actual runtime behavior, for example: - -- overlay appears, hides, or tracks a real mpv window -- mpv launch flags or pause-until-ready behavior -- plugin/socket/auto-start handshake under a real player -- macOS/window-tracker/focus-sensitive behavior - -If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass. - -## Artifact contract - -The verifier writes under `.tmp/skill-verification/<timestamp>/`: - -- `summary.json` -- `summary.txt` -- `classification.txt` -- `env.txt` -- `lanes.txt` -- `steps.tsv` -- `steps/*.stdout.log` -- `steps/*.stderr.log` - -On failure, quote the exact failing command and point at the artifact directory. diff --git a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh b/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh deleted file mode 100755 index 5d5b31c1..00000000 --- a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: classify_subminer_diff.sh [path ...] - -Emit suggested verification lanes for explicit paths or current local git changes. - -Output format: - lane:<name> - flag:<name> - reason:<text> -EOF -} - -has_item() { - local needle=$1 - shift || true - local item - for item in "$@"; do - if [[ "$item" == "$needle" ]]; then - return 0 - fi - done - return 1 -} - -add_lane() { - local lane=$1 - if ! has_item "$lane" "${LANES[@]:-}"; then - LANES+=("$lane") - fi -} - -add_flag() { - local flag=$1 - if ! has_item "$flag" "${FLAGS[@]:-}"; then - FLAGS+=("$flag") - fi -} - -add_reason() { - REASONS+=("$1") -} - -collect_git_paths() { - local top_level - if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then - return 0 - fi - - ( - cd "$top_level" - if git rev-parse --verify HEAD >/dev/null 2>&1; then - git diff --name-only --relative HEAD -- - git diff --name-only --relative --cached -- - else - git diff --name-only --relative -- - git diff --name-only --relative --cached -- - fi - git ls-files --others --exclude-standard - ) | awk 'NF' | sort -u -} - -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - usage - exit 0 -fi - -declare -a PATHS=() -declare -a LANES=() -declare -a FLAGS=() -declare -a REASONS=() - -if [[ $# -gt 0 ]]; then - while [[ $# -gt 0 ]]; do - PATHS+=("$1") - shift - done -else - while IFS= read -r line; do - [[ -n "$line" ]] && PATHS+=("$line") - done < <(collect_git_paths) -fi - -if [[ ${#PATHS[@]} -eq 0 ]]; then - add_lane "core" - add_reason "no changed paths detected -> default to core" -fi - -for path in "${PATHS[@]}"; do - specialized=0 - - case "$path" in - docs-site/*|docs/*|changes/*|README.md) - add_lane "docs" - add_reason "$path -> docs" - specialized=1 - ;; - esac - - case "$path" in - src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc) - add_lane "config" - add_reason "$path -> config" - specialized=1 - ;; - esac - - case "$path" in - stats/*) - add_lane "stats" - add_reason "$path -> stats" - specialized=1 - ;; - esac - - case "$path" in - launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs) - add_lane "launcher-plugin" - add_reason "$path -> launcher-plugin" - add_flag "real-runtime-candidate" - add_reason "$path -> real-runtime-candidate" - specialized=1 - ;; - esac - - case "$path" in - src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs) - add_lane "runtime-compat" - add_reason "$path -> runtime-compat" - add_flag "real-runtime-candidate" - add_reason "$path -> real-runtime-candidate" - specialized=1 - ;; - esac - - if [[ "$specialized" == "0" ]]; then - case "$path" in - src/*|package.json|tsconfig*.json|scripts/*|Makefile) - add_lane "core" - add_reason "$path -> core" - ;; - esac - fi - - case "$path" in - package.json|src/main.ts|src/main-entry.ts|src/preload.ts) - add_flag "broad-impact" - add_reason "$path -> broad-impact" - ;; - esac -done - -if [[ ${#LANES[@]} -eq 0 ]]; then - add_lane "core" - add_reason "no lane-specific matches -> default to core" -fi - -for lane in "${LANES[@]}"; do - printf 'lane:%s\n' "$lane" -done - -for flag in "${FLAGS[@]}"; do - printf 'flag:%s\n' "$flag" -done - -for reason in "${REASONS[@]}"; do - printf 'reason:%s\n' "$reason" -done diff --git a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh b/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh deleted file mode 100755 index 9a16d906..00000000 --- a/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh +++ /dev/null @@ -1,537 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: verify_subminer_change.sh [options] [path ...] - -Options: - --lane <name> Force a verification lane. Repeatable. - --artifact-dir <dir> Use an explicit artifact directory. - --allow-real-runtime Allow explicit real-runtime execution. - --allow-real-gui Deprecated alias for --allow-real-runtime. - --dry-run Record planned steps without executing commands. - --help Show this help text. - -If no lanes are supplied, the script classifies the provided paths. If no paths are -provided, it classifies the current local git changes. - -Authoritative real-runtime verification should be requested with explicit path -arguments instead of relying on inferred local git changes. -EOF -} - -timestamp() { - date +%Y%m%d-%H%M%S -} - -timestamp_iso() { - date -u +%Y-%m-%dT%H:%M:%SZ -} - -generate_session_id() { - local tmp_dir - tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX") - basename "$tmp_dir" - rmdir "$tmp_dir" -} - -has_item() { - local needle=$1 - shift || true - local item - for item in "$@"; do - if [[ "$item" == "$needle" ]]; then - return 0 - fi - done - return 1 -} - -normalize_lane_name() { - case "$1" in - real-gui) - printf '%s' "real-runtime" - ;; - *) - printf '%s' "$1" - ;; - esac -} - -add_lane() { - local lane - lane=$(normalize_lane_name "$1") - if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then - SELECTED_LANES+=("$lane") - fi -} - -add_blocker() { - BLOCKERS+=("$1") - BLOCKED=1 -} - -validate_artifact_dir() { - local candidate=$1 - if [[ ! "$candidate" =~ ^[A-Za-z0-9._/@:+-]+$ ]]; then - echo "Invalid characters in --artifact-dir path" >&2 - exit 2 - fi -} - -append_step_record() { - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV" -} - -record_env() { - { - printf 'repo_root=%s\n' "$REPO_ROOT" - printf 'session_id=%s\n' "$SESSION_ID" - printf 'artifact_dir=%s\n' "$ARTIFACT_DIR" - printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE" - printf 'dry_run=%s\n' "$DRY_RUN" - printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME" - printf 'session_home=%s\n' "$SESSION_HOME" - printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME" - printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR" - printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR" - printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG" - printf 'pwd=%s\n' "$(pwd)" - git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true - git status --short 2>/dev/null || true - if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then - printf 'requested_paths=\n' - printf ' %s\n' "${PATH_ARGS[@]}" - fi - } >"$ARTIFACT_DIR/env.txt" -} - -run_step() { - local lane=$1 - local name=$2 - local command=$3 - local note=${4:-} - local lane_slug=${lane//[^a-zA-Z0-9_-]/-} - local slug=${name//[^a-zA-Z0-9_-]/-} - local step_slug="${lane_slug}--${slug}" - local stdout_rel="steps/${step_slug}.stdout.log" - local stderr_rel="steps/${step_slug}.stderr.log" - local stdout_path="$ARTIFACT_DIR/$stdout_rel" - local stderr_path="$ARTIFACT_DIR/$stderr_rel" - local status exit_code - - COMMANDS_RUN+=("$command") - printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${step_slug}.command.txt" - - if [[ "$DRY_RUN" == "1" ]]; then - printf '[dry-run] %s\n' "$command" >"$stdout_path" - : >"$stderr_path" - status="dry-run" - exit_code=0 - else - if HOME="$SESSION_HOME" \ - XDG_CONFIG_HOME="$SESSION_XDG_CONFIG_HOME" \ - SUBMINER_SESSION_LOGS_DIR="$SESSION_LOGS_DIR" \ - SUBMINER_SESSION_MPV_LOG="$SESSION_MPV_LOG" \ - bash -c "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then - status="passed" - exit_code=0 - EXECUTED_REAL_STEPS=1 - else - exit_code=$? - status="failed" - FAILED=1 - fi - fi - - append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note" - printf '%s\t%s\t%s\n' "$lane" "$name" "$status" - - if [[ "$status" == "failed" ]]; then - FAILURE_STEP="$name" - FAILURE_COMMAND="$command" - FAILURE_STDOUT="$stdout_rel" - FAILURE_STDERR="$stderr_rel" - return "$exit_code" - fi -} - -record_nonpassing_step() { - local lane=$1 - local name=$2 - local status=$3 - local note=$4 - local lane_slug=${lane//[^a-zA-Z0-9_-]/-} - local slug=${name//[^a-zA-Z0-9_-]/-} - local step_slug="${lane_slug}--${slug}" - local stdout_rel="steps/${step_slug}.stdout.log" - local stderr_rel="steps/${step_slug}.stderr.log" - printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel" - : >"$ARTIFACT_DIR/$stderr_rel" - append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note" - printf '%s\t%s\t%s\n' "$lane" "$name" "$status" -} - -record_skipped_step() { - record_nonpassing_step "$1" "$2" "skipped" "$3" -} - -record_blocked_step() { - add_blocker "$3" - record_nonpassing_step "$1" "$2" "blocked" "$3" -} - -record_failed_step() { - FAILED=1 - FAILURE_STEP=$2 - FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"} - local lane_slug=${1//[^a-zA-Z0-9_-]/-} - local step_slug=${2//[^a-zA-Z0-9_-]/-} - FAILURE_STDOUT="steps/${lane_slug}--${step_slug}.stdout.log" - FAILURE_STDERR="steps/${lane_slug}--${step_slug}.stderr.log" - add_blocker "$3" - record_nonpassing_step "$1" "$2" "failed" "$3" -} - -find_real_runtime_helper() { - local candidate - for candidate in \ - "$SCRIPT_DIR/run_real_runtime_smoke.sh" \ - "$SCRIPT_DIR/run_real_mpv_smoke.sh"; do - if [[ -x "$candidate" ]]; then - printf '%s' "$candidate" - return 0 - fi - done - return 1 -} - -acquire_real_runtime_lease() { - local lease_root="$REPO_ROOT/.tmp/skill-verification/locks" - local lease_dir="$lease_root/exclusive-real-runtime" - mkdir -p "$lease_root" - if mkdir "$lease_dir" 2>/dev/null; then - REAL_RUNTIME_LEASE_DIR="$lease_dir" - printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id" - return 0 - fi - - local owner="" - if [[ -f "$lease_dir/session_id" ]]; then - owner=$(cat "$lease_dir/session_id") - fi - REAL_RUNTIME_LEASE_ERROR="real-runtime lease already held${owner:+ by $owner}" - return 1 -} - -release_real_runtime_lease() { - if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then - if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then - local owner - owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id") - if [[ "$owner" != "$SESSION_ID" ]]; then - return 0 - fi - fi - rm -rf "$REAL_RUNTIME_LEASE_DIR" - fi -} - -compute_final_status() { - if [[ "$FAILED" == "1" ]]; then - FINAL_STATUS="failed" - elif [[ "$BLOCKED" == "1" ]]; then - FINAL_STATUS="blocked" - elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then - FINAL_STATUS="passed" - else - FINAL_STATUS="skipped" - fi -} - -write_summary_files() { - local lane_lines - lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}") - printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt" - # bash 3.2 raises "unbound variable" under set -u when expanding an empty - # array, so guard on length (matching the idiom used elsewhere here). - if [[ ${#BLOCKERS[@]} -gt 0 ]]; then - printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt" - else - : >"$ARTIFACT_DIR/blockers.txt" - fi - if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then - printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt" - else - : >"$ARTIFACT_DIR/requested-paths.txt" - fi - - ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \ - SESSION_ID_ENV="$SESSION_ID" \ - FINAL_STATUS_ENV="$FINAL_STATUS" \ - PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \ - ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \ - SESSION_HOME_ENV="$SESSION_HOME" \ - SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \ - SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \ - SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \ - SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \ - STARTED_AT_ENV="$STARTED_AT" \ - FINISHED_AT_ENV="$FINISHED_AT" \ - FAILED_ENV="$FAILED" \ - FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \ - FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \ - FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \ - bun -e ' - const fs = require("fs"); - const path = require("path"); - - const lines = fs - .readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "steps.tsv"), "utf8") - .trim() - .split("\n") - .filter(Boolean) - .slice(1) - .map((line) => { - const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t"); - return { lane, name, status, exitCode: Number(exitCode), command, stdout, stderr, note }; - }); - - const payload = { - sessionId: process.env.SESSION_ID_ENV, - startedAt: process.env.STARTED_AT_ENV, - finishedAt: process.env.FINISHED_AT_ENV, - status: process.env.FINAL_STATUS_ENV, - pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV, - allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1", - sessionHome: process.env.SESSION_HOME_ENV, - sessionXdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV, - sessionMpvDir: process.env.SESSION_MPV_DIR_ENV, - sessionLogsDir: process.env.SESSION_LOGS_DIR_ENV, - sessionMpvLog: process.env.SESSION_MPV_LOG_ENV, - failed: process.env.FAILED_ENV === "1", - failure: process.env.FAILURE_COMMAND_ENV - ? { - command: process.env.FAILURE_COMMAND_ENV, - stdout: process.env.FAILURE_STDOUT_ENV, - stderr: process.env.FAILURE_STDERR_ENV, - } - : null, - blockers: fs - .readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "blockers.txt"), "utf8") - .split("\n") - .filter(Boolean), - lanes: fs - .readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "lanes.txt"), "utf8") - .split("\n") - .filter(Boolean), - requestedPaths: fs - .readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "requested-paths.txt"), "utf8") - .split("\n") - .filter(Boolean), - steps: lines, - }; - - fs.writeFileSync( - path.join(process.env.ARTIFACT_DIR_ENV, "summary.json"), - JSON.stringify(payload, null, 2) + "\n", - ); - - const summaryLines = [ - `status: ${payload.status}`, - `session: ${payload.sessionId}`, - `artifacts: ${process.env.ARTIFACT_DIR_ENV}`, - `lanes: ${payload.lanes.join(", ") || "(none)"}`, - ]; - - if (payload.requestedPaths.length > 0) { - summaryLines.push("requested paths:"); - for (const entry of payload.requestedPaths) { - summaryLines.push(`- ${entry}`); - } - } - - if (payload.failure) { - summaryLines.push(`failure command: ${payload.failure.command}`); - summaryLines.push(`failure stdout: ${payload.failure.stdout}`); - summaryLines.push(`failure stderr: ${payload.failure.stderr}`); - } - - if (payload.blockers.length > 0) { - summaryLines.push("blockers:"); - for (const blocker of payload.blockers) { - summaryLines.push(`- ${blocker}`); - } - } - - summaryLines.push("steps:"); - for (const step of payload.steps) { - summaryLines.push(`- ${step.lane}/${step.name}: ${step.status}`); - } - - fs.writeFileSync( - path.join(process.env.ARTIFACT_DIR_ENV, "summary.txt"), - summaryLines.join("\n") + "\n", - ); - ' -} - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -SKILL_DIR=$(cd "$SCRIPT_DIR/.." && pwd) -REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) - -declare -a PATH_ARGS=() -declare -a SELECTED_LANES=() -declare -a COMMANDS_RUN=() -declare -a BLOCKERS=() - -ALLOW_REAL_RUNTIME=0 -DRY_RUN=0 -FAILED=0 -BLOCKED=0 -EXECUTED_REAL_STEPS=0 -FAILURE_STEP="" -FAILURE_COMMAND="" -FAILURE_STDOUT="" -FAILURE_STDERR="" -REAL_RUNTIME_LEASE_DIR="" -REAL_RUNTIME_LEASE_ERROR="" -PATH_SELECTION_MODE="auto" - -trap 'release_real_runtime_lease' EXIT - -while [[ $# -gt 0 ]]; do - case "$1" in - --lane) - shift - [[ $# -gt 0 ]] || { - echo "Missing value for --lane" >&2 - exit 2 - } - add_lane "$1" - PATH_SELECTION_MODE="explicit-lanes" - ;; - --artifact-dir) - shift - [[ $# -gt 0 ]] || { - echo "Missing value for --artifact-dir" >&2 - exit 2 - } - ARTIFACT_DIR=$1 - ;; - --allow-real-runtime|--allow-real-gui) - ALLOW_REAL_RUNTIME=1 - ;; - --dry-run) - DRY_RUN=1 - ;; - --help|-h) - usage - exit 0 - ;; - *) - PATH_ARGS+=("$1") - ;; - esac - shift || true -done - -if [[ -z "${ARTIFACT_DIR:-}" ]]; then - SESSION_ID=$(generate_session_id) - ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID" -else - validate_artifact_dir "$ARTIFACT_DIR" - SESSION_ID=$(basename "$ARTIFACT_DIR") -fi - -mkdir -p "$ARTIFACT_DIR/steps" -STEPS_TSV="$ARTIFACT_DIR/steps.tsv" -printf 'lane\tstep\tstatus\texit_code\tcommand\tstdout\tstderr\tnote\n' >"$STEPS_TSV" - -STARTED_AT=$(timestamp_iso) -SESSION_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/home" -SESSION_XDG_CONFIG_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/xdg-config" -SESSION_MPV_DIR="$SESSION_XDG_CONFIG_HOME/mpv" -SESSION_LOGS_DIR="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/logs" -SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log" -mkdir -p "$SESSION_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR" - -CLASSIFIER_OUTPUT="$ARTIFACT_DIR/classification.txt" -if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then - if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then - PATH_SELECTION_MODE="explicit-paths" - fi - if "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}" >"$CLASSIFIER_OUTPUT"; then - while IFS= read -r line; do - case "$line" in - lane:*) - add_lane "${line#lane:}" - ;; - esac - done <"$CLASSIFIER_OUTPUT" - else - record_failed_step "meta" "classify" "classification failed" - fi -else - : >"$CLASSIFIER_OUTPUT" -fi - -record_env - -if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then - add_lane "core" -fi - -for lane in "${SELECTED_LANES[@]}"; do - case "$lane" in - docs) - run_step "$lane" "docs-kb" "bun run test:docs:kb" || break - ;; - config) - run_step "$lane" "config" "bun run test:config" || break - ;; - stats) - run_step "$lane" "stats" "bun run test:stats" || break - ;; - core) - run_step "$lane" "typecheck" "bun run typecheck" || break - run_step "$lane" "fast-tests" "bun run test:fast" || break - ;; - launcher-plugin) - run_step "$lane" "launcher" "bun run test:launcher" || break - run_step "$lane" "plugin-src" "bun run test:plugin:src" || break - ;; - runtime-compat) - run_step "$lane" "runtime-compat" "bun run test:runtime:compat" || break - ;; - real-runtime) - if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then - record_blocked_step "$lane" "real-runtime" "real-runtime requested without --allow-real-runtime" - continue - fi - if ! acquire_real_runtime_lease; then - record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR" - continue - fi - helper=$(find_real_runtime_helper || true) - if [[ -z "${helper:-}" ]]; then - record_blocked_step "$lane" "real-runtime-helper" "no real-runtime helper script available in $SCRIPT_DIR" - continue - fi - run_step "$lane" "real-runtime" "\"$helper\" \"$SESSION_ID\" \"$ARTIFACT_DIR\"" || break - ;; - *) - record_blocked_step "$lane" "unknown-lane" "unknown lane: $lane" - ;; - esac -done - -release_real_runtime_lease -FINISHED_AT=$(timestamp_iso) -compute_final_status -write_summary_files - -printf 'summary:%s\n' "$ARTIFACT_DIR/summary.txt" -cat "$ARTIFACT_DIR/summary.txt" diff --git a/plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md b/plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md deleted file mode 100644 index 0695c6a0..00000000 --- a/plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: 'subminer-scrum-master' -description: 'Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Records a plan, dispatches one or more subagents when useful, and requires verification before handoff.' ---- - -# SubMiner Scrum Master - -Canonical source: this plugin path. - -Own workflow, not code by default. - -Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, worker dispatch, and verification through completion. - -## Core Rules - -1. Keep the process light for questions, obvious mechanical edits, and tiny isolated changes. -2. Record a plan before dispatching coding work. -3. Split multi-part work into clear phases and ownership areas. -4. Dispatch conservatively. Parallelize only disjoint write scopes. -5. Require verification before handoff, typically via `subminer-change-verification`. -6. Report dispatched workers, verification, blockers, and remaining risks. - -## Intake Workflow - -1. Parse the request. - Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up. -2. Write a short working plan in-thread when the work is nontrivial. -3. Choose execution mode: - - no subagents for trivial work - - one worker for focused work - - parallel workers only for disjoint scopes -4. Run verification before handoff. - -## Dispatch Rules - -The scrum master orchestrates. Workers implement. - -- Do not become the default implementer unless delegation is unnecessary. -- Do not parallelize overlapping files or tightly coupled runtime work. -- Give every worker explicit ownership of files/modules. -- Tell every worker other agents may be active and they must not revert unrelated edits. -- Require each worker to report: - - changed files - - tests run - - blockers - -Use worker agents for implementation and explorer agents only for bounded codebase questions. - -## Verification - -Every nontrivial code task gets verification. - -Preferred flow: - -1. use `subminer-change-verification` -2. start with the cheapest sufficient lane -3. escalate only when needed -4. if worker verification is sufficient, accept it or run one final consolidating pass - -Never hand off nontrivial work without stating what was verified and what was skipped. - -## Pre-Handoff Policy Checks - -Before handoff, always ask and answer both questions explicitly: - -1. Docs update required? -2. Changelog fragment required? - -Rules: - -- Do not assume silence implies "no." -- If the answer is yes, complete the update or report the blocker. -- Include final yes/no answers in the handoff summary even when both answers are "no." - -## Failure / Scope Handling - -- If a worker hits ambiguity, pause and ask the user. -- If verification fails, either: - - send the worker back with exact failure context, or - - fix it directly if it is tiny and clearly in scope -- If new scope appears, pause and re-plan before silently expanding work. - -## Representative Flows - -### Trivial work - -- keep a short plan -- implement directly or with one worker if helpful -- run targeted verification -- report outcome concisely - -### Focused implementation - -- record plan -- dispatch one worker -- integrate -- verify -- report outcome - -### Multi-part execution - -- define distinct deliverables/phases -- record sequencing in the plan -- dispatch workers only where scopes are disjoint -- integrate -- run consolidated verification -- report outcome - -## Output Expectations - -At the end, report: - -- which workers were dispatched and what they owned -- what verification ran -- explicit answers to: - - docs update required? - - changelog fragment required? -- blockers, skips, and risks diff --git a/scripts/docs-knowledge-base.test.ts b/scripts/docs-knowledge-base.test.ts index 97e5ba1f..d08052df 100644 --- a/scripts/docs-knowledge-base.test.ts +++ b/scripts/docs-knowledge-base.test.ts @@ -19,6 +19,7 @@ const requiredDocs = [ 'docs/knowledge-base/catalog.md', 'docs/knowledge-base/quality.md', 'docs/workflow/README.md', + 'docs/workflow/agent-skills.md', 'docs/workflow/planning.md', 'docs/workflow/verification.md', ] as const; diff --git a/scripts/subminer-change-verification.test.ts b/scripts/subminer-change-verification.test.ts deleted file mode 100644 index 6f28db2e..00000000 --- a/scripts/subminer-change-verification.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import test from 'node:test'; - -const repoRoot = process.cwd(); -const classifyScript = path.join( - repoRoot, - '.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh', -); -const verifyScript = path.join( - repoRoot, - '.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh', -); - -function withTempDir<T>(fn: (dir: string) => T): T { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-')); - try { - return fn(dir); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -function runBash(args: string[]) { - return spawnSync('bash', args, { - cwd: repoRoot, - env: process.env, - encoding: 'utf8', - }); -} - -function parseArtifactDir(stdout: string): string { - const match = stdout.match(/^artifacts: (.+)$/m); - assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`); - return match[1] ?? ''; -} - -function readSummaryJson(artifactDir: string) { - return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as { - sessionId: string; - status: string; - lanes: string[]; - blockers?: string[]; - artifactDir: string; - pathSelectionMode?: string; - steps: Array<{ - lane: string; - name: string; - stdout: string; - stderr: string; - note: string; - }>; - }; -} - -test('classifier marks launcher and plugin paths as real-runtime candidates', () => { - const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']); - - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /^lane:launcher-plugin$/m); - assert.match(result.stdout, /^flag:real-runtime-candidate$/m); - assert.doesNotMatch(result.stdout, /real-gui-candidate/); -}); - -test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => { - withTempDir((root) => { - const artifactDir = path.join(root, 'artifacts'); - const result = runBash([ - verifyScript, - '--dry-run', - '--artifact-dir', - artifactDir, - '--lane', - 'real-runtime', - 'launcher/mpv.ts', - ]); - - assert.equal(result.status, 0, result.stdout); - - const summary = readSummaryJson(artifactDir); - assert.equal(summary.status, 'blocked'); - assert.deepEqual(summary.lanes, ['real-runtime']); - assert.ok(summary.sessionId.length > 0); - assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime'))); - assert.equal(fs.existsSync(path.join(artifactDir, 'summary.json')), true); - }); -}); - -test('verifier fails closed for unknown lanes', () => { - withTempDir((root) => { - const artifactDir = path.join(root, 'artifacts'); - const result = runBash([ - verifyScript, - '--dry-run', - '--artifact-dir', - artifactDir, - '--lane', - 'not-a-lane', - 'src/main.ts', - ]); - - assert.equal(result.status, 0, result.stdout); - - const summary = readSummaryJson(artifactDir); - assert.equal(summary.status, 'blocked'); - assert.deepEqual(summary.lanes, ['not-a-lane']); - assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane'))); - }); -}); - -test('verifier keeps non-passing step artifacts distinct across lanes', () => { - withTempDir((root) => { - const artifactDir = path.join(root, 'artifacts'); - const result = runBash([ - verifyScript, - '--dry-run', - '--artifact-dir', - artifactDir, - '--lane', - 'docs', - '--lane', - 'not-a-lane', - 'src/main.ts', - ]); - - assert.equal(result.status, 0, result.stdout); - - const summary = readSummaryJson(artifactDir); - const docsStep = summary.steps.find((step) => step.lane === 'docs' && step.name === 'docs-kb'); - const unknownStep = summary.steps.find( - (step) => step.lane === 'not-a-lane' && step.name === 'unknown-lane', - ); - - assert.ok(docsStep); - assert.ok(unknownStep); - assert.notEqual(docsStep?.stdout, unknownStep?.stdout); - assert.equal(fs.existsSync(path.join(artifactDir, docsStep!.stdout)), true); - assert.equal(fs.existsSync(path.join(artifactDir, unknownStep!.stdout)), true); - }); -}); - -test('verifier records the real-runtime lease blocker once', () => { - withTempDir((root) => { - const artifactDir = path.join(root, 'artifacts'); - const leaseDir = path.join( - repoRoot, - '.tmp', - 'skill-verification', - 'locks', - 'exclusive-real-runtime', - ); - fs.mkdirSync(leaseDir, { recursive: true }); - fs.writeFileSync(path.join(leaseDir, 'session_id'), 'other-session'); - - try { - const result = runBash([ - verifyScript, - '--dry-run', - '--artifact-dir', - artifactDir, - '--allow-real-runtime', - '--lane', - 'real-runtime', - 'launcher/mpv.ts', - ]); - - assert.equal(result.status, 0, result.stdout); - - const summary = readSummaryJson(artifactDir); - assert.deepEqual(summary.blockers, ['real-runtime lease already held by other-session']); - } finally { - fs.rmSync(leaseDir, { recursive: true, force: true }); - } - }); -}); - -test('verifier allocates unique session ids and artifact roots by default', () => { - const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']); - const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']); - - assert.equal(first.status, 0, first.stderr || first.stdout); - assert.equal(second.status, 0, second.stderr || second.stdout); - - const firstArtifactDir = parseArtifactDir(first.stdout); - const secondArtifactDir = parseArtifactDir(second.stdout); - - try { - const firstSummary = readSummaryJson(firstArtifactDir); - const secondSummary = readSummaryJson(secondArtifactDir); - - assert.notEqual(firstSummary.sessionId, secondSummary.sessionId); - assert.notEqual(firstArtifactDir, secondArtifactDir); - assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes'); - assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes'); - } finally { - fs.rmSync(firstArtifactDir, { recursive: true, force: true }); - fs.rmSync(secondArtifactDir, { recursive: true, force: true }); - } -});