mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 16:19:27 -07:00
Compare commits
23 Commits
windows-qo
...
v0.12.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 009579f55e | |||
| 1bd696ef11 | |||
| 29b85fd084 | |||
| c9ce337c1a | |||
| d81fe87982 | |||
| aa6903d457 | |||
| 659f468bfb | |||
| 87fbe6c002 | |||
| e06f12634f | |||
| 48f74db239 | |||
| fd6dea9d33 | |||
| 0cdd79da9a | |||
| 3e7573c9fc | |||
| 20a0efe572 | |||
| 7698258f61 | |||
| ac25213255 | |||
| a5dbe055fc | |||
| 04742b1806 | |||
| f0e15c5dc4 | |||
| 9145c730b5 | |||
| cf86817cd8 | |||
| 3f7de73734 | |||
| de9b887798 |
2
.github/workflows/prerelease.yml
vendored
2
.github/workflows/prerelease.yml
vendored
@@ -137,7 +137,6 @@ jobs:
|
||||
with:
|
||||
name: appimage
|
||||
path: release/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
needs: [quality-gate]
|
||||
@@ -213,7 +212,6 @@ jobs:
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
needs: [quality-gate]
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
id: TASK-285
|
||||
title: Rename anime visibility filter heading to title visibility
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-10 00:00'
|
||||
updated_date: '2026-04-10 00:00'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
- bug
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- stats/src/components/trends/TrendsTab.tsx
|
||||
- stats/src/components/trends/TrendsTab.test.tsx
|
||||
priority: low
|
||||
ordinal: 200000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Align the library cumulative trends filter UI with the new terminology by renaming the hardcoded anime visibility heading to title visibility.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The trends filter heading uses `Title Visibility`
|
||||
- [x] #2 The component behavior and props stay unchanged
|
||||
- [x] #3 A regression test covers the rendered heading text
|
||||
<!-- AC:END -->
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
id: TASK-286
|
||||
title: 'Assess and address PR #49 CodeRabbit review follow-ups'
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-11 18:55'
|
||||
updated_date: '2026-04-11 22:40'
|
||||
labels:
|
||||
- bug
|
||||
- code-review
|
||||
- windows
|
||||
- overlay
|
||||
dependencies: []
|
||||
references:
|
||||
- src/main/runtime/config-hot-reload-handlers.ts
|
||||
- src/renderer/handlers/keyboard.ts
|
||||
- src/renderer/handlers/mouse.ts
|
||||
- vendor/subminer-yomitan
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Track the current PR #49 review round and resolve the actionable CodeRabbit findings on the Windows update branch.
|
||||
|
||||
Focus areas include the renderer mouse interaction fix, config hot-reload keyboard state, and any other review items that still apply after verifying the current branch state.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All actionable CodeRabbit comments on PR #49 are either fixed or shown to be obsolete with evidence.
|
||||
- [x] #2 Regression tests are added or updated for any behavior change that could regress.
|
||||
- [x] #3 The branch passes the repo's relevant verification checks for the touched areas.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Pull the current unresolved CodeRabbit review threads for PR #49 and cluster them into still-actionable fixes versus obsolete/nit-only items.
|
||||
2. For each still-actionable behavior bug, add or extend the narrowest failing test first in the touched suite before changing production code.
|
||||
3. Implement the minimal fixes across the affected runtime, renderer, plugin, IPC, and Windows tracker files, keeping each change traceable to the review thread.
|
||||
4. Run targeted verification for the touched areas, update task notes with assessment results, and capture which review comments were fixed versus assessed as obsolete or deferred nitpicks.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Assessed PR #49 CodeRabbit threads. Fixed the real regressions in first-run CLI gating, IPC session-action validation, renderer controller-modal lifecycle notifications, async subtitle-sidebar toggle guarding, plugin config-dir resolution priority, prerelease artifact upload failure handling, immersion tracker lazy startup, win32 z-order error handling, and Windows socket-aware mpv matching.
|
||||
|
||||
Review assessment: the overlay-shortcut lifecycle comment is obsolete for the current architecture because overlay-local shortcuts are intentionally handled through the local fallback path and the runtime only tracks configured-state/cleanup. Refactor-only nit comments for splitting `scripts/build-changelog.ts` and `src/core/services/session-bindings.ts` were left as follow-up quality work, not behavior bugs in this PR round.
|
||||
|
||||
Verification: `bun test src/main/runtime/first-run-setup-service.test.ts src/core/services/session-bindings.test.ts src/core/services/app-ready.test.ts src/core/services/ipc.test.ts src/renderer/handlers/keyboard.test.ts src/main/overlay-runtime.test.ts src/window-trackers/mpv-socket-match.test.ts`, `bun test src/window-trackers/windows-tracker.test.ts`, `bun run typecheck`, `lua scripts/test-plugin-lua-compat.lua`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Assessed the current CodeRabbit round on PR #49 and addressed the still-valid behavior issues rather than blanket-applying every bot suggestion. The branch now treats the new session/stats CLI flags as explicit startup commands during first-run setup, validates the new session actions through IPC, points session-binding command diagnostics at the correct config field, keeps immersion tracker startup lazy until later runtime triggers, and notifies overlay modal lifecycle state when controller-select/debug are opened from local keyboard bindings. I also switched the subtitle-sidebar IPC callback to the async guarded path so promise rejections feed renderer recovery instead of being dropped.
|
||||
|
||||
On the Windows/plugin side, the mpv plugin now prefers config-file matches before falling back to an existing config directory, prerelease workflow uploads fail if expected Linux/macOS artifacts are missing, the Win32 z-order bind path now validates the `GetWindowLongW` call for the window above mpv, and the Windows tracker now passes the target socket path into native polling and filters mpv instances by command line so multiple sockets can be distinguished on Windows. Added/updated regression coverage for first-run gating, IPC validation, session-binding diagnostics, controller modal lifecycle notifications, modal ready-listener dispatch, and socket-path matching. Verification run: `bun run typecheck`, the targeted Bun test suites for the touched areas, `bun test src/window-trackers/windows-tracker.test.ts`, and `lua scripts/test-plugin-lua-compat.lua`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
id: TASK-287
|
||||
title: Restore Lua parser compatibility for mpv plugin modules
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-11 21:25'
|
||||
updated_date: '2026-04-11 21:29'
|
||||
labels:
|
||||
- bug
|
||||
- mpv-plugin
|
||||
- lua
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Users with Lua runtimes that do not accept the current `goto continue` pattern in the mpv plugin should be able to load the plugin without syntax errors. Remove the parser-incompatible control-flow usage from the affected plugin modules without changing plugin behavior.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The mpv plugin source no longer relies on parser-incompatible `goto continue` labels in the affected Lua modules.
|
||||
- [x] #2 Automated coverage fails on the old parser-incompatible source and passes once the compatibility fix is in place.
|
||||
- [x] #3 Existing plugin start/gate verification still passes after the compatibility fix.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Reused existing local cleanups in `plugin/subminer/hover.lua` and `plugin/subminer/environment.lua` to remove `goto continue` / `::continue::` control flow without behavior changes.
|
||||
|
||||
Added `scripts/test-plugin-lua-compat.lua` and wired it into `test:plugin:src`; the regression checks reject the legacy pattern structurally and verify parse success with `luajit` when available.
|
||||
|
||||
Verification run on 2026-04-11: `lua scripts/test-plugin-lua-compat.lua` ✅, `bun run test:plugin:src` ✅, `bun run changelog:lint` ✅, `bun run typecheck` ✅, `bun run test:env` ✅, `bun run build` ✅, `bun run test:smoke:dist` ✅.
|
||||
|
||||
`bun run test:fast` remains red for unrelated existing immersion-tracker assertions in `src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts` and `src/core/services/immersion-tracker/__tests__/query.test.ts` (`tsMs`/`lastWatchedMs` observed as `-2147483648`).
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Removed parser-incompatible `goto continue` usage from the affected mpv Lua plugin modules, added a dedicated Lua compatibility regression script to the plugin test lane, and added a changelog fragment for the user-visible fix. Requested plugin verification is green; unrelated existing `test:fast` immersion-tracker failures remain outside this task.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
id: TASK-288
|
||||
title: Stabilize immersion-tracker CI timestamp handling under libsql/Bun
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-11 21:34'
|
||||
updated_date: '2026-04-11 21:43'
|
||||
labels:
|
||||
- bug
|
||||
- ci
|
||||
- immersion-tracker
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
`bun run test:fast` is currently failing because large millisecond timestamps are not handled safely through the libsql/Bun path. Fix timestamp parsing/storage so lifetime/library and session-event queries return correct wall-clock values in CI and runtime.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Large wall-clock timestamps round-trip correctly through immersion-tracker lifetime/library queries under the repo's Bun/libsql runtime.
|
||||
- [x] #2 Session-event timestamps round-trip correctly for real wall-clock values used by runtime event inserts.
|
||||
- [x] #3 Targeted immersion-tracker regression coverage passes, and the previously failing `test:fast` lane no longer fails on these timestamp assertions.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Root cause split in two places: Bun/libsql corrupts large millisecond timestamp strings when coerced through `Number(...)`, and `imm_session_events.ts_ms` being `INTEGER` let runtime event inserts/readbacks return `-2147483648` on CI/runtime.
|
||||
|
||||
Fix shipped by parsing timestamp strings without the broken `Number(largeString)` path, migrating `imm_session_events.ts_ms` to `TEXT`, ordering/retention queries via `CAST(ts_ms AS REAL)`, and avoiding `Number(currentMs)` when reusing already-normalized timestamp strings.
|
||||
|
||||
Added regression coverage for both real runtime event inserts and schema migration/repair of previously truncated session-event rows.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed immersion-tracker timestamp handling under Bun/libsql so large wall-clock millisecond values survive runtime writes, query reads, and schema migration. `bun run test:fast`, `bun run typecheck`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`, and `bun run changelog:lint` all pass after the patch.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
id: TASK-289
|
||||
title: Finish current windows-qol rebase
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-11 22:07'
|
||||
updated_date: '2026-04-11 22:08'
|
||||
labels:
|
||||
- maintenance
|
||||
- rebase
|
||||
dependencies: []
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Resolve the in-progress rebase on `windows-qol` and ensure the branch lands cleanly.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Interactive rebase completes without conflicts.
|
||||
- [x] #2 Working tree is clean after the rebase finishes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Completed the interactive rebase on `windows-qol` and resolved the transient editor-blocked `git rebase --continue` step. Branch now rebased cleanly onto `49e46e6b`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Addressed the latest CodeRabbit follow-ups on PR #49, including generation-scoped Lua session binding names, stricter session command validation, session-help shortcut visibility, the numeric-selection key guard, stats-overlay startup classification, and safer session-binding persistence.
|
||||
@@ -4,4 +4,3 @@ area: overlay
|
||||
- Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
|
||||
- Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
|
||||
- Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
|
||||
- Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Fixed immersion-tracker timestamp handling under Bun/libsql so library rows, session timelines, and lifetime summaries keep real wall-clock millisecond values instead of truncating to invalid negative timestamps.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: mpv-plugin
|
||||
|
||||
- Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes.
|
||||
@@ -1,11 +0,0 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
|
||||
- Trends add a 365-day range next to the existing 7d/30d/90d/all options.
|
||||
- Library detail view gets a delete-episode action that removes the video and all its sessions.
|
||||
- Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
|
||||
- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
|
||||
- Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
|
||||
- Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
|
||||
- Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart 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.
|
||||
@@ -177,7 +177,7 @@
|
||||
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
|
||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
||||
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -540,7 +540,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
"openControllerSelect": "Alt+C",
|
||||
"openControllerDebug": "Alt+Shift+C",
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
"toggleSubtitleSidebar": "Backslash",
|
||||
"toggleSubtitleSidebar": "\\",
|
||||
"multiCopyTimeoutMs": 3000
|
||||
}
|
||||
}
|
||||
@@ -564,7 +564,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"\\"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
|
||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
||||
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.`
|
||||
@@ -45,7 +45,7 @@
|
||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts",
|
||||
|
||||
@@ -71,13 +71,7 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
for _, dir in ipairs(candidates) do
|
||||
if file_exists(join_path(dir, "config.jsonc")) or file_exists(join_path(dir, "config.json")) then
|
||||
return dir
|
||||
end
|
||||
end
|
||||
|
||||
for _, dir in ipairs(candidates) do
|
||||
if file_exists(dir) then
|
||||
if file_exists(join_path(dir, "config.jsonc")) or file_exists(join_path(dir, "config.json")) or file_exists(dir) then
|
||||
return dir
|
||||
end
|
||||
end
|
||||
@@ -114,26 +108,33 @@ function M.create(ctx)
|
||||
if not image then
|
||||
image = line:match('^"([^"]+)"')
|
||||
end
|
||||
if image then
|
||||
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
|
||||
return true
|
||||
end
|
||||
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
|
||||
return true
|
||||
end
|
||||
if not image then
|
||||
goto continue
|
||||
end
|
||||
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
|
||||
return true
|
||||
end
|
||||
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
|
||||
return true
|
||||
end
|
||||
else
|
||||
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
|
||||
if argv0 and not argv0:find("subminer.lua", 1, true) and not argv0:find("subminer.conf", 1, true) then
|
||||
local exe = argv0:match("([^/\\]+)$") or argv0
|
||||
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
|
||||
return true
|
||||
end
|
||||
if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
|
||||
return true
|
||||
end
|
||||
if not argv0 then
|
||||
goto continue
|
||||
end
|
||||
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
|
||||
goto continue
|
||||
end
|
||||
local exe = argv0:match("([^/\\]+)$") or argv0
|
||||
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
|
||||
return true
|
||||
end
|
||||
if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -189,37 +189,41 @@ function M.create(ctx)
|
||||
local source_len = #plain
|
||||
local cursor = 1
|
||||
for _, token in ipairs(payload.tokens or {}) do
|
||||
if type(token) == "table" and type(token.text) == "string" and token.text ~= "" then
|
||||
local token_text = token.text
|
||||
local start_pos = nil
|
||||
local end_pos = nil
|
||||
if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
|
||||
goto continue
|
||||
end
|
||||
|
||||
if type(token.startPos) == "number" and type(token.endPos) == "number" then
|
||||
if token.startPos >= 0 and token.endPos >= token.startPos then
|
||||
local candidate_start = token.startPos + 1
|
||||
local candidate_stop = token.endPos
|
||||
if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then
|
||||
start_pos = candidate_start
|
||||
end_pos = candidate_stop
|
||||
end
|
||||
end
|
||||
end
|
||||
local token_text = token.text
|
||||
local start_pos = nil
|
||||
local end_pos = nil
|
||||
|
||||
if not start_pos or not end_pos then
|
||||
local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
|
||||
if not fallback_start then
|
||||
fallback_start, fallback_stop = plain:find(token_text, 1, true)
|
||||
if type(token.startPos) == "number" and type(token.endPos) == "number" then
|
||||
if token.startPos >= 0 and token.endPos >= token.startPos then
|
||||
local candidate_start = token.startPos + 1
|
||||
local candidate_stop = token.endPos
|
||||
if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then
|
||||
start_pos = candidate_start
|
||||
end_pos = candidate_stop
|
||||
end
|
||||
start_pos, end_pos = fallback_start, fallback_stop
|
||||
end
|
||||
|
||||
if start_pos and end_pos then
|
||||
if token.index == payload.hoveredTokenIndex then
|
||||
return start_pos, end_pos
|
||||
end
|
||||
cursor = end_pos + 1
|
||||
end
|
||||
end
|
||||
|
||||
if not start_pos or not end_pos then
|
||||
local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
|
||||
if not fallback_start then
|
||||
fallback_start, fallback_stop = plain:find(token_text, 1, true)
|
||||
end
|
||||
start_pos, end_pos = fallback_start, fallback_stop
|
||||
end
|
||||
|
||||
if start_pos and end_pos then
|
||||
if token.index == payload.hoveredTokenIndex then
|
||||
return start_pos, end_pos
|
||||
end
|
||||
cursor = end_pos + 1
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
|
||||
return nil
|
||||
|
||||
@@ -313,14 +313,12 @@ function M.create(ctx)
|
||||
|
||||
local previous_binding_names = state.session_binding_names
|
||||
local next_binding_names = {}
|
||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||
local generation = state.session_binding_generation
|
||||
|
||||
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
local key_name = key_spec_to_mpv_binding(binding.key)
|
||||
if key_name then
|
||||
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
|
||||
local name = "subminer-session-binding-" .. tostring(index)
|
||||
next_binding_names[#next_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
handle_binding(binding, timeout_ms)
|
||||
|
||||
@@ -33,7 +33,6 @@ function M.new()
|
||||
auto_play_ready_timeout = nil,
|
||||
auto_play_ready_osd_timer = nil,
|
||||
suppress_ready_overlay_restore = false,
|
||||
session_binding_generation = 0,
|
||||
session_binding_names = {},
|
||||
session_numeric_binding_names = {},
|
||||
session_numeric_selection = nil,
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
local MODULE_PATHS = {
|
||||
"plugin/subminer/hover.lua",
|
||||
"plugin/subminer/environment.lua",
|
||||
}
|
||||
|
||||
local LEGACY_PARSER_CANDIDATES = {
|
||||
"luajit",
|
||||
"lua5.1",
|
||||
"lua51",
|
||||
}
|
||||
|
||||
local function assert_true(condition, message)
|
||||
if condition then
|
||||
return
|
||||
end
|
||||
error(message or "assert_true failed")
|
||||
end
|
||||
|
||||
local function read_file(path)
|
||||
local file = assert(io.open(path, "r"), "failed to open " .. path)
|
||||
local content = file:read("*a")
|
||||
file:close()
|
||||
return content
|
||||
end
|
||||
|
||||
local function find_legacy_incompatible_continue(source)
|
||||
local goto_start, goto_end = source:find("%f[%a]goto%s+continue%f[%A]")
|
||||
if goto_start then
|
||||
return "goto continue", goto_start, goto_end
|
||||
end
|
||||
|
||||
local label_start, label_end = source:find("::continue::", 1, true)
|
||||
if label_start then
|
||||
return "::continue::", label_start, label_end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function assert_no_legacy_incompatible_continue(path)
|
||||
local source = read_file(path)
|
||||
local match = find_legacy_incompatible_continue(source)
|
||||
assert_true(match == nil, path .. " still contains legacy-incompatible continue control flow: " .. tostring(match))
|
||||
end
|
||||
|
||||
local function assert_loadfile_ok(path)
|
||||
local chunk, err = loadfile(path)
|
||||
assert_true(chunk ~= nil, "loadfile failed for " .. path .. ": " .. tostring(err))
|
||||
end
|
||||
|
||||
local function normalize_execute_result(ok, why, code)
|
||||
if type(ok) == "number" then
|
||||
return ok == 0, ok
|
||||
end
|
||||
if type(ok) == "boolean" then
|
||||
if ok then
|
||||
return true, code or 0
|
||||
end
|
||||
return false, code or 1
|
||||
end
|
||||
return false, code or 1
|
||||
end
|
||||
|
||||
local function command_succeeds(command)
|
||||
local ok, why, code = os.execute(command)
|
||||
return normalize_execute_result(ok, why, code)
|
||||
end
|
||||
|
||||
local function command_exists(command)
|
||||
local shell = package.config:sub(1, 1) == "\\" and "where " or "command -v "
|
||||
local redirect = package.config:sub(1, 1) == "\\" and " >NUL 2>NUL" or " >/dev/null 2>&1"
|
||||
local escaped = command
|
||||
local success = command_succeeds(shell .. escaped .. redirect)
|
||||
return success
|
||||
end
|
||||
|
||||
local function find_legacy_parser()
|
||||
for _, command in ipairs(LEGACY_PARSER_CANDIDATES) do
|
||||
if command_exists(command) then
|
||||
return command
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function shell_redirect()
|
||||
if package.config:sub(1, 1) == "\\" then
|
||||
return " >NUL 2>NUL"
|
||||
end
|
||||
return " >/dev/null 2>&1"
|
||||
end
|
||||
|
||||
local function assert_parser_accepts_file(parser, path)
|
||||
local command = string.format("%s -e %q%s", parser, "assert(loadfile(" .. string.format("%q", path) .. "))", shell_redirect())
|
||||
local success = command_succeeds(command)
|
||||
assert_true(success, parser .. " failed to parse " .. path)
|
||||
end
|
||||
|
||||
local function assert_parser_rejects_legacy_fixture(parser)
|
||||
local legacy_fixture = [[
|
||||
local tokens = {}
|
||||
for _, token in ipairs(tokens or {}) do
|
||||
if type(token) ~= "table" then
|
||||
goto continue
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
]]
|
||||
local command = string.format("%s -e %q%s", parser, legacy_fixture, shell_redirect())
|
||||
local success = command_succeeds(command)
|
||||
assert_true(not success, parser .. " unexpectedly accepted legacy goto/label continue fixture")
|
||||
end
|
||||
|
||||
do
|
||||
local legacy_fixture = [[
|
||||
for _, token in ipairs(tokens or {}) do
|
||||
if type(token) ~= "table" then
|
||||
goto continue
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
]]
|
||||
local match = find_legacy_incompatible_continue(legacy_fixture)
|
||||
assert_true(match ~= nil, "legacy fixture should trigger incompatible continue detector")
|
||||
end
|
||||
|
||||
for _, path in ipairs(MODULE_PATHS) do
|
||||
assert_no_legacy_incompatible_continue(path)
|
||||
assert_loadfile_ok(path)
|
||||
end
|
||||
|
||||
local parser = find_legacy_parser()
|
||||
if parser then
|
||||
assert_parser_rejects_legacy_fixture(parser)
|
||||
for _, path in ipairs(MODULE_PATHS) do
|
||||
assert_parser_accepts_file(parser, path)
|
||||
end
|
||||
print("plugin lua compatibility regression tests: OK (" .. parser .. ")")
|
||||
else
|
||||
print("plugin lua compatibility regression tests: OK (legacy parser unavailable; structural checks only)")
|
||||
end
|
||||
@@ -231,9 +231,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(cycleRuntimeOption), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true);
|
||||
|
||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||
|
||||
const dictionary = parseArgs(['--dictionary']);
|
||||
assert.equal(dictionary.dictionary, true);
|
||||
assert.equal(hasExplicitCommand(dictionary), true);
|
||||
|
||||
@@ -686,7 +686,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.mineSentenceMultiple ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.toggleSecondarySub ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
|
||||
@@ -50,7 +50,6 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
assert.equal(config.discordPresence.enabled, true);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||
|
||||
@@ -91,7 +91,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
toggleSubtitleSidebar: '\\',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
|
||||
@@ -166,20 +166,14 @@ const TRENDS_DASHBOARD = {
|
||||
ratios: {
|
||||
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
||||
},
|
||||
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,
|
||||
},
|
||||
],
|
||||
animePerDay: {
|
||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
||||
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
|
||||
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
|
||||
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
||||
},
|
||||
animeCumulative: {
|
||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
||||
@@ -604,23 +598,7 @@ describe('stats server API routes', () => {
|
||||
const body = await res.json();
|
||||
assert.deepEqual(seenArgs, ['90d', 'month']);
|
||||
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
||||
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
||||
});
|
||||
|
||||
it('GET /api/stats/trends/dashboard accepts 365d range', async () => {
|
||||
let seenArgs: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getTrendsDashboard: async (...args: unknown[]) => {
|
||||
seenArgs = args;
|
||||
return TRENDS_DASHBOARD;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenArgs, ['365d', 'month']);
|
||||
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
|
||||
});
|
||||
|
||||
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
||||
|
||||
@@ -108,8 +108,8 @@ test('runAppReadyRuntime creates immersion tracker during heavy startup', async
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
|
||||
assert.ok(calls.includes('createImmersionTracker'));
|
||||
assert.ok(calls.indexOf('createImmersionTracker') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
||||
|
||||
@@ -278,7 +278,7 @@ export function handleCliCommand(
|
||||
osdLabel: string,
|
||||
): void => {
|
||||
runAsyncWithOsd(
|
||||
() => deps.dispatchSessionAction(request),
|
||||
() => deps.dispatchSessionAction?.(request) ?? Promise.resolve(),
|
||||
deps,
|
||||
logLabel,
|
||||
osdLabel,
|
||||
|
||||
@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async getTrendsDashboard(
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||
range: '7d' | '30d' | '90d' | 'all' = '30d',
|
||||
groupBy: 'day' | 'month' = 'day',
|
||||
): Promise<unknown> {
|
||||
return getTrendsDashboard(this.db, range, groupBy);
|
||||
|
||||
@@ -687,7 +687,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
|
||||
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
||||
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
|
||||
assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime');
|
||||
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
|
||||
assert.equal(
|
||||
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
|
||||
@@ -835,65 +835,6 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard supports 365d range and caps day buckets at 365', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', {
|
||||
canonicalTitle: '365d Trends',
|
||||
sourcePath: '/tmp/365d-trends.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: '365d Trends',
|
||||
canonicalTitle: '365d Trends',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: '365d-trends.mkv',
|
||||
parsedTitle: '365d Trends',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
// Seed 400 distinct rollup days so we can prove the 365d range caps at 365.
|
||||
const latestRollupDay = 20513;
|
||||
const createdAtMs = '1772395200000';
|
||||
for (let offset = 0; offset < 400; offset += 1) {
|
||||
const rollupDay = latestRollupDay - offset;
|
||||
insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||
}
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '365d', 'day');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 365);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -1938,50 +1879,6 @@ test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-wall-clock.mkv', {
|
||||
canonicalTitle: 'Events Wall Clock',
|
||||
sourcePath: '/tmp/events-wall-clock.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const startedAtMs = Date.now() - 10_000;
|
||||
const eventTsMs = startedAtMs + 5_000;
|
||||
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||
|
||||
stmts.eventInsertStmt.run(
|
||||
sessionId,
|
||||
toDbTimestamp(eventTsMs),
|
||||
EVENT_SUBTITLE_LINE,
|
||||
0,
|
||||
0,
|
||||
500,
|
||||
1,
|
||||
0,
|
||||
'{"line":"wall-clock"}',
|
||||
toDbTimestamp(eventTsMs),
|
||||
toDbTimestamp(eventTsMs),
|
||||
);
|
||||
|
||||
const events = getSessionEvents(db, sessionId, 10);
|
||||
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0]?.tsMs, eventTsMs);
|
||||
assert.equal(events[0]?.payload, '{"line":"wall-clock"}');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents returns empty array for session with no events', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -3769,224 +3666,3 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export function pruneRawRetention(
|
||||
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
||||
? (
|
||||
db
|
||||
.prepare(`DELETE FROM imm_session_events WHERE CAST(ts_ms AS REAL) < CAST(? AS REAL)`)
|
||||
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
|
||||
changes: number;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export function getSessionEvents(
|
||||
if (!eventTypes || eventTypes.length === 0) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
||||
FROM imm_session_events WHERE session_id = ? ORDER BY CAST(ts_ms AS REAL) ASC LIMIT ?
|
||||
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
|
||||
`);
|
||||
const rows = stmt.all(sessionId, limit) as Array<SessionEventRow & { tsMs: number | string }>;
|
||||
return rows.map((row) => ({
|
||||
@@ -147,7 +147,7 @@ export function getSessionEvents(
|
||||
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
||||
FROM imm_session_events
|
||||
WHERE session_id = ? AND event_type IN (${placeholders})
|
||||
ORDER BY CAST(ts_ms AS REAL) ASC
|
||||
ORDER BY ts_ms ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
|
||||
|
||||
@@ -602,7 +602,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
|
||||
FROM imm_session_events e
|
||||
JOIN imm_sessions s ON s.session_id = e.session_id
|
||||
WHERE s.video_id = ? AND e.event_type = 4
|
||||
ORDER BY CAST(e.ts_ms AS REAL) DESC
|
||||
ORDER BY e.ts_ms DESC
|
||||
`,
|
||||
)
|
||||
.all(videoId) as Array<{
|
||||
|
||||
@@ -345,11 +345,7 @@ export function fromDbTimestamp(ms: number | bigint | string | null | undefined)
|
||||
if (typeof ms === 'bigint') {
|
||||
return Number(ms);
|
||||
}
|
||||
const normalized = normalizeTimestampString(ms);
|
||||
if (/^-?\d+$/.test(normalized)) {
|
||||
return Number(BigInt(normalized));
|
||||
}
|
||||
return Math.trunc(Number.parseFloat(normalized));
|
||||
return Number(ms);
|
||||
}
|
||||
|
||||
function getNumericCalendarValue(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './query-shared';
|
||||
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
|
||||
|
||||
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
type TrendRange = '7d' | '30d' | '90d' | 'all';
|
||||
type TrendGroupBy = 'day' | 'month';
|
||||
|
||||
interface TrendChartPoint {
|
||||
@@ -27,19 +27,6 @@ interface TrendPerAnimePoint {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface LibrarySummaryRow {
|
||||
title: string;
|
||||
watchTimeMin: number;
|
||||
videos: number;
|
||||
sessions: number;
|
||||
cards: number;
|
||||
words: number;
|
||||
lookups: number;
|
||||
lookupsPerHundred: number | null;
|
||||
firstWatched: number;
|
||||
lastWatched: number;
|
||||
}
|
||||
|
||||
interface TrendSessionMetricRow {
|
||||
startedAtMs: number;
|
||||
epochDay: number;
|
||||
@@ -74,6 +61,14 @@ export interface TrendsDashboardQueryResult {
|
||||
ratios: {
|
||||
lookupsPerHundred: TrendChartPoint[];
|
||||
};
|
||||
animePerDay: {
|
||||
episodes: TrendPerAnimePoint[];
|
||||
watchTime: TrendPerAnimePoint[];
|
||||
cards: TrendPerAnimePoint[];
|
||||
words: TrendPerAnimePoint[];
|
||||
lookups: TrendPerAnimePoint[];
|
||||
lookupsPerHundred: TrendPerAnimePoint[];
|
||||
};
|
||||
animeCumulative: {
|
||||
watchTime: TrendPerAnimePoint[];
|
||||
episodes: TrendPerAnimePoint[];
|
||||
@@ -84,14 +79,12 @@ export interface TrendsDashboardQueryResult {
|
||||
watchTimeByDayOfWeek: TrendChartPoint[];
|
||||
watchTimeByHour: TrendChartPoint[];
|
||||
};
|
||||
librarySummary: LibrarySummaryRow[];
|
||||
}
|
||||
|
||||
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
'365d': 365,
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
@@ -307,6 +300,61 @@ function buildLookupsPerHundredWords(
|
||||
});
|
||||
}
|
||||
|
||||
function buildPerAnimeFromSessions(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
getValue: (session: TrendSessionMetricRow) => number,
|
||||
): TrendPerAnimePoint[] {
|
||||
const byAnime = new Map<string, Map<number, number>>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const animeTitle = resolveTrendAnimeTitle(session);
|
||||
const epochDay = session.epochDay;
|
||||
const dayMap = byAnime.get(animeTitle) ?? new Map();
|
||||
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
|
||||
byAnime.set(animeTitle, dayMap);
|
||||
}
|
||||
|
||||
const result: TrendPerAnimePoint[] = [];
|
||||
for (const [animeTitle, dayMap] of byAnime) {
|
||||
for (const [epochDay, value] of dayMap) {
|
||||
result.push({ epochDay, animeTitle, value });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): TrendPerAnimePoint[] {
|
||||
const lookups = new Map<string, Map<number, number>>();
|
||||
const words = new Map<string, Map<number, number>>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const animeTitle = resolveTrendAnimeTitle(session);
|
||||
const epochDay = session.epochDay;
|
||||
|
||||
const lookupMap = lookups.get(animeTitle) ?? new Map();
|
||||
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
|
||||
lookups.set(animeTitle, lookupMap);
|
||||
|
||||
const wordMap = words.get(animeTitle) ?? new Map();
|
||||
wordMap.set(epochDay, (wordMap.get(epochDay) ?? 0) + getTrendSessionWordCount(session));
|
||||
words.set(animeTitle, wordMap);
|
||||
}
|
||||
|
||||
const result: TrendPerAnimePoint[] = [];
|
||||
for (const [animeTitle, dayMap] of lookups) {
|
||||
const wordMap = words.get(animeTitle) ?? new Map();
|
||||
for (const [epochDay, lookupCount] of dayMap) {
|
||||
const wordCount = wordMap.get(epochDay) ?? 0;
|
||||
result.push({
|
||||
epochDay,
|
||||
animeTitle,
|
||||
value: wordCount > 0 ? +((lookupCount / wordCount) * 100).toFixed(1) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
||||
const byAnime = new Map<string, Map<number, number>>();
|
||||
const allDays = new Set<number>();
|
||||
@@ -342,89 +390,6 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildLibrarySummary(
|
||||
rollups: ImmersionSessionRollupRow[],
|
||||
sessions: TrendSessionMetricRow[],
|
||||
titlesByVideoId: Map<number, string>,
|
||||
): LibrarySummaryRow[] {
|
||||
type Accum = {
|
||||
watchTimeMin: number;
|
||||
videos: Set<number>;
|
||||
cards: number;
|
||||
words: number;
|
||||
firstWatched: number;
|
||||
lastWatched: number;
|
||||
sessions: number;
|
||||
lookups: number;
|
||||
};
|
||||
|
||||
const byTitle = new Map<string, Accum>();
|
||||
|
||||
const ensure = (title: string): Accum => {
|
||||
const existing = byTitle.get(title);
|
||||
if (existing) return existing;
|
||||
const created: Accum = {
|
||||
watchTimeMin: 0,
|
||||
videos: new Set<number>(),
|
||||
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;
|
||||
}
|
||||
|
||||
function getVideoAnimeTitleMap(
|
||||
db: DatabaseSync,
|
||||
videoIds: Array<number | null>,
|
||||
@@ -697,6 +662,8 @@ export function getTrendsDashboard(
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalTokensSeen,
|
||||
),
|
||||
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
|
||||
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -723,6 +690,7 @@ export function getTrendsDashboard(
|
||||
ratios: {
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||
},
|
||||
animePerDay,
|
||||
animeCumulative: {
|
||||
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
||||
episodes: buildCumulativePerAnime(animePerDay.episodes),
|
||||
@@ -733,6 +701,5 @@ export function getTrendsDashboard(
|
||||
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
||||
watchTimeByHour: buildWatchTimeByHour(sessions),
|
||||
},
|
||||
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,370 +263,6 @@ test('ensureSchema adds youtube metadata table to existing schema version 15 dat
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema migrates session event timestamps to text and repairs libsql-truncated wall-clock values', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE imm_schema_version (
|
||||
schema_version INTEGER PRIMARY KEY,
|
||||
applied_at_ms INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (16, 1000);
|
||||
|
||||
CREATE TABLE imm_rollup_state(
|
||||
state_key TEXT PRIMARY KEY,
|
||||
state_value INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0);
|
||||
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
normalized_title_key TEXT NOT NULL UNIQUE,
|
||||
canonical_title TEXT NOT NULL,
|
||||
anilist_id INTEGER UNIQUE,
|
||||
title_romaji TEXT,
|
||||
title_english TEXT,
|
||||
title_native TEXT,
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_key TEXT NOT NULL UNIQUE,
|
||||
anime_id INTEGER,
|
||||
canonical_title TEXT NOT NULL,
|
||||
source_type INTEGER NOT NULL,
|
||||
source_path TEXT,
|
||||
source_url TEXT,
|
||||
parsed_basename TEXT,
|
||||
parsed_title TEXT,
|
||||
parsed_season INTEGER,
|
||||
parsed_episode INTEGER,
|
||||
parser_source TEXT,
|
||||
parser_confidence REAL,
|
||||
parse_metadata_json TEXT,
|
||||
watched INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||
codec_id INTEGER, container_id INTEGER,
|
||||
width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
|
||||
bitrate_kbps INTEGER, audio_codec_id INTEGER,
|
||||
hash_sha256 TEXT, screenshot_path TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_uuid TEXT NOT NULL UNIQUE,
|
||||
video_id INTEGER NOT NULL,
|
||||
started_at_ms TEXT NOT NULL,
|
||||
ended_at_ms TEXT,
|
||||
status INTEGER NOT NULL,
|
||||
locale_id INTEGER,
|
||||
target_lang_id INTEGER,
|
||||
difficulty_tier INTEGER,
|
||||
subtitle_mode INTEGER,
|
||||
ended_media_ms INTEGER,
|
||||
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
cards_mined INTEGER NOT NULL DEFAULT 0,
|
||||
lookup_count INTEGER NOT NULL DEFAULT 0,
|
||||
lookup_hits INTEGER NOT NULL DEFAULT 0,
|
||||
yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,
|
||||
pause_count INTEGER NOT NULL DEFAULT 0,
|
||||
pause_ms INTEGER NOT NULL DEFAULT 0,
|
||||
seek_forward_count INTEGER NOT NULL DEFAULT 0,
|
||||
seek_backward_count INTEGER NOT NULL DEFAULT 0,
|
||||
media_buffer_events INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
|
||||
);
|
||||
|
||||
CREATE TABLE imm_session_telemetry(
|
||||
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
sample_ms TEXT NOT NULL,
|
||||
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
cards_mined INTEGER NOT NULL DEFAULT 0,
|
||||
lookup_count INTEGER NOT NULL DEFAULT 0,
|
||||
lookup_hits INTEGER NOT NULL DEFAULT 0,
|
||||
yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,
|
||||
pause_count INTEGER NOT NULL DEFAULT 0,
|
||||
pause_ms INTEGER NOT NULL DEFAULT 0,
|
||||
seek_forward_count INTEGER NOT NULL DEFAULT 0,
|
||||
seek_backward_count INTEGER NOT NULL DEFAULT 0,
|
||||
media_buffer_events INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_session_events(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
event_type INTEGER NOT NULL,
|
||||
line_index INTEGER,
|
||||
segment_start_ms INTEGER,
|
||||
segment_end_ms INTEGER,
|
||||
tokens_delta INTEGER NOT NULL DEFAULT 0,
|
||||
cards_delta INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_daily_rollups(
|
||||
rollup_day INTEGER NOT NULL,
|
||||
video_id INTEGER,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_min REAL NOT NULL DEFAULT 0,
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
cards_per_hour REAL,
|
||||
tokens_per_min REAL,
|
||||
lookup_hit_rate REAL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
PRIMARY KEY (rollup_day, video_id)
|
||||
);
|
||||
|
||||
CREATE TABLE imm_monthly_rollups(
|
||||
rollup_month INTEGER NOT NULL,
|
||||
video_id INTEGER,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_min REAL NOT NULL DEFAULT 0,
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
cards_per_hour REAL,
|
||||
tokens_per_min REAL,
|
||||
lookup_hit_rate REAL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
PRIMARY KEY (rollup_month, video_id)
|
||||
);
|
||||
|
||||
CREATE TABLE imm_words(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
headword TEXT NOT NULL,
|
||||
word TEXT NOT NULL,
|
||||
reading TEXT NOT NULL,
|
||||
part_of_speech TEXT,
|
||||
pos1 TEXT,
|
||||
pos2 TEXT,
|
||||
pos3 TEXT,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
frequency INTEGER NOT NULL DEFAULT 0,
|
||||
frequency_rank INTEGER,
|
||||
UNIQUE(headword, word, reading)
|
||||
);
|
||||
|
||||
CREATE TABLE imm_kanji(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kanji TEXT NOT NULL UNIQUE,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
frequency INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE imm_subtitle_lines(
|
||||
line_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
event_id INTEGER,
|
||||
video_id INTEGER NOT NULL,
|
||||
anime_id INTEGER,
|
||||
line_index INTEGER NOT NULL,
|
||||
segment_start_ms INTEGER,
|
||||
segment_end_ms INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
secondary_text TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE imm_word_line_occurrences(
|
||||
line_id INTEGER NOT NULL,
|
||||
word_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(line_id, word_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_kanji_line_occurrences(
|
||||
line_id INTEGER NOT NULL,
|
||||
kanji_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(line_id, kanji_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_lifetime_global(
|
||||
global_id INTEGER PRIMARY KEY CHECK(global_id = 1),
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
active_days INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_started INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_completed INTEGER NOT NULL DEFAULT 0,
|
||||
anime_completed INTEGER NOT NULL DEFAULT 0,
|
||||
last_rebuilt_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE imm_lifetime_anime(
|
||||
anime_id INTEGER PRIMARY KEY,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_started INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_completed INTEGER NOT NULL DEFAULT 0,
|
||||
first_watched_ms TEXT,
|
||||
last_watched_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_lifetime_media(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
first_watched_ms TEXT,
|
||||
last_watched_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_lifetime_applied_sessions(
|
||||
session_id INTEGER PRIMARY KEY,
|
||||
applied_at_ms TEXT NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_media_art(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
anilist_id INTEGER,
|
||||
cover_url TEXT,
|
||||
cover_blob BLOB,
|
||||
cover_blob_hash TEXT,
|
||||
fetched_at_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
cover_blob BLOB NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE imm_youtube_videos(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
youtube_video_id TEXT,
|
||||
video_url TEXT,
|
||||
video_title TEXT,
|
||||
video_thumbnail_url TEXT,
|
||||
channel_id TEXT,
|
||||
channel_name TEXT,
|
||||
channel_url TEXT,
|
||||
channel_thumbnail_url TEXT,
|
||||
uploader_id TEXT,
|
||||
uploader_url TEXT,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
fetched_at_ms TEXT NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO imm_videos (
|
||||
video_id, video_key, canonical_title, source_type, source_path, source_url, watched, duration_ms,
|
||||
CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'local:/tmp/repaired-event.mkv', 'Repaired Event', 1, '/tmp/repaired-event.mkv', NULL, 0, 0, '1000', '1000'
|
||||
);
|
||||
|
||||
INSERT INTO imm_sessions (
|
||||
session_id, session_uuid, video_id, started_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'session-1', 1, '1775940000000', 1, '1775940000000', '1775940000000'
|
||||
);
|
||||
|
||||
INSERT INTO imm_session_events (
|
||||
event_id, session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
|
||||
tokens_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 1, -2147483648, 4, NULL, NULL, NULL, 0, 1, '{\"noteIds\":[1]}', '1775943304128', '1775943304128'
|
||||
);
|
||||
`);
|
||||
|
||||
ensureSchema(db);
|
||||
|
||||
const column = db.prepare(`PRAGMA table_info(imm_session_events)`).all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
assert.equal(column.find((entry) => entry.name === 'ts_ms')?.type, 'TEXT');
|
||||
|
||||
const row = db.prepare(
|
||||
`
|
||||
SELECT ts_ms AS tsMs, typeof(ts_ms) AS tsType, CREATED_DATE AS createdDate
|
||||
FROM imm_session_events
|
||||
WHERE event_id = 1
|
||||
`,
|
||||
).get() as {
|
||||
tsMs: string;
|
||||
tsType: string;
|
||||
createdDate: string;
|
||||
};
|
||||
|
||||
assert.equal(row.tsType, 'text');
|
||||
assert.equal(row.tsMs, '1775943304128');
|
||||
assert.equal(row.createdDate, '1775943304128');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema creates large-history performance indexes', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -170,14 +170,6 @@ function hasColumn(db: DatabaseSync, tableName: string, columnName: string): boo
|
||||
.some((row: unknown) => (row as { name: string }).name === columnName);
|
||||
}
|
||||
|
||||
function getColumnType(db: DatabaseSync, tableName: string, columnName: string): string | null {
|
||||
const row = (db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>).find((entry) => entry.name === columnName);
|
||||
return row?.type ?? null;
|
||||
}
|
||||
|
||||
function addColumnIfMissing(
|
||||
db: DatabaseSync,
|
||||
tableName: string,
|
||||
@@ -195,92 +187,6 @@ function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: str
|
||||
}
|
||||
}
|
||||
|
||||
function migrateSessionEventTimestampsToText(db: DatabaseSync): void {
|
||||
if (getColumnType(db, 'imm_session_events', 'ts_ms') === 'TEXT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const lineIndexExpr = hasColumn(db, 'imm_session_events', 'line_index') ? 'line_index' : 'NULL';
|
||||
const segmentStartExpr = hasColumn(db, 'imm_session_events', 'segment_start_ms')
|
||||
? 'segment_start_ms'
|
||||
: 'NULL';
|
||||
const segmentEndExpr = hasColumn(db, 'imm_session_events', 'segment_end_ms')
|
||||
? 'segment_end_ms'
|
||||
: 'NULL';
|
||||
const tokensDeltaExpr = hasColumn(db, 'imm_session_events', 'tokens_delta')
|
||||
? 'tokens_delta'
|
||||
: '0';
|
||||
const cardsDeltaExpr = hasColumn(db, 'imm_session_events', 'cards_delta') ? 'cards_delta' : '0';
|
||||
const payloadExpr = hasColumn(db, 'imm_session_events', 'payload_json') ? 'payload_json' : 'NULL';
|
||||
const createdDateExpr = hasColumn(db, 'imm_session_events', 'CREATED_DATE')
|
||||
? 'CAST(CREATED_DATE AS TEXT)'
|
||||
: 'NULL';
|
||||
const lastUpdateExpr = hasColumn(db, 'imm_session_events', 'LAST_UPDATE_DATE')
|
||||
? 'CAST(LAST_UPDATE_DATE AS TEXT)'
|
||||
: 'NULL';
|
||||
const repairedTimestampExpr =
|
||||
hasColumn(db, 'imm_session_events', 'CREATED_DATE') ||
|
||||
hasColumn(db, 'imm_session_events', 'LAST_UPDATE_DATE')
|
||||
? `CASE
|
||||
WHEN ts_ms < 0 AND COALESCE(CREATED_DATE, LAST_UPDATE_DATE) IS NOT NULL
|
||||
THEN CAST(COALESCE(CREATED_DATE, LAST_UPDATE_DATE) AS TEXT)
|
||||
ELSE CAST(ts_ms AS TEXT)
|
||||
END`
|
||||
: 'CAST(ts_ms AS TEXT)';
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`
|
||||
CREATE TABLE imm_session_events_new(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
ts_ms TEXT NOT NULL,
|
||||
event_type INTEGER NOT NULL,
|
||||
line_index INTEGER,
|
||||
segment_start_ms INTEGER,
|
||||
segment_end_ms INTEGER,
|
||||
tokens_delta INTEGER NOT NULL DEFAULT 0,
|
||||
cards_delta INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO imm_session_events_new(
|
||||
event_id,
|
||||
session_id,
|
||||
ts_ms,
|
||||
event_type,
|
||||
line_index,
|
||||
segment_start_ms,
|
||||
segment_end_ms,
|
||||
tokens_delta,
|
||||
cards_delta,
|
||||
payload_json,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
event_id,
|
||||
session_id,
|
||||
${repairedTimestampExpr},
|
||||
event_type,
|
||||
${lineIndexExpr},
|
||||
${segmentStartExpr},
|
||||
${segmentEndExpr},
|
||||
${tokensDeltaExpr},
|
||||
${cardsDeltaExpr},
|
||||
${payloadExpr},
|
||||
${createdDateExpr},
|
||||
${lastUpdateExpr}
|
||||
FROM imm_session_events
|
||||
`);
|
||||
db.exec('DROP TABLE imm_session_events');
|
||||
db.exec('ALTER TABLE imm_session_events_new RENAME TO imm_session_events');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
export function applyPragmas(db: DatabaseSync): void {
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA synchronous = NORMAL');
|
||||
@@ -779,7 +685,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE TABLE IF NOT EXISTS imm_session_events(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
ts_ms TEXT NOT NULL,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
event_type INTEGER NOT NULL,
|
||||
line_index INTEGER,
|
||||
segment_start_ms INTEGER,
|
||||
@@ -1216,8 +1122,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
addColumnIfMissing(db, 'imm_sessions', 'ended_media_ms', 'INTEGER');
|
||||
}
|
||||
|
||||
migrateSessionEventTimestampsToText(db);
|
||||
|
||||
ensureLifetimeSummaryTables(db);
|
||||
|
||||
db.exec(`
|
||||
@@ -1516,8 +1420,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
) {
|
||||
throw new Error('Incomplete telemetry write');
|
||||
}
|
||||
const telemetrySampleMs =
|
||||
write.sampleMs === undefined ? currentMs : toDbTimestamp(write.sampleMs);
|
||||
const telemetrySampleMs = toDbTimestamp(write.sampleMs ?? Number(currentMs));
|
||||
stmts.telemetryInsertStmt.run(
|
||||
write.sessionId,
|
||||
telemetrySampleMs,
|
||||
@@ -1592,7 +1495,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
|
||||
stmts.eventInsertStmt.run(
|
||||
write.sessionId,
|
||||
write.sampleMs === undefined ? currentMs : toDbTimestamp(write.sampleMs),
|
||||
toDbTimestamp(write.sampleMs ?? Number(currentMs)),
|
||||
write.eventType ?? 0,
|
||||
write.lineIndex ?? null,
|
||||
write.segmentStartMs ?? null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SCHEMA_VERSION = 17;
|
||||
export const SCHEMA_VERSION = 16;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
|
||||
@@ -897,18 +897,6 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
|
||||
direction: -1,
|
||||
},
|
||||
});
|
||||
await dispatchHandler!({}, {
|
||||
actionId: 'toggleSubtitleSidebar',
|
||||
});
|
||||
await dispatchHandler!({}, {
|
||||
actionId: 'openSessionHelp',
|
||||
});
|
||||
await dispatchHandler!({}, {
|
||||
actionId: 'openControllerSelect',
|
||||
});
|
||||
await dispatchHandler!({}, {
|
||||
actionId: 'openControllerDebug',
|
||||
});
|
||||
|
||||
assert.deepEqual(dispatched, [
|
||||
{
|
||||
@@ -922,18 +910,6 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
|
||||
direction: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
actionId: 'toggleSubtitleSidebar',
|
||||
},
|
||||
{
|
||||
actionId: 'openSessionHelp',
|
||||
},
|
||||
{
|
||||
actionId: 'openControllerSelect',
|
||||
},
|
||||
{
|
||||
actionId: 'openControllerDebug',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||
|
||||
type WindowTrackerStub = {
|
||||
@@ -16,9 +15,7 @@ function createMainWindowRecorder() {
|
||||
let visible = false;
|
||||
let focused = false;
|
||||
let opacity = 1;
|
||||
let contentReady = true;
|
||||
const window = {
|
||||
webContents: {},
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => visible,
|
||||
isFocused: () => focused,
|
||||
@@ -53,24 +50,11 @@ function createMainWindowRecorder() {
|
||||
calls.push('move-top');
|
||||
},
|
||||
};
|
||||
(
|
||||
window as {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
}
|
||||
)[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
|
||||
|
||||
return {
|
||||
window,
|
||||
calls,
|
||||
getOpacity: () => opacity,
|
||||
setContentReady: (nextContentReady: boolean) => {
|
||||
contentReady = nextContentReady;
|
||||
(
|
||||
window as {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
}
|
||||
)[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
|
||||
},
|
||||
setFocused: (nextFocused: boolean) => {
|
||||
focused = nextFocused;
|
||||
},
|
||||
@@ -301,54 +285,6 @@ test('Windows visible overlay restores opacity after the deferred reveal delay',
|
||||
assert.ok(calls.includes('opacity:1'));
|
||||
});
|
||||
|
||||
test('Windows visible overlay waits for content-ready before first reveal', () => {
|
||||
const { window, calls, setContentReady } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
setContentReady(false);
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncWindowsOverlayToMpvZOrder: () => {
|
||||
calls.push('sync-windows-z-order');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
} as never);
|
||||
|
||||
run();
|
||||
|
||||
assert.ok(!calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
|
||||
setContentReady(true);
|
||||
run();
|
||||
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked Windows overlay refresh rebinds while already visible', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -34,10 +34,6 @@ function resolveCount(count: number | undefined): number {
|
||||
return Math.min(9, Math.max(1, normalized));
|
||||
}
|
||||
|
||||
function assertUnreachableSessionAction(actionId: never): never {
|
||||
throw new Error(`Unhandled session action: ${String(actionId)}`);
|
||||
}
|
||||
|
||||
export async function dispatchSessionAction(
|
||||
request: SessionActionDispatchRequest,
|
||||
deps: SessionActionExecutorDeps,
|
||||
@@ -125,7 +121,5 @@ export async function dispatchSessionAction(
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return assertUnreachableSessionAction(request.actionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,37 +226,7 @@ test('compileSessionBindings rejects malformed command arrays', () => {
|
||||
assert.equal(result.bindings[0]?.actionType, 'mpv-command');
|
||||
assert.deepEqual(result.bindings[0]?.command, ['show-text', 3000]);
|
||||
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
|
||||
'unsupported:keybindings[1].command',
|
||||
]);
|
||||
});
|
||||
|
||||
test('compileSessionBindings rejects non-string command heads and extra args on special commands', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [
|
||||
createKeybinding('Ctrl+J', [42] as never),
|
||||
createKeybinding('Ctrl+K', [SPECIAL_COMMANDS.JIMAKU_OPEN, 'extra'] as never),
|
||||
],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.bindings, []);
|
||||
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
|
||||
'unsupported:keybindings[0].command',
|
||||
'unsupported:keybindings[1].command',
|
||||
]);
|
||||
});
|
||||
|
||||
test('compileSessionBindings points unsupported command warnings at the command field', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [createKeybinding('Ctrl+K', [SPECIAL_COMMANDS.JIMAKU_OPEN, 'extra'] as never)],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.bindings, []);
|
||||
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
|
||||
'unsupported:keybindings[0].command',
|
||||
'unsupported:keybindings[1].key',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -268,49 +268,40 @@ function resolveCommandBinding(
|
||||
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') {
|
||||
return null;
|
||||
return {
|
||||
actionType: 'mpv-command',
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'triggerSubsync' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openRuntimeOptions' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openJimaku' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openYoutubePicker' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
if (command.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const parts = first.split(':');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
@@ -436,7 +427,7 @@ export function compileSessionBindings(
|
||||
if (!resolved) {
|
||||
warnings.push({
|
||||
kind: 'unsupported',
|
||||
path: `keybindings[${index}].command`,
|
||||
path: `keybindings[${index}].key`,
|
||||
value: binding.command,
|
||||
message: 'Unsupported keybinding command syntax.',
|
||||
});
|
||||
|
||||
@@ -311,6 +311,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
deps.createSubtitleTimingTracker();
|
||||
if (deps.createImmersionTracker) {
|
||||
deps.createImmersionTracker();
|
||||
deps.log('Runtime ready: immersion tracker startup requested.');
|
||||
} else {
|
||||
deps.log('Runtime ready: immersion tracker dependency is missing.');
|
||||
|
||||
@@ -30,10 +30,8 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
|
||||
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
||||
}
|
||||
|
||||
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
||||
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
||||
? raw
|
||||
: '30d';
|
||||
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' {
|
||||
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
|
||||
}
|
||||
|
||||
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||
|
||||
33
src/main.ts
33
src/main.ts
@@ -1544,8 +1544,8 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
setKeybindings: (keybindings) => {
|
||||
appState.keybindings = keybindings;
|
||||
},
|
||||
setSessionBindings: (sessionBindings, sessionBindingWarnings) => {
|
||||
persistSessionBindings(sessionBindings, sessionBindingWarnings);
|
||||
setSessionBindings: (sessionBindings) => {
|
||||
persistSessionBindings(sessionBindings);
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => {
|
||||
refreshGlobalAndOverlayShortcuts();
|
||||
@@ -1944,17 +1944,7 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetMpvSocketPath) {
|
||||
const windowTracker = appState.windowTracker as
|
||||
| {
|
||||
getTargetWindowHandle?: () => number | null;
|
||||
}
|
||||
| null;
|
||||
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
|
||||
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
|
||||
return trackedHandle;
|
||||
}
|
||||
}
|
||||
void targetMpvSocketPath;
|
||||
return findWindowsMpvTargetWindowHandle();
|
||||
} catch {
|
||||
return null;
|
||||
@@ -3631,7 +3621,7 @@ function ensureOverlayStartupPrereqs(): void {
|
||||
if (appState.keybindings.length === 0) {
|
||||
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||
refreshCurrentSessionBindings();
|
||||
} else if (!appState.sessionBindingsInitialized) {
|
||||
} else if (appState.sessionBindings.length === 0) {
|
||||
refreshCurrentSessionBindings();
|
||||
}
|
||||
if (!appState.mpvClient) {
|
||||
@@ -4254,14 +4244,15 @@ function persistSessionBindings(
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||
): void {
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
});
|
||||
writeSessionBindingsArtifact(CONFIG_DIR, artifact);
|
||||
appState.sessionBindings = bindings;
|
||||
appState.sessionBindingsInitialized = true;
|
||||
writeSessionBindingsArtifact(
|
||||
CONFIG_DIR,
|
||||
buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
}),
|
||||
);
|
||||
if (appState.mpvClient?.connected) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
|
||||
@@ -33,8 +33,6 @@ function createMockWindow(): MockWindow & {
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
emitDidFinishLoad: () => void;
|
||||
emitReadyToShow: () => void;
|
||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
||||
webContents: {
|
||||
focused: boolean;
|
||||
@@ -91,18 +89,6 @@ function createMockWindow(): MockWindow & {
|
||||
focus: () => {
|
||||
state.focused = true;
|
||||
},
|
||||
emitDidFinishLoad: () => {
|
||||
const callbacks = state.loadCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitReadyToShow: () => {
|
||||
const callbacks = state.readyToShowCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
once: (_event: 'ready-to-show', cb: () => void) => {
|
||||
state.readyToShowCallbacks.push(cb);
|
||||
},
|
||||
@@ -283,13 +269,16 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
assert.equal(window.loadCallbacks.length, 1);
|
||||
assert.equal(window.readyToShowCallbacks.length, 1);
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.emitDidFinishLoad();
|
||||
window.loadCallbacks[0]!();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
window.readyToShowCallbacks[0]!();
|
||||
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
@@ -560,7 +549,6 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
|
||||
|
||||
test('modal fallback reveal skips showing window when content is not ready', async () => {
|
||||
const window = createMockWindow();
|
||||
let scheduledReveal: (() => void) | null = null;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
@@ -569,14 +557,6 @@ test('modal fallback reveal skips showing window when content is not ready', asy
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
}, {
|
||||
scheduleRevealFallback: (callback) => {
|
||||
scheduledReveal = callback;
|
||||
return { scheduled: true } as never;
|
||||
},
|
||||
clearRevealFallback: () => {
|
||||
scheduledReveal = null;
|
||||
},
|
||||
});
|
||||
|
||||
window.loading = true;
|
||||
@@ -588,11 +568,10 @@ test('modal fallback reveal skips showing window when content is not ready', asy
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
if (scheduledReveal === null) {
|
||||
throw new Error('expected reveal callback');
|
||||
}
|
||||
const runScheduledReveal: () => void = scheduledReveal;
|
||||
runScheduledReveal();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 260);
|
||||
});
|
||||
|
||||
assert.equal(window.getShowCount(), 0);
|
||||
|
||||
@@ -620,49 +599,17 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.emitDidFinishLoad();
|
||||
assert.equal(window.loadCallbacks.length, 1);
|
||||
assert.equal(window.readyToShowCallbacks.length, 1);
|
||||
|
||||
window.loadCallbacks[0]!();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
window.readyToShowCallbacks[0]!();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen creates a fresh window after close destroys the previous one', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
@@ -670,7 +617,8 @@ test('modal reopen creates a fresh window after close destroys the previous one'
|
||||
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
getModalWindow: () =>
|
||||
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
@@ -705,7 +653,8 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
getModalWindow: () =>
|
||||
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
|
||||
@@ -50,15 +50,8 @@ export interface OverlayModalRuntime {
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
}
|
||||
|
||||
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||
|
||||
export interface OverlayModalRuntimeOptions {
|
||||
onModalStateChange?: (isActive: boolean) => void;
|
||||
scheduleRevealFallback?: (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => RevealFallbackHandle;
|
||||
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
|
||||
}
|
||||
|
||||
export function createOverlayModalRuntimeService(
|
||||
@@ -72,14 +65,7 @@ export function createOverlayModalRuntimeService(
|
||||
let mainWindowHiddenByModal = false;
|
||||
let modalWindowPrimedForImmediateShow = false;
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||
const scheduleRevealFallback = (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||
(options.clearRevealFallback ?? globalThis.clearTimeout)(timeout);
|
||||
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const notifyModalStateChange = (nextState: boolean): void => {
|
||||
if (modalActive === nextState) return;
|
||||
@@ -221,7 +207,7 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
clearRevealFallback(pendingModalWindowRevealTimeout);
|
||||
clearTimeout(pendingModalWindowRevealTimeout);
|
||||
pendingModalWindowRevealTimeout = null;
|
||||
pendingModalWindowReveal = null;
|
||||
};
|
||||
@@ -280,7 +266,7 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
pendingModalWindowRevealTimeout = scheduleRevealFallback(() => {
|
||||
pendingModalWindowRevealTimeout = setTimeout(() => {
|
||||
const targetWindow = pendingModalWindowReveal;
|
||||
clearPendingModalWindowReveal();
|
||||
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
|
||||
|
||||
@@ -11,14 +11,10 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: Array<{ enabled: boolean }> = [];
|
||||
const sessionBindingWarnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
setSessionBindings: (_sessionBindings, warnings) => {
|
||||
calls.push('set:session-bindings');
|
||||
sessionBindingWarnings.push(warnings.map((warning) => warning.message));
|
||||
},
|
||||
setSessionBindings: () => calls.push('set:session-bindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
@@ -48,12 +44,6 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
|
||||
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
|
||||
assert.equal(sessionBindingWarnings.length, 1);
|
||||
assert.ok(
|
||||
sessionBindingWarnings[0]?.some((message) =>
|
||||
message.includes('Rename shortcuts.toggleVisibleOverlayGlobal'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
|
||||
@@ -80,34 +70,6 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
|
||||
assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler forwards compiled session-binding warnings', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.shortcuts.openSessionHelp = 'Ctrl+?';
|
||||
const warnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => {},
|
||||
setSessionBindings: (_sessionBindings, sessionBindingWarnings) => {
|
||||
warnings.push(sessionBindingWarnings.map((warning) => warning.message));
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
applyAnkiRuntimeConfigPatch: () => {},
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['shortcuts'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.ok(warnings[0]?.some((message) => message.includes('Unsupported accelerator key token')));
|
||||
});
|
||||
|
||||
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
|
||||
const calls: string[] = [];
|
||||
const handleMessage = createConfigHotReloadMessageHandler({
|
||||
|
||||
@@ -7,10 +7,7 @@ import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '.
|
||||
|
||||
type ConfigHotReloadAppliedDeps = {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => void;
|
||||
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
@@ -40,10 +37,9 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
|
||||
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
|
||||
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
|
||||
const { bindings: sessionBindings, warnings: sessionBindingWarnings } = compileSessionBindings({
|
||||
const { bindings: sessionBindings } = compileSessionBindings({
|
||||
keybindings,
|
||||
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||
statsToggleKey: config.stats.toggleKey,
|
||||
platform:
|
||||
process.platform === 'darwin'
|
||||
? 'darwin'
|
||||
@@ -55,7 +51,6 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
||||
return {
|
||||
keybindings,
|
||||
sessionBindings,
|
||||
sessionBindingWarnings,
|
||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||
subtitleSidebar: config.subtitleSidebar,
|
||||
secondarySubMode: config.secondarySub.defaultMode,
|
||||
@@ -66,7 +61,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||
deps.setSessionBindings(payload.sessionBindings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
|
||||
@@ -86,13 +86,9 @@ test('config hot reload message main deps builder maps notifications', () => {
|
||||
|
||||
test('config hot reload applied main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const warningCounts: number[] = [];
|
||||
const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||
setKeybindings: () => calls.push('keybindings'),
|
||||
setSessionBindings: (_sessionBindings, warnings) => {
|
||||
calls.push('session-bindings');
|
||||
warningCounts.push(warnings.length);
|
||||
},
|
||||
setSessionBindings: () => calls.push('session-bindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set-secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
@@ -101,7 +97,7 @@ test('config hot reload applied main deps builder maps callbacks', () => {
|
||||
|
||||
const deps = buildDeps();
|
||||
deps.setKeybindings([]);
|
||||
deps.setSessionBindings([], []);
|
||||
deps.setSessionBindings([]);
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
deps.setSecondarySubMode('hover');
|
||||
deps.broadcastToOverlayWindows('config:hot-reload', {});
|
||||
@@ -114,7 +110,6 @@ test('config hot reload applied main deps builder maps callbacks', () => {
|
||||
'broadcast:config:hot-reload',
|
||||
'apply-anki',
|
||||
]);
|
||||
assert.deepEqual(warningCounts, [0]);
|
||||
});
|
||||
|
||||
test('config hot reload runtime main deps builder maps runtime callbacks', () => {
|
||||
|
||||
@@ -62,10 +62,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
|
||||
|
||||
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => void;
|
||||
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
@@ -76,10 +73,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
deps.setKeybindings(keybindings),
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings),
|
||||
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) =>
|
||||
deps.setSessionBindings(sessionBindings),
|
||||
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
||||
|
||||
@@ -104,30 +104,6 @@ test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit comm
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as explicit commands', () => {
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, toggleSubtitleSidebar: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openSessionHelp: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, openControllerSelect: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('setup service auto-completes legacy installs with config and dictionaries', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
|
||||
@@ -79,11 +79,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
@@ -97,14 +93,12 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.dictionary ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinLibraries ||
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinSubtitleUrlsOnly ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
|
||||
@@ -113,12 +113,3 @@ test('applyStartupState preserves cleared startup-only runtime flags', () => {
|
||||
|
||||
assert.equal(appState.initialArgs?.settings, true);
|
||||
});
|
||||
|
||||
test('createAppState starts with session bindings marked uninitialized', () => {
|
||||
const appState = createAppState({
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
texthookerPort: 4000,
|
||||
});
|
||||
|
||||
assert.equal(appState.sessionBindingsInitialized, false);
|
||||
});
|
||||
|
||||
@@ -172,7 +172,6 @@ export interface AppState {
|
||||
mecabTokenizer: MecabTokenizer | null;
|
||||
keybindings: Keybinding[];
|
||||
sessionBindings: CompiledSessionBinding[];
|
||||
sessionBindingsInitialized: boolean;
|
||||
subtitleTimingTracker: SubtitleTimingTracker | null;
|
||||
immersionTracker: ImmersionTrackerService | null;
|
||||
ankiIntegration: AnkiIntegration | null;
|
||||
@@ -256,7 +255,6 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
mecabTokenizer: null,
|
||||
keybindings: [],
|
||||
sessionBindings: [],
|
||||
sessionBindingsInitialized: false,
|
||||
subtitleTimingTracker: null,
|
||||
immersionTracker: null,
|
||||
ankiIntegration: null,
|
||||
|
||||
@@ -78,7 +78,6 @@ function installKeyboardTestGlobals() {
|
||||
let markActiveVideoWatchedResult = true;
|
||||
let markActiveVideoWatchedCalls = 0;
|
||||
let statsToggleOverlayCalls = 0;
|
||||
const openedModalNotifications: string[] = [];
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
|
||||
@@ -184,9 +183,6 @@ function installKeyboardTestGlobals() {
|
||||
focusMainWindowCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: string) => {
|
||||
openedModalNotifications.push(modal);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -316,7 +312,6 @@ function installKeyboardTestGlobals() {
|
||||
},
|
||||
markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls,
|
||||
statsToggleOverlayCalls: () => statsToggleOverlayCalls,
|
||||
openedModalNotifications,
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
@@ -331,8 +326,6 @@ function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
let controllerSelectKeydownCount = 0;
|
||||
let openControllerSelectCount = 0;
|
||||
let openControllerDebugCount = 0;
|
||||
let playlistBrowserKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
@@ -380,12 +373,6 @@ function createKeyboardHandlerHarness() {
|
||||
},
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
openControllerSelectModal: () => {
|
||||
openControllerSelectCount += 1;
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
openControllerDebugCount += 1;
|
||||
},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
});
|
||||
@@ -395,8 +382,6 @@ function createKeyboardHandlerHarness() {
|
||||
handlers,
|
||||
testGlobals,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
openControllerDebugCount: () => openControllerDebugCount,
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
@@ -404,88 +389,6 @@ function createKeyboardHandlerHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('session help chord resolver follows remapped session bindings', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyH',
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'KeyH',
|
||||
key: { code: 'KeyH', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openJimaku',
|
||||
},
|
||||
{
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyJ',
|
||||
key: { code: 'KeyJ', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
] as never);
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'KeyH',
|
||||
key: { code: 'KeyH', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSessionHelp',
|
||||
},
|
||||
{
|
||||
sourcePath: 'keybindings[1].key',
|
||||
originalKey: 'KeyK',
|
||||
key: { code: 'KeyK', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerSelect',
|
||||
},
|
||||
] as never);
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: true,
|
||||
});
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('numeric selection ignores non-digit keys instead of falling through to other shortcuts', async () => {
|
||||
const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.beginSessionNumericSelection('copySubtitleMultiple');
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
|
||||
|
||||
assert.equal(ctx.state.chordPending, false);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -728,44 +631,6 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus',
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.setConfiguredShortcuts({
|
||||
copySubtitle: '',
|
||||
copySubtitleMultiple: '',
|
||||
updateLastCardFromClipboard: '',
|
||||
triggerFieldGrouping: '',
|
||||
triggerSubsync: 'Ctrl+Alt+S',
|
||||
mineSentence: '',
|
||||
mineSentenceMultiple: '',
|
||||
multiCopyTimeoutMs: 3333,
|
||||
toggleSecondarySub: '',
|
||||
markAudioCard: '',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: '',
|
||||
toggleVisibleOverlayGlobal: '',
|
||||
});
|
||||
testGlobals.setStatsToggleKey('');
|
||||
testGlobals.setMarkWatchedKey('');
|
||||
|
||||
await handlers.refreshConfiguredShortcuts();
|
||||
|
||||
assert.equal(ctx.state.sessionActionTimeoutMs, 3333);
|
||||
assert.equal(ctx.state.statsToggleKey, '');
|
||||
assert.equal(ctx.state.markWatchedKey, '');
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -788,37 +653,8 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured controller select binding opens locally without dispatching a session action', async () => {
|
||||
const { testGlobals, handlers, openControllerSelectCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openControllerSelect',
|
||||
originalKey: 'Alt+D',
|
||||
key: { code: 'KeyD', modifiers: ['alt'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openControllerSelect',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'd',
|
||||
code: 'KeyD',
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
assert.equal(openControllerSelectCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured controller debug binding opens locally without dispatching a session action', async () => {
|
||||
const { testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
|
||||
test('keyboard mode: configured controller debug binding dispatches session action', async () => {
|
||||
const { testGlobals, handlers } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
@@ -839,16 +675,14 @@ test('keyboard mode: configured controller debug binding opens locally without d
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openControllerDebug', payload: undefined }]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
|
||||
const { ctx, testGlobals, handlers } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
@@ -871,9 +705,7 @@ test('keyboard mode: configured controller debug binding is not swallowed while
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openControllerDebug', payload: undefined }]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
@@ -990,29 +822,6 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshConfiguredShortcuts updates refreshed stats and mark-watched keys', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.setStatsToggleKey('KeyG');
|
||||
testGlobals.setMarkWatchedKey('KeyM');
|
||||
await handlers.refreshConfiguredShortcuts();
|
||||
|
||||
const beforeMarkWatchedCalls = testGlobals.markActiveVideoWatchedCalls();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM' });
|
||||
await wait(10);
|
||||
|
||||
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
|
||||
assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeMarkWatchedCalls + 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ export function createKeyboardHandlers(
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
}) => void;
|
||||
openControllerSelectModal?: () => void;
|
||||
openControllerDebugModal?: () => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
@@ -80,27 +78,12 @@ export function createKeyboardHandlers(
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
function updateConfiguredShortcuts(
|
||||
shortcuts: Required<ShortcutsConfig>,
|
||||
statsToggleKey?: string,
|
||||
markWatchedKey?: string,
|
||||
): void {
|
||||
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void {
|
||||
ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs;
|
||||
if (typeof statsToggleKey === 'string') {
|
||||
ctx.state.statsToggleKey = statsToggleKey;
|
||||
}
|
||||
if (typeof markWatchedKey === 'string') {
|
||||
ctx.state.markWatchedKey = markWatchedKey;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfiguredShortcuts(): Promise<void> {
|
||||
const [shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
window.electronAPI.getStatsToggleKey(),
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
]);
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts());
|
||||
}
|
||||
|
||||
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
||||
@@ -177,9 +160,8 @@ export function createKeyboardHandlers(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
e.preventDefault();
|
||||
return true;
|
||||
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
@@ -199,18 +181,6 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
options.openControllerSelectModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
options.openControllerDebugModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'mpv-command') {
|
||||
dispatchConfiguredMpvCommand(binding.command);
|
||||
return;
|
||||
@@ -929,7 +899,9 @@ export function createKeyboardHandlers(
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
]);
|
||||
updateSessionBindings(sessionBindings);
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
updateConfiguredShortcuts(shortcuts);
|
||||
ctx.state.statsToggleKey = statsToggleKey;
|
||||
ctx.state.markWatchedKey = markWatchedKey;
|
||||
syncKeyboardTokenSelection();
|
||||
|
||||
const subtitleMutationObserver = new MutationObserver(() => {
|
||||
@@ -1143,7 +1115,6 @@ export function createKeyboardHandlers(
|
||||
|
||||
return {
|
||||
beginSessionNumericSelection,
|
||||
getSessionHelpOpeningInfo: resolveSessionHelpChordBinding,
|
||||
setupMpvInputForwarding,
|
||||
refreshConfiguredShortcuts,
|
||||
updateSessionBindings,
|
||||
|
||||
@@ -96,10 +96,6 @@ const OVERLAY_SHORTCUTS: Array<{
|
||||
{ key: 'markAudioCard', label: 'Mark audio card' },
|
||||
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
|
||||
{ key: 'openJimaku', label: 'Open jimaku' },
|
||||
{ key: 'openSessionHelp', label: 'Open session help' },
|
||||
{ key: 'openControllerSelect', label: 'Open controller select' },
|
||||
{ key: 'openControllerDebug', label: 'Open controller debug' },
|
||||
{ key: 'toggleSubtitleSidebar', label: 'Toggle subtitle sidebar' },
|
||||
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
|
||||
];
|
||||
|
||||
@@ -108,12 +104,11 @@ function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): Session
|
||||
|
||||
for (const shortcut of OVERLAY_SHORTCUTS) {
|
||||
const keybind = shortcuts[shortcut.key];
|
||||
if (typeof keybind !== 'string') continue;
|
||||
if (keybind.trim().length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
shortcut:
|
||||
typeof keybind === 'string' && keybind.trim().length > 0
|
||||
? formatKeybinding(keybind)
|
||||
: 'Unbound',
|
||||
shortcut: formatKeybinding(keybind),
|
||||
action: shortcut.label,
|
||||
});
|
||||
}
|
||||
@@ -596,17 +591,13 @@ export function createSessionHelpModal(
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
ctx.state.sessionHelpModalOpen = true;
|
||||
helpSections = [];
|
||||
helpFilterValue = '';
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.sessionHelpModal.classList.remove('hidden');
|
||||
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
ctx.state.sessionHelpSelectedIndex = 0;
|
||||
ctx.dom.sessionHelpContent.innerHTML = '';
|
||||
ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results');
|
||||
helpFilterValue = '';
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
@@ -174,12 +174,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
},
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
},
|
||||
@@ -437,7 +431,11 @@ function registerModalOpenHandlers(): void {
|
||||
});
|
||||
window.electronAPI.onOpenSessionHelp(() => {
|
||||
runGuarded('session-help:open', () => {
|
||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
||||
sessionHelpModal.openSessionHelpModal({
|
||||
bindingKey: 'KeyH',
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
window.electronAPI.notifyOverlayModalOpened('session-help');
|
||||
});
|
||||
});
|
||||
@@ -510,8 +508,8 @@ function registerKeyboardCommandHandlers(): void {
|
||||
});
|
||||
|
||||
window.electronAPI.onSubtitleSidebarToggle(() => {
|
||||
runGuardedAsync('subtitle-sidebar:toggle', async () => {
|
||||
await subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||
runGuarded('subtitle-sidebar:toggle', () => {
|
||||
void subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,11 +28,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'toggleSubtitleSidebar',
|
||||
'openRuntimeOptions',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
CompiledSessionBinding,
|
||||
SessionActionId,
|
||||
SessionActionPayload,
|
||||
SessionBindingWarning,
|
||||
} from './session-bindings';
|
||||
import type {
|
||||
JimakuApiResponse,
|
||||
@@ -328,7 +327,6 @@ export interface ClipboardAppendResult {
|
||||
export interface ConfigHotReloadPayload {
|
||||
keybindings: Keybinding[];
|
||||
sessionBindings: CompiledSessionBinding[];
|
||||
sessionBindingWarnings: SessionBindingWarning[];
|
||||
subtitleStyle: SubtitleStyleConfig | null;
|
||||
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
||||
secondarySubMode: SecondarySubMode;
|
||||
|
||||
@@ -79,11 +79,11 @@ export abstract class BaseWindowTracker {
|
||||
this.updateTargetWindowFocused(focused);
|
||||
}
|
||||
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null, initialFocused = true): void {
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
||||
if (newGeometry) {
|
||||
if (!this.windowFound) {
|
||||
this.windowFound = true;
|
||||
this.updateTargetWindowFocused(initialFocused);
|
||||
this.updateTargetWindowFocused(true);
|
||||
if (this.onWindowFound) this.onWindowFound(newGeometry);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filterMpvPollResultBySocketPath, matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
function createPollResult(commandLines: Array<string | null>): MpvPollResult {
|
||||
return {
|
||||
matches: commandLines.map((commandLine, index) => ({
|
||||
hwnd: index + 1,
|
||||
bounds: { x: index * 10, y: 0, width: 1280, height: 720 },
|
||||
area: 1280 * 720,
|
||||
isForeground: index === 0,
|
||||
commandLine,
|
||||
})),
|
||||
focusState: true,
|
||||
windowState: 'visible',
|
||||
};
|
||||
}
|
||||
|
||||
test('matchesMpvSocketPathInCommandLine accepts equals and space-delimited socket flags', () => {
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
|
||||
'\\\\.\\pipe\\subminer-a',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server "\\\\.\\pipe\\subminer-b" video.mkv',
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('filterMpvPollResultBySocketPath keeps only matches for the requested socket path', () => {
|
||||
const result = filterMpvPollResultBySocketPath(
|
||||
createPollResult([
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video-a.mkv',
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-b video-b.mkv',
|
||||
null,
|
||||
]),
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
);
|
||||
|
||||
assert.deepEqual(result.matches.map((match) => match.hwnd), [2]);
|
||||
assert.equal(result.windowState, 'visible');
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function matchesMpvSocketPathInCommandLine(
|
||||
commandLine: string,
|
||||
targetSocketPath: string,
|
||||
): boolean {
|
||||
if (!commandLine || !targetSocketPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const escapedSocketPath = escapeRegex(targetSocketPath);
|
||||
return new RegExp(`--input-ipc-server(?:=|\\s+)("?${escapedSocketPath}"?)`, 'i').test(
|
||||
commandLine,
|
||||
);
|
||||
}
|
||||
|
||||
export function filterMpvPollResultBySocketPath(
|
||||
result: MpvPollResult,
|
||||
targetSocketPath?: string | null,
|
||||
): MpvPollResult {
|
||||
if (!targetSocketPath) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const matches = result.matches.filter(
|
||||
(match) =>
|
||||
typeof match.commandLine === 'string' &&
|
||||
matchesMpvSocketPathInCommandLine(match.commandLine, targetSocketPath),
|
||||
);
|
||||
|
||||
return {
|
||||
matches,
|
||||
focusState: matches.some((match) => match.isForeground),
|
||||
windowState: matches.length > 0 ? 'visible' : 'not-found',
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import koffi from 'koffi';
|
||||
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||
|
||||
const user32 = koffi.load('user32.dll');
|
||||
const dwmapi = koffi.load('dwmapi.dll');
|
||||
@@ -128,7 +126,6 @@ export interface MpvWindowMatch {
|
||||
bounds: WindowBounds;
|
||||
area: number;
|
||||
isForeground: boolean;
|
||||
commandLine?: string | null;
|
||||
}
|
||||
|
||||
export interface MpvPollResult {
|
||||
@@ -173,48 +170,12 @@ function getProcessNameByPid(pid: number): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
const processCommandLineCache = new Map<number, string | null>();
|
||||
|
||||
function getProcessCommandLineByPid(pid: number): string | null {
|
||||
if (processCommandLineCache.has(pid)) {
|
||||
return processCommandLineCache.get(pid) ?? null;
|
||||
}
|
||||
|
||||
let commandLine: string | null = null;
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1500,
|
||||
},
|
||||
).trim();
|
||||
commandLine = output.length > 0 ? output : null;
|
||||
} catch {
|
||||
commandLine = null;
|
||||
}
|
||||
|
||||
processCommandLineCache.set(pid, commandLine);
|
||||
return commandLine;
|
||||
}
|
||||
|
||||
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
|
||||
export function findMpvWindows(): MpvPollResult {
|
||||
const foregroundHwnd = GetForegroundWindow();
|
||||
const matches: MpvWindowMatch[] = [];
|
||||
let hasMinimized = false;
|
||||
let hasFocused = false;
|
||||
const processNameCache = new Map<number, string | null>();
|
||||
const processCommandLineLookupCache = new Map<number, string | null>();
|
||||
|
||||
const cb = koffi.register((hwnd: number, _lParam: number) => {
|
||||
if (!IsWindowVisible(hwnd)) return true;
|
||||
@@ -232,18 +193,6 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
|
||||
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
||||
|
||||
let commandLine: string | null = null;
|
||||
if (targetSocketPath) {
|
||||
commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
|
||||
if (!processCommandLineLookupCache.has(pidValue)) {
|
||||
commandLine = getProcessCommandLineByPid(pidValue);
|
||||
processCommandLineLookupCache.set(pidValue, commandLine);
|
||||
}
|
||||
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsIconic(hwnd)) {
|
||||
hasMinimized = true;
|
||||
return true;
|
||||
@@ -260,7 +209,6 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
bounds,
|
||||
area: bounds.width * bounds.height,
|
||||
isForeground,
|
||||
commandLine,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -342,18 +290,10 @@ export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void
|
||||
|
||||
let insertAfter = HWND_TOP;
|
||||
if (windowAboveMpv !== 0) {
|
||||
try {
|
||||
resetLastError();
|
||||
const aboveExStyle = assertGetWindowLongSucceeded(
|
||||
'bindOverlayAboveMpv window above style',
|
||||
GetWindowLongW(windowAboveMpv, GWL_EXSTYLE),
|
||||
);
|
||||
const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0;
|
||||
if (aboveIsTopmost === mpvIsTopmost) {
|
||||
insertAfter = windowAboveMpv;
|
||||
}
|
||||
} catch {
|
||||
insertAfter = HWND_TOP;
|
||||
const aboveExStyle = GetWindowLongW(windowAboveMpv, GWL_EXSTYLE);
|
||||
const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0;
|
||||
if (aboveIsTopmost === mpvIsTopmost) {
|
||||
insertAfter = windowAboveMpv;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,22 +70,6 @@ test('WindowsWindowTracker updates geometry from poll output', () => {
|
||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker preserves an unfocused initial match', () => {
|
||||
const tracker = new WindowsWindowTracker(undefined, {
|
||||
pollMpvWindows: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720, focused: false }),
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker clears geometry for poll misses', () => {
|
||||
const tracker = new WindowsWindowTracker(undefined, {
|
||||
pollMpvWindows: () => mpvNotFound,
|
||||
|
||||
@@ -32,8 +32,9 @@ type WindowsTrackerDeps = {
|
||||
};
|
||||
|
||||
function defaultPollMpvWindows(_targetMpvSocketPath?: string | null): MpvPollResult {
|
||||
void _targetMpvSocketPath;
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
return win32.findMpvWindows(_targetMpvSocketPath);
|
||||
return win32.findMpvWindows();
|
||||
}
|
||||
|
||||
export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
@@ -50,7 +51,6 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
private trackingLossStartedAtMs: number | null = null;
|
||||
private targetWindowMinimized = false;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private currentTargetWindowHwnd: number | null = null;
|
||||
|
||||
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
|
||||
super();
|
||||
@@ -81,10 +81,6 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
return this.targetWindowMinimized;
|
||||
}
|
||||
|
||||
getTargetWindowHandle(): number | null {
|
||||
return this.currentTargetWindowHwnd;
|
||||
}
|
||||
|
||||
private maybeLogPollError(error: Error): void {
|
||||
const now = Date.now();
|
||||
const fingerprint = error.message;
|
||||
@@ -126,7 +122,7 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
|
||||
private selectBestMatch(
|
||||
result: MpvPollResult,
|
||||
): { geometry: WindowGeometry; focused: boolean; hwnd: number } | null {
|
||||
): { geometry: WindowGeometry; focused: boolean } | null {
|
||||
if (result.matches.length === 0) return null;
|
||||
|
||||
const focusedMatch = result.matches.find((m) => m.isForeground);
|
||||
@@ -137,7 +133,6 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
return {
|
||||
geometry: best.bounds,
|
||||
focused: best.isForeground,
|
||||
hwnd: best.hwnd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,29 +147,25 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
if (best) {
|
||||
this.resetTrackingLossState();
|
||||
this.targetWindowMinimized = false;
|
||||
this.currentTargetWindowHwnd = best.hwnd;
|
||||
this.updateGeometry(best.geometry, best.focused);
|
||||
this.updateTargetWindowFocused(best.focused);
|
||||
this.updateGeometry(best.geometry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.windowState === 'minimized') {
|
||||
this.targetWindowMinimized = true;
|
||||
this.currentTargetWindowHwnd = null;
|
||||
this.updateTargetWindowFocused(false);
|
||||
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetWindowMinimized = false;
|
||||
this.currentTargetWindowHwnd = null;
|
||||
this.updateTargetWindowFocused(false);
|
||||
this.registerTrackingMiss();
|
||||
} catch (error: unknown) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.maybeLogPollError(err);
|
||||
this.targetWindowMinimized = false;
|
||||
this.currentTargetWindowHwnd = null;
|
||||
this.updateTargetWindowFocused(false);
|
||||
this.registerTrackingMiss();
|
||||
} finally {
|
||||
|
||||
@@ -93,7 +93,7 @@ export function AnimeTab({
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search library..."
|
||||
placeholder="Search anime..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
@@ -125,12 +125,12 @@ export function AnimeTab({
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} titles · {formatDuration(totalMs)}
|
||||
{filtered.length} anime · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
|
||||
) : (
|
||||
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
||||
{filtered.map((item) => (
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { filterCardEvents } from './EpisodeDetail';
|
||||
import type { EpisodeCardEvent } from '../../types/stats';
|
||||
|
||||
function makeEvent(over: Partial<EpisodeCardEvent> & { eventId: number }): EpisodeCardEvent {
|
||||
return {
|
||||
sessionId: 1,
|
||||
tsMs: 0,
|
||||
cardsDelta: 1,
|
||||
noteIds: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('filterCardEvents: before load, returns all events unchanged', () => {
|
||||
const ev1 = makeEvent({ eventId: 1, noteIds: [101] });
|
||||
const ev2 = makeEvent({ eventId: 2, noteIds: [102] });
|
||||
const noteInfos = new Map(); // empty — simulates pre-load state
|
||||
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ false);
|
||||
assert.equal(result.length, 2, 'should return both events before load');
|
||||
assert.deepEqual(result[0]?.noteIds, [101]);
|
||||
assert.deepEqual(result[1]?.noteIds, [102]);
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, drops noteIds not in noteInfos', () => {
|
||||
const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); // survives
|
||||
const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); // deleted from Anki
|
||||
const noteInfos = new Map([[101, { noteId: 101, expression: '食べる' }]]);
|
||||
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ true);
|
||||
assert.equal(result.length, 1, 'should drop event whose noteId was deleted from Anki');
|
||||
assert.equal(result[0]?.eventId, 1);
|
||||
assert.deepEqual(result[0]?.noteIds, [101]);
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, legacy rollup events (empty noteIds, positive cardsDelta) are kept', () => {
|
||||
const rollup = makeEvent({ eventId: 3, noteIds: [], cardsDelta: 5 });
|
||||
const noteInfos = new Map<number, { noteId: number; expression: string }>();
|
||||
const result = filterCardEvents([rollup], noteInfos, true);
|
||||
assert.equal(result.length, 1, 'legacy rollup event should survive filtering');
|
||||
assert.equal(result[0]?.cardsDelta, 5);
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, event with multiple noteIds keeps surviving ones', () => {
|
||||
const ev = makeEvent({ eventId: 4, noteIds: [201, 202, 203] });
|
||||
const noteInfos = new Map([
|
||||
[201, { noteId: 201, expression: 'A' }],
|
||||
[203, { noteId: 203, expression: 'C' }],
|
||||
]);
|
||||
const result = filterCardEvents([ev], noteInfos, true);
|
||||
assert.equal(result.length, 1, 'event with surviving noteIds should be kept');
|
||||
assert.deepEqual(result[0]?.noteIds, [201, 203], 'only surviving noteIds should remain');
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, event where all noteIds deleted is dropped', () => {
|
||||
const ev = makeEvent({ eventId: 5, noteIds: [301, 302] });
|
||||
const noteInfos = new Map<number, { noteId: number; expression: string }>();
|
||||
const result = filterCardEvents([ev], noteInfos, true);
|
||||
assert.equal(result.length, 0, 'event with all noteIds deleted should be dropped');
|
||||
});
|
||||
@@ -16,32 +16,10 @@ interface NoteInfo {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export function filterCardEvents(
|
||||
cardEvents: EpisodeDetailData['cardEvents'],
|
||||
noteInfos: Map<number, NoteInfo>,
|
||||
noteInfosLoaded: boolean,
|
||||
): EpisodeDetailData['cardEvents'] {
|
||||
if (!noteInfosLoaded) return cardEvents;
|
||||
return cardEvents
|
||||
.map((ev) => {
|
||||
// Legacy rollup events: no noteIds, just a cardsDelta count — keep as-is.
|
||||
if (ev.noteIds.length === 0) return ev;
|
||||
const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id));
|
||||
return { ...ev, noteIds: survivingNoteIds };
|
||||
})
|
||||
.filter((ev, i) => {
|
||||
// If the event originally had noteIds, only keep it if some survived.
|
||||
if ((cardEvents[i]?.noteIds.length ?? 0) > 0) return ev.noteIds.length > 0;
|
||||
// Legacy rollup event (originally no noteIds): keep if it has a positive delta.
|
||||
return ev.cardsDelta > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
|
||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -63,14 +41,8 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
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);
|
||||
});
|
||||
} else {
|
||||
if (!cancelled) setNoteInfosLoaded(true);
|
||||
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -100,16 +72,6 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
|
||||
const { sessions, cardEvents } = data;
|
||||
|
||||
const filteredCardEvents = filterCardEvents(cardEvents, noteInfos, noteInfosLoaded);
|
||||
|
||||
const hiddenCardCount = noteInfosLoaded
|
||||
? cardEvents.reduce((sum, ev) => {
|
||||
if (ev.noteIds.length === 0) return sum;
|
||||
const surviving = ev.noteIds.filter((id) => noteInfos.has(id));
|
||||
return sum + (ev.noteIds.length - surviving.length);
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||
{sessions.length > 0 && (
|
||||
@@ -144,11 +106,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredCardEvents.length > 0 && (
|
||||
{cardEvents.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||
<div className="space-y-1.5">
|
||||
{filteredCardEvents.map((ev) => (
|
||||
{cardEvents.map((ev) => (
|
||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||
{ev.noteIds.length > 0 ? (
|
||||
@@ -182,12 +144,6 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hiddenCardCount > 0 && (
|
||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
||||
{hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from
|
||||
Anki)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
120
stats/src/components/library/LibraryTab.tsx
Normal file
120
stats/src/components/library/LibraryTab.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import {
|
||||
groupMediaLibraryItems,
|
||||
summarizeMediaLibraryGroups,
|
||||
} from '../../lib/media-library-grouping';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
interface LibraryTabProps {
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
}
|
||||
|
||||
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
const { media, loading, error } = useMediaLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return media;
|
||||
const q = search.toLowerCase();
|
||||
return media.filter((m) => {
|
||||
const haystacks = [
|
||||
m.canonicalTitle,
|
||||
m.videoTitle,
|
||||
m.channelName,
|
||||
m.uploaderId,
|
||||
m.channelId,
|
||||
].filter(Boolean);
|
||||
return haystacks.some((value) => value!.toLowerCase().includes(q));
|
||||
});
|
||||
}, [media, search]);
|
||||
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
||||
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search titles..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
|
||||
{summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
|
||||
<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">
|
||||
{group.channelUrl ? (
|
||||
<a
|
||||
href={group.channelUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
|
||||
>
|
||||
{group.title}
|
||||
</a>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
<div 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createElement } from 'react';
|
||||
import { getRelatedCollectionLabel, buildDeleteEpisodeHandler } from './MediaDetailView';
|
||||
import { getRelatedCollectionLabel } from './MediaDetailView';
|
||||
|
||||
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
||||
assert.equal(
|
||||
@@ -43,85 +41,3 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
|
||||
'View Anime',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDeleteEpisodeHandler calls deleteVideo then onBack when confirm returns true', async () => {
|
||||
let deletedVideoId: number | null = null;
|
||||
let onBackCalled = false;
|
||||
|
||||
const fakeApiClient = {
|
||||
deleteVideo: async (id: number) => {
|
||||
deletedVideoId = id;
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConfirm = (_title: string) => true;
|
||||
|
||||
const handler = buildDeleteEpisodeHandler({
|
||||
videoId: 42,
|
||||
title: 'Test Episode',
|
||||
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
|
||||
confirmFn: fakeConfirm,
|
||||
onBack: () => {
|
||||
onBackCalled = true;
|
||||
},
|
||||
setDeleteError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.equal(deletedVideoId, 42);
|
||||
assert.equal(onBackCalled, true);
|
||||
});
|
||||
|
||||
test('buildDeleteEpisodeHandler does nothing when confirm returns false', async () => {
|
||||
let deletedVideoId: number | null = null;
|
||||
let onBackCalled = false;
|
||||
|
||||
const fakeApiClient = {
|
||||
deleteVideo: async (id: number) => {
|
||||
deletedVideoId = id;
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConfirm = (_title: string) => false;
|
||||
|
||||
const handler = buildDeleteEpisodeHandler({
|
||||
videoId: 42,
|
||||
title: 'Test Episode',
|
||||
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
|
||||
confirmFn: fakeConfirm,
|
||||
onBack: () => {
|
||||
onBackCalled = true;
|
||||
},
|
||||
setDeleteError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.equal(deletedVideoId, null);
|
||||
assert.equal(onBackCalled, false);
|
||||
});
|
||||
|
||||
test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () => {
|
||||
let capturedError: string | null = null;
|
||||
|
||||
const fakeApiClient = {
|
||||
deleteVideo: async (_id: number) => {
|
||||
throw new Error('Network failure');
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConfirm = (_title: string) => true;
|
||||
|
||||
const handler = buildDeleteEpisodeHandler({
|
||||
videoId: 42,
|
||||
title: 'Test Episode',
|
||||
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
|
||||
confirmFn: fakeConfirm,
|
||||
onBack: () => {},
|
||||
setDeleteError: (msg) => {
|
||||
capturedError = msg;
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.equal(capturedError, 'Network failure');
|
||||
});
|
||||
|
||||
@@ -1,48 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { MediaHeader } from './MediaHeader';
|
||||
import { MediaSessionList } from './MediaSessionList';
|
||||
import type { MediaDetailData, SessionSummary } from '../../types/stats';
|
||||
|
||||
interface DeleteEpisodeHandlerOptions {
|
||||
videoId: number;
|
||||
title: string;
|
||||
apiClient: { deleteVideo: (id: number) => Promise<void> };
|
||||
confirmFn: (title: string) => boolean;
|
||||
onBack: () => void;
|
||||
setDeleteError: (msg: string | null) => void;
|
||||
/**
|
||||
* Ref used to guard against reentrant delete calls synchronously. When set,
|
||||
* a subsequent invocation while the previous request is still pending is
|
||||
* ignored so clicks during the await window can't trigger duplicate deletes.
|
||||
*/
|
||||
isDeletingRef?: { current: boolean };
|
||||
/** Optional React state setter so the UI can reflect the pending state. */
|
||||
setIsDeleting?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||
return async () => {
|
||||
if (opts.isDeletingRef?.current) return;
|
||||
if (!opts.confirmFn(opts.title)) return;
|
||||
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
||||
opts.setIsDeleting?.(true);
|
||||
opts.setDeleteError(null);
|
||||
try {
|
||||
await opts.apiClient.deleteVideo(opts.videoId);
|
||||
opts.onBack();
|
||||
} catch (err) {
|
||||
opts.setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.');
|
||||
} finally {
|
||||
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
|
||||
opts.setIsDeleting?.(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
||||
if (detail?.channelName?.trim()) {
|
||||
return 'View Channel';
|
||||
@@ -71,8 +35,6 @@ export function MediaDetailView({
|
||||
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
|
||||
const isDeletingEpisodeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSessions(data?.sessions ?? null);
|
||||
@@ -117,17 +79,6 @@ export function MediaDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEpisode = buildDeleteEpisodeHandler({
|
||||
videoId,
|
||||
title: detail.canonicalTitle,
|
||||
apiClient,
|
||||
confirmFn: confirmEpisodeDelete,
|
||||
onBack,
|
||||
setDeleteError,
|
||||
isDeletingRef: isDeletingEpisodeRef,
|
||||
setIsDeleting: setIsDeletingEpisode,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -148,11 +99,7 @@ export function MediaDetailView({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<MediaHeader
|
||||
detail={detail}
|
||||
onDeleteEpisode={handleDeleteEpisode}
|
||||
isDeletingEpisode={isDeletingEpisode}
|
||||
/>
|
||||
<MediaHeader detail={detail} />
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
<MediaSessionList
|
||||
sessions={sessions}
|
||||
|
||||
@@ -12,16 +12,9 @@ interface MediaHeaderProps {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null;
|
||||
onDeleteEpisode?: () => void;
|
||||
isDeletingEpisode?: boolean;
|
||||
}
|
||||
|
||||
export function MediaHeader({
|
||||
detail,
|
||||
initialKnownWordsSummary = null,
|
||||
onDeleteEpisode,
|
||||
isDeletingEpisode = false,
|
||||
}: MediaHeaderProps) {
|
||||
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
|
||||
const knownTokenRate =
|
||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||
const avgSessionMs =
|
||||
@@ -57,21 +50,7 @@ export function MediaHeader({
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="min-w-0 flex-1 text-lg font-bold text-ctp-text truncate">
|
||||
{detail.canonicalTitle}
|
||||
</h2>
|
||||
{onDeleteEpisode != null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteEpisode}
|
||||
disabled={isDeletingEpisode}
|
||||
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeletingEpisode ? 'Deleting...' : 'Delete Episode'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
{detail.channelName ? (
|
||||
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
||||
{detail.channelUrl ? (
|
||||
|
||||
@@ -36,7 +36,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
||||
/>
|
||||
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
|
||||
<StatCard
|
||||
label="Active Titles"
|
||||
label="Active Anime"
|
||||
value={formatNumber(summary.activeAnimeCount)}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TrackingSnapshot({
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total unique videos watched across all titles in your library">
|
||||
<Tooltip text="Total unique episodes (videos) watched across all anime">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
@@ -79,9 +79,9 @@ export function TrackingSnapshot({
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of titles fully completed">
|
||||
<Tooltip text="Number of anime series fully completed">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Titles</div>
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface WatchTimeChartProps {
|
||||
@@ -52,23 +52,28 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||
<BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
|
||||
@@ -125,13 +125,14 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
||||
const hasKnownWords = knownWordsMap.size > 0;
|
||||
|
||||
const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||
buildSessionChartEvents(events);
|
||||
const lookupRate = buildLookupRateDisplay(
|
||||
session.yomitanLookupCount,
|
||||
getSessionDisplayWordCount(session),
|
||||
);
|
||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||
const seekCount = seekEvents.length;
|
||||
const cardEventCount = cardEvents.length;
|
||||
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
||||
const activeMarker = useMemo<SessionChartMarker | null>(
|
||||
@@ -229,6 +230,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
sorted={sorted}
|
||||
knownWordsMap={knownWordsMap}
|
||||
cardEvents={cardEvents}
|
||||
seekEvents={seekEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
markers={markers}
|
||||
@@ -240,6 +242,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={handleOpenNote}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
@@ -251,6 +254,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
<FallbackView
|
||||
sorted={sorted}
|
||||
cardEvents={cardEvents}
|
||||
seekEvents={seekEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
markers={markers}
|
||||
@@ -262,6 +266,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={handleOpenNote}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
@@ -275,6 +280,7 @@ function RatioView({
|
||||
sorted,
|
||||
knownWordsMap,
|
||||
cardEvents,
|
||||
seekEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
markers,
|
||||
@@ -286,6 +292,7 @@ function RatioView({
|
||||
loadingNoteIds,
|
||||
onOpenNote,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
@@ -293,6 +300,7 @@ function RatioView({
|
||||
sorted: TimelineEntry[];
|
||||
knownWordsMap: Map<number, number>;
|
||||
cardEvents: SessionEvent[];
|
||||
seekEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||
markers: SessionChartMarker[];
|
||||
@@ -304,6 +312,7 @@ function RatioView({
|
||||
loadingNoteIds: Set<number>;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
@@ -441,6 +450,22 @@ function RatioView({
|
||||
/>
|
||||
))}
|
||||
|
||||
{seekEvents.map((e, i) => {
|
||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`seek-${i}`}
|
||||
yAxisId="pct"
|
||||
x={e.tsMs}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.75}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Yomitan lookup markers */}
|
||||
{yomitanLookupEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
@@ -524,6 +549,7 @@ function RatioView({
|
||||
<StatsBar
|
||||
hasKnownWords
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
@@ -537,6 +563,7 @@ function RatioView({
|
||||
function FallbackView({
|
||||
sorted,
|
||||
cardEvents,
|
||||
seekEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
markers,
|
||||
@@ -548,12 +575,14 @@ function FallbackView({
|
||||
loadingNoteIds,
|
||||
onOpenNote,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
}: {
|
||||
sorted: TimelineEntry[];
|
||||
cardEvents: SessionEvent[];
|
||||
seekEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||
markers: SessionChartMarker[];
|
||||
@@ -565,6 +594,7 @@ function FallbackView({
|
||||
loadingNoteIds: Set<number>;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
@@ -650,6 +680,20 @@ function FallbackView({
|
||||
strokeOpacity={0.8}
|
||||
/>
|
||||
))}
|
||||
{seekEvents.map((e, i) => {
|
||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`seek-${i}`}
|
||||
x={e.tsMs}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.75}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{yomitanLookupEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`yomitan-${i}`}
|
||||
@@ -691,6 +735,7 @@ function FallbackView({
|
||||
<StatsBar
|
||||
hasKnownWords={false}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
@@ -704,12 +749,14 @@ function FallbackView({
|
||||
function StatsBar({
|
||||
hasKnownWords,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
session,
|
||||
lookupRate,
|
||||
}: {
|
||||
hasKnownWords: boolean;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
session: SessionSummary;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
@@ -744,7 +791,12 @@ function StatsBar({
|
||||
{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{pauseCount > 0 && <span className="text-ctp-surface2">|</span>}
|
||||
{seekCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
|
||||
|
||||
{/* Group 3: Learning events */}
|
||||
<span className="flex items-center gap-1.5">
|
||||
|
||||
@@ -33,6 +33,8 @@ function markerLabel(marker: SessionChartMarker): string {
|
||||
switch (marker.kind) {
|
||||
case 'pause':
|
||||
return '||';
|
||||
case 'seek':
|
||||
return marker.direction === 'backward' ? '<<' : '>>';
|
||||
case 'card':
|
||||
return '\u26CF';
|
||||
}
|
||||
@@ -42,6 +44,10 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
|
||||
switch (marker.kind) {
|
||||
case 'pause':
|
||||
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
||||
case 'seek':
|
||||
return marker.direction === 'backward'
|
||||
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
|
||||
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
|
||||
case 'card':
|
||||
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
||||
}
|
||||
|
||||
@@ -41,6 +41,35 @@ test('SessionEventPopover renders formatted card-mine details with fetched note
|
||||
assert.match(markup, /Open in Anki/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover renders seek metadata compactly', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'seek-3000',
|
||||
kind: 'seek',
|
||||
anchorTsMs: 3_000,
|
||||
eventTsMs: 3_000,
|
||||
direction: 'backward',
|
||||
fromMs: 5_000,
|
||||
toMs: 1_500,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading={false}
|
||||
pinned={false}
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Seek backward/);
|
||||
assert.match(markup, /5\.0s/);
|
||||
assert.match(markup, /1\.5s/);
|
||||
assert.match(markup, /3\.5s/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'card-9000',
|
||||
|
||||
@@ -31,12 +31,18 @@ export function SessionEventPopover({
|
||||
onClose,
|
||||
onOpenNote,
|
||||
}: SessionEventPopoverProps) {
|
||||
const seekDurationLabel =
|
||||
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
|
||||
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-ctp-text">
|
||||
{marker.kind === 'pause' && 'Paused'}
|
||||
{marker.kind === 'seek' && `Seek ${marker.direction}`}
|
||||
{marker.kind === 'card' && 'Card mined'}
|
||||
</div>
|
||||
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
||||
@@ -66,6 +72,7 @@ export function SessionEventPopover({
|
||||
) : null}
|
||||
<div className="text-sm">
|
||||
{marker.kind === 'pause' && '||'}
|
||||
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
|
||||
{marker.kind === 'card' && '\u26CF'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,6 +84,19 @@ export function SessionEventPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marker.kind === 'seek' && (
|
||||
<div className="space-y-1 text-xs text-ctp-subtext0">
|
||||
<div>
|
||||
From{' '}
|
||||
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
|
||||
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
|
||||
</div>
|
||||
<div>
|
||||
Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marker.kind === 'card' && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-ctp-cards-mined">
|
||||
|
||||
@@ -120,7 +120,7 @@ export function SessionRow({
|
||||
}}
|
||||
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
|
||||
title="View in Library"
|
||||
title="View anime overview"
|
||||
>
|
||||
{'\u2197'}
|
||||
</button>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SessionBucket } from '../../lib/session-grouping';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
import { buildBucketDeleteHandler } from './SessionsTab';
|
||||
|
||||
function makeSession(over: Partial<SessionSummary>): SessionSummary {
|
||||
return {
|
||||
sessionId: 1,
|
||||
videoId: 100,
|
||||
canonicalTitle: 'Episode 1',
|
||||
startedAtMs: 1_000_000,
|
||||
endedAtMs: 1_060_000,
|
||||
activeWatchedMs: 60_000,
|
||||
cardsMined: 1,
|
||||
linesSeen: 10,
|
||||
lookupCount: 5,
|
||||
lookupHits: 3,
|
||||
knownWordsSeen: 5,
|
||||
...over,
|
||||
} as SessionSummary;
|
||||
}
|
||||
|
||||
function makeBucket(sessions: SessionSummary[]): SessionBucket {
|
||||
const sorted = [...sessions].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
||||
return {
|
||||
key: `v-${sorted[0]!.videoId}`,
|
||||
videoId: sorted[0]!.videoId ?? null,
|
||||
sessions: sorted,
|
||||
totalActiveMs: sorted.reduce((s, x) => s + x.activeWatchedMs, 0),
|
||||
totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0),
|
||||
representativeSession: sorted[0]!,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildBucketDeleteHandler deletes every session in the bucket when confirm returns true', async () => {
|
||||
let deleted: number[] | null = null;
|
||||
let onSuccessCalledWith: number[] | null = null;
|
||||
let onErrorCalled = false;
|
||||
|
||||
const bucket = makeBucket([
|
||||
makeSession({ sessionId: 11, startedAtMs: 2_000_000 }),
|
||||
makeSession({ sessionId: 22, startedAtMs: 3_000_000 }),
|
||||
makeSession({ sessionId: 33, startedAtMs: 4_000_000 }),
|
||||
]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async (ids: number[]) => {
|
||||
deleted = ids;
|
||||
},
|
||||
},
|
||||
confirm: (title, count) => {
|
||||
assert.equal(title, 'Episode 1');
|
||||
assert.equal(count, 3);
|
||||
return true;
|
||||
},
|
||||
onSuccess: (ids) => {
|
||||
onSuccessCalledWith = ids;
|
||||
},
|
||||
onError: () => {
|
||||
onErrorCalled = true;
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.deepEqual(deleted, [33, 22, 11]);
|
||||
assert.deepEqual(onSuccessCalledWith, [33, 22, 11]);
|
||||
assert.equal(onErrorCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
||||
let deleteCalled = false;
|
||||
let successCalled = false;
|
||||
|
||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async () => {
|
||||
deleteCalled = true;
|
||||
},
|
||||
},
|
||||
confirm: () => false,
|
||||
onSuccess: () => {
|
||||
successCalled = true;
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(deleteCalled, false);
|
||||
assert.equal(successCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler reports errors via onError without calling onSuccess', async () => {
|
||||
let errorMessage: string | null = null;
|
||||
let successCalled = false;
|
||||
|
||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
confirm: () => true,
|
||||
onSuccess: () => {
|
||||
successCalled = true;
|
||||
},
|
||||
onError: (message) => {
|
||||
errorMessage = message;
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(errorMessage, 'boom');
|
||||
assert.equal(successCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler falls back to a generic title when canonicalTitle is null', async () => {
|
||||
let seenTitle: string | null = null;
|
||||
|
||||
const bucket = makeBucket([
|
||||
makeSession({ sessionId: 1, canonicalTitle: null }),
|
||||
makeSession({ sessionId: 2, canonicalTitle: null }),
|
||||
]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: { deleteSessions: async () => {} },
|
||||
confirm: (title) => {
|
||||
seenTitle = title;
|
||||
return false;
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(seenTitle, 'this episode');
|
||||
});
|
||||
@@ -3,9 +3,8 @@ import { useSessions } from '../../hooks/useSessions';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatSessionDayLabel } from '../../lib/formatters';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
@@ -24,35 +23,6 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface BucketDeleteDeps {
|
||||
bucket: SessionBucket;
|
||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
||||
confirm: (title: string, count: number) => boolean;
|
||||
onSuccess: (deletedIds: number[]) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a handler that deletes every session in a bucket after confirmation.
|
||||
*
|
||||
* Extracted as a pure factory so the deletion flow can be unit-tested without
|
||||
* rendering the full SessionsTab or mocking React state.
|
||||
*/
|
||||
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
||||
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
|
||||
return async () => {
|
||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||
if (!confirm(title, ids.length)) return;
|
||||
try {
|
||||
await client.deleteSessions(ids);
|
||||
onSuccess(ids);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionsTabProps {
|
||||
initialSessionId?: number | null;
|
||||
onClearInitialSession?: () => void;
|
||||
@@ -66,12 +36,10 @@ export function SessionsTab({
|
||||
}: SessionsTabProps = {}) {
|
||||
const { sessions, loading, error } = useSessions();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleSessions(sessions);
|
||||
@@ -108,16 +76,7 @@ export function SessionsTab({
|
||||
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
|
||||
}, [visibleSessions, search]);
|
||||
|
||||
const dayGroups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
||||
|
||||
const toggleBucket = (key: string) => {
|
||||
setExpandedBuckets((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
@@ -135,33 +94,6 @@ export function SessionsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBucket = async (bucket: SessionBucket) => {
|
||||
setDeleteError(null);
|
||||
setDeletingBucketKey(bucket.key);
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient,
|
||||
confirm: confirmBucketDelete,
|
||||
onSuccess: (ids) => {
|
||||
const deleted = new Set(ids);
|
||||
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
||||
setExpandedId((prev) => (prev != null && deleted.has(prev) ? null : prev));
|
||||
setExpandedBuckets((prev) => {
|
||||
if (!prev.has(bucket.key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(bucket.key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onError: (message) => setDeleteError(message),
|
||||
});
|
||||
try {
|
||||
await handler();
|
||||
} finally {
|
||||
setDeletingBucketKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
@@ -178,120 +110,39 @@ export function SessionsTab({
|
||||
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
|
||||
{Array.from(dayGroups.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 bucketBodyId = `session-bucket-${bucket.key}`;
|
||||
const isExpanded = expandedBuckets.has(bucket.key);
|
||||
const title = bucket.representativeSession.canonicalTitle ?? 'Unknown Media';
|
||||
const deleteDisabled = deletingBucketKey === bucket.key;
|
||||
return (
|
||||
<div key={bucket.key}>
|
||||
<div className="relative group flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleBucket(bucket.key)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={bucketBodyId}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`text-ctp-overlay2 text-xs shrink-0 transition-transform ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{title}</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{bucket.sessions.length} session
|
||||
{bucket.sessions.length === 1 ? '' : 's'} ·{' '}
|
||||
{formatDuration(bucket.totalActiveMs)} active ·{' '}
|
||||
{formatNumber(bucket.totalCardsMined)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteBucket(bucket)}
|
||||
disabled={deleteDisabled}
|
||||
aria-label={`Delete all ${bucket.sessions.length} sessions of ${title}`}
|
||||
title="Delete all sessions in this group"
|
||||
className="shrink-0 w-8 rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-overlay2 hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div id={bucketBodyId} className="mt-2 ml-6 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>
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
|
||||
<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">
|
||||
{daySessions.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>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-ctp-overlay2 text-sm">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function DateRangeSelector({
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<SegmentedControl
|
||||
label="Range"
|
||||
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
||||
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
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<string>;
|
||||
}
|
||||
|
||||
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') {
|
||||
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<SortColumn>('watchTimeMin');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('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 (
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleHeaderClick = (column: SortColumn) => {
|
||||
if (column === sortColumn) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === 'title' ? 'asc' : 'desc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
|
||||
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
|
||||
<BarChart
|
||||
data={leaderboard}
|
||||
layout="vertical"
|
||||
margin={{ top: 8, right: 16, bottom: 8, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayTitle"
|
||||
width={160}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||
formatter={(value: number) => [`${value} min`, 'Watch Time']}
|
||||
labelFormatter={(_label, payload) => {
|
||||
const datum = payload?.[0]?.payload as { title?: string } | undefined;
|
||||
return datum?.title ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="watchTimeMin" fill={LEADERBOARD_BAR_COLOR} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
|
||||
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-ctp-surface0">
|
||||
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
|
||||
{COLUMNS.map((column) => {
|
||||
const isActive = column.id === sortColumn;
|
||||
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
return (
|
||||
<th
|
||||
key={column.id}
|
||||
scope="col"
|
||||
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${isActive ? 'text-ctp-text' : ''}`}
|
||||
onClick={() => handleHeaderClick(column.id)}
|
||||
>
|
||||
{column.label}
|
||||
{indicator}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row) => (
|
||||
<tr
|
||||
key={row.title}
|
||||
className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40"
|
||||
>
|
||||
<td
|
||||
className="px-2 py-2 text-left text-ctp-text max-w-[240px] truncate"
|
||||
title={row.title}
|
||||
>
|
||||
{row.title}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatWatchTime(row.watchTimeMin)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.videos)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.sessions)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.cards)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.words)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.lookups)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums">
|
||||
{formatDateRange(row.firstWatched, row.lastWatched)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
export interface PerAnimeDataPoint {
|
||||
@@ -73,6 +64,14 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
||||
const { points, seriesKeys } = buildLineData(data);
|
||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
@@ -85,22 +84,21 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
||||
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}>
|
||||
<AreaChart data={points} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<AreaChart data={points}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
@@ -21,29 +19,35 @@ interface TrendChartProps {
|
||||
}
|
||||
|
||||
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
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}>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<BarChart data={data}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
tickFormatter={formatter}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={color}
|
||||
@@ -55,22 +59,20 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
|
||||
/>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
tickFormatter={formatter}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
)}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AnimeVisibilityFilter } from './TrendsTab';
|
||||
|
||||
test('AnimeVisibilityFilter uses title visibility wording', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={['KonoSuba']}
|
||||
hiddenAnime={new Set()}
|
||||
onShowAll={() => {}}
|
||||
onHideAll={() => {}}
|
||||
onToggleAnime={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Title Visibility/);
|
||||
assert.doesNotMatch(markup, /Anime Visibility/);
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
filterHiddenAnimeData,
|
||||
pruneHiddenAnime,
|
||||
} from './anime-visibility';
|
||||
import { LibrarySummarySection } from './LibrarySummarySection';
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -29,7 +28,7 @@ interface AnimeVisibilityFilterProps {
|
||||
onToggleAnime: (title: string) => void;
|
||||
}
|
||||
|
||||
export function AnimeVisibilityFilter({
|
||||
function AnimeVisibilityFilter({
|
||||
animeTitles,
|
||||
hiddenAnime,
|
||||
onShowAll,
|
||||
@@ -45,7 +44,7 @@ export function AnimeVisibilityFilter({
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
||||
Title Visibility
|
||||
Anime Visibility
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||
Shared across all anime trend charts. Default: show everything.
|
||||
@@ -115,6 +114,11 @@ export function TrendsTab() {
|
||||
if (!data) return null;
|
||||
|
||||
const animeTitles = buildAnimeVisibilityOptions([
|
||||
data.animePerDay.episodes,
|
||||
data.animePerDay.watchTime,
|
||||
data.animePerDay.cards,
|
||||
data.animePerDay.words,
|
||||
data.animePerDay.lookups,
|
||||
data.animeCumulative.episodes,
|
||||
data.animeCumulative.cards,
|
||||
data.animeCumulative.words,
|
||||
@@ -122,6 +126,24 @@ export function TrendsTab() {
|
||||
]);
|
||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||
|
||||
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,
|
||||
);
|
||||
const filteredAnimeProgress = filterHiddenAnimeData(
|
||||
data.animeCumulative.episodes,
|
||||
activeHiddenAnime,
|
||||
@@ -163,18 +185,6 @@ export function TrendsTab() {
|
||||
/>
|
||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={data.patterns.watchTimeByDayOfWeek}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={data.patterns.watchTimeByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
|
||||
<SectionHeader>Period Trends</SectionHeader>
|
||||
<TrendChart
|
||||
@@ -211,7 +221,7 @@ export function TrendsTab() {
|
||||
type="line"
|
||||
/>
|
||||
|
||||
<SectionHeader>Library — Cumulative</SectionHeader>
|
||||
<SectionHeader>Anime — Per Day</SectionHeader>
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={animeTitles}
|
||||
hiddenAnime={activeHiddenAnime}
|
||||
@@ -229,6 +239,21 @@ export function TrendsTab() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
||||
<StackedTrendChart
|
||||
title="Cards Mined per Anime"
|
||||
data={filteredCardsPerAnime}
|
||||
colorPalette={cardsMinedStackedColors}
|
||||
/>
|
||||
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
||||
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
||||
<StackedTrendChart
|
||||
title="Lookups/100w per Anime"
|
||||
data={filteredLookupsPerHundredPerAnime}
|
||||
/>
|
||||
|
||||
<SectionHeader>Anime — Cumulative</SectionHeader>
|
||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||
<StackedTrendChart
|
||||
@@ -238,8 +263,19 @@ export function TrendsTab() {
|
||||
/>
|
||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
||||
|
||||
<SectionHeader>Library — Summary</SectionHeader>
|
||||
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={data.patterns.watchTimeByDayOfWeek}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={data.patterns.watchTimeByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function CrossAnimeWordsTable({
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
Words Across Multiple Titles
|
||||
Words In Multiple Anime
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasKnownData && (
|
||||
@@ -97,8 +97,8 @@ export function CrossAnimeWordsTable({
|
||||
{collapsed ? null : ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||
{hideKnown
|
||||
? 'All words that span multiple titles are already known!'
|
||||
: 'No words found across multiple titles.'}
|
||||
? 'All multi-anime words are already known!'
|
||||
: 'No words found across multiple anime.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -109,7 +109,7 @@ export function CrossAnimeWordsTable({
|
||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||
<th className="text-right py-2 pr-3 font-medium w-16">Titles</th>
|
||||
<th className="text-right py-2 pr-3 font-medium w-16">Anime</th>
|
||||
<th className="text-right py-2 font-medium w-16">Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
|
||||
return {
|
||||
wordId: 1,
|
||||
headword: '日本語',
|
||||
word: '日本語',
|
||||
reading: 'にほんご',
|
||||
frequency: 5,
|
||||
frequencyRank: 100,
|
||||
animeCount: 1,
|
||||
partOfSpeech: null,
|
||||
firstSeen: 0,
|
||||
lastSeen: 0,
|
||||
...over,
|
||||
} as VocabularyEntry;
|
||||
}
|
||||
|
||||
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
|
||||
const entry = makeEntry({});
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
||||
);
|
||||
assert.ok(!markup.includes('>Reading<'), 'should not have a Reading column header');
|
||||
assert.ok(markup.includes('日本語'), 'should include the headword');
|
||||
assert.ok(markup.includes('にほんご'), 'should include the reading inline');
|
||||
});
|
||||
|
||||
test('omits reading when reading equals headword', () => {
|
||||
const entry = makeEntry({ headword: 'カレー', word: 'カレー', reading: 'カレー' });
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
||||
);
|
||||
assert.ok(markup.includes('カレー'), 'should include the headword');
|
||||
assert.ok(
|
||||
!markup.includes('【'),
|
||||
'should not render any bracketed reading when equal to headword',
|
||||
);
|
||||
});
|
||||
@@ -113,6 +113,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||
<th className="text-right py-2 font-medium w-20">Seen</th>
|
||||
</tr>
|
||||
@@ -127,19 +128,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
||||
#{w.frequencyRank!.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="text-ctp-text font-medium">{w.headword}</span>
|
||||
{(() => {
|
||||
const reading = fullReading(w.headword, w.reading);
|
||||
// `fullReading` normalizes katakana to hiragana, so we normalize the
|
||||
// headword the same way before comparing — otherwise katakana-only
|
||||
// entries like `カレー` would render `【かれー】`.
|
||||
const normalizedHeadword = fullReading(w.headword, w.headword);
|
||||
if (!reading || reading === normalizedHeadword) return null;
|
||||
return (
|
||||
<span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>
|
||||
);
|
||||
})()}
|
||||
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
||||
{fullReading(w.headword, w.reading) || w.headword}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
|
||||
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { MediaLibraryItem } from '../types/stats';
|
||||
import { shouldRefreshMediaLibraryRows } from './useMediaLibrary';
|
||||
|
||||
const baseItem: MediaLibraryItem = {
|
||||
videoId: 1,
|
||||
canonicalTitle: 'watch?v=abc123',
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 60_000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 10,
|
||||
lastWatchedMs: 1_000,
|
||||
hasCoverArt: 0,
|
||||
youtubeVideoId: 'abc123',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
videoTitle: null,
|
||||
videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||
channelId: null,
|
||||
channelName: null,
|
||||
channelUrl: null,
|
||||
channelThumbnailUrl: null,
|
||||
uploaderId: null,
|
||||
uploaderUrl: null,
|
||||
description: null,
|
||||
};
|
||||
|
||||
test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => {
|
||||
assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true);
|
||||
});
|
||||
|
||||
test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => {
|
||||
assert.equal(
|
||||
shouldRefreshMediaLibraryRows([
|
||||
{
|
||||
...baseItem,
|
||||
videoTitle: 'Video Name',
|
||||
channelName: 'Creator Name',
|
||||
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
|
||||
},
|
||||
]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
|
||||
assert.equal(
|
||||
shouldRefreshMediaLibraryRows([
|
||||
{
|
||||
...baseItem,
|
||||
youtubeVideoId: null,
|
||||
videoUrl: null,
|
||||
},
|
||||
]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
65
stats/src/hooks/useMediaLibrary.ts
Normal file
65
stats/src/hooks/useMediaLibrary.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { MediaLibraryItem } from '../types/stats';
|
||||
|
||||
const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500;
|
||||
const MEDIA_LIBRARY_MAX_RETRIES = 3;
|
||||
|
||||
export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean {
|
||||
return rows.some((row) => {
|
||||
if (!row.youtubeVideoId) {
|
||||
return false;
|
||||
}
|
||||
return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim();
|
||||
});
|
||||
}
|
||||
|
||||
export function useMediaLibrary() {
|
||||
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let retryCount = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | 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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { media, loading, error };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user