mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Fix stats command flow and tracking metrics regressions
- Route default `subminer stats` through attached `--stats`; keep daemon path for `--background`/`--stop` - Update overview metrics: lookup rate uses lifetime Yomitan lookups per 100 tokens; new words dedupe by headword - Suppress repeated macOS `Overlay loading...` OSD during fullscreen tracker flaps and improve session-detail chart scaling - Add/adjust launcher, tracker query, stats server, IPC, overlay, and stats UI regression tests; add changelog fragments
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: TASK-177.1
|
||||
title: Fix overview lookup rate metric
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-19 17:46'
|
||||
updated_date: '2026-03-19 17:54'
|
||||
labels:
|
||||
- stats
|
||||
- immersion-tracking
|
||||
- yomitan
|
||||
dependencies: []
|
||||
references:
|
||||
- stats/src/components/overview/OverviewTab.tsx
|
||||
- stats/src/lib/dashboard-data.ts
|
||||
- stats/src/lib/yomitan-lookup.ts
|
||||
- src/core/services/immersion-tracker/query.ts
|
||||
- src/core/services/stats-server.ts
|
||||
parent_task_id: TASK-177
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Update the stats homepage Tracking Snapshot so Lookup Rate reflects lifetime intentional Yomitan lookups normalized by total tokens seen, matching the newer stats semantics already used in session, media, and anime views.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Overview data exposes the lifetime totals needed to compute global Yomitan lookups per 100 tokens on the homepage
|
||||
- [x] #2 The homepage Tracking Snapshot Lookup Rate card shows Yomitan lookup rate as `X / 100 tokens` with tooltip/copy aligned to that meaning
|
||||
- [x] #3 Automated tests cover the lifetime totals plumbing and homepage summary/rendering change
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Extend overview lifetime hints/query plumbing to include total tokens seen and total intentional Yomitan lookups from finished sessions.
|
||||
2. Add/adjust focused tests first for query hints, stats overview API typing/mocks, and overview summary formatting so the homepage metric fails under old semantics.
|
||||
3. Update the overview summary/card to derive Lookup Rate from lifetime Yomitan lookups per 100 tokens and align tooltip/copy with that meaning.
|
||||
4. Run focused verification on the touched query, stats-server, and stats UI tests; record results and blockers in the task notes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Extended overview lifetime hints to include total tokens seen and total intentional Yomitan lookups from finished sessions so the homepage can compute a true global lookup rate.
|
||||
|
||||
Extracted the homepage Tracking Snapshot into a dedicated presentational component to keep OverviewTab smaller and make the Lookup Rate card copy directly testable.
|
||||
|
||||
Focused verification passed for query hints, IPC/stats overview plumbing, stats server overview response, dashboard summary logic, and homepage snapshot rendering.
|
||||
|
||||
SubMiner verifier core lane artifact: .tmp/skill-verification/subminer-verify-20260319-105320-7FDlwh. `bun run typecheck` passed there; `bun run test:fast` failed for a pre-existing/unrelated environment issue in scripts/update-aur-package.test.ts because scripts/update-aur-package.sh reported `mapfile: command not found`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Homepage Lookup Rate now uses lifetime intentional Yomitan lookups normalized by lifetime tokens seen, matching the existing session/media/anime semantics instead of the old known-word hit-rate metric. I extended overview query hints and API typings with total token and Yomitan lookup totals, updated the overview summary builder to reuse the shared per-100-token formatter, and replaced the inline Tracking Snapshot block with a dedicated component that renders `X / 100 tokens` plus Yomitan-specific tooltip copy.
|
||||
|
||||
Tests added/updated: query hints coverage for the new lifetime totals, stats server and IPC overview fixtures, overview summary assertions, and a dedicated Tracking Snapshot render test for the homepage card text. Focused `bun test` runs passed for those touched areas. Repo-native verifier `--lane core` also passed `bun run typecheck`; its `bun run test:fast` step still fails for the unrelated existing `scripts/update-aur-package.sh: line 71: mapfile: command not found` environment issue.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: TASK-177.2
|
||||
title: Count homepage new words by headword
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-19 19:38'
|
||||
updated_date: '2026-03-19 19:40'
|
||||
labels:
|
||||
- stats
|
||||
- immersion-tracking
|
||||
- vocabulary
|
||||
dependencies: []
|
||||
references:
|
||||
- src/core/services/immersion-tracker/query.ts
|
||||
- stats/src/components/overview/TrackingSnapshot.tsx
|
||||
- stats/src/lib/dashboard-data.ts
|
||||
parent_task_id: TASK-177
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Align the homepage New Words metric with the Known Words semantics by counting distinct headwords first seen in the selected window, so inflected or alternate forms of the same word do not inflate the summary.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Homepage new-word counts use distinct headwords by earliest first-seen timestamp instead of counting separate word-form rows
|
||||
- [x] #2 Homepage tooltip/copy reflects the headword-based semantics
|
||||
- [x] #3 Automated tests cover the headword de-duplication behavior and affected overview copy
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Change the new-word aggregate query to group `imm_words` by headword, compute each headword's earliest `first_seen`, and count headwords whose first sighting falls within today/week windows.
|
||||
2. Add failing tests first for the aggregate path so multiple rows sharing a headword only contribute once.
|
||||
3. Update homepage tooltip/copy to say unique headwords first seen today/week.
|
||||
4. Run focused query and stats overview tests, then record verification and any blockers.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Updated the new-word aggregate to count distinct headwords by each headword's earliest `first_seen` timestamp, so multiple inflected/form rows for the same headword contribute only once.
|
||||
|
||||
Adjusted homepage tooltip copy to say unique headwords first seen today/week, keeping the visible card labels unchanged.
|
||||
|
||||
Focused verification passed for the query aggregate and homepage snapshot tests.
|
||||
|
||||
SubMiner verifier core lane artifact: .tmp/skill-verification/subminer-verify-20260319-123942-4intgW. `bun run typecheck` passed there; `bun run test:fast` still fails for the unrelated environment issue in scripts/update-aur-package.test.ts (`mapfile: command not found`).
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Homepage New Words now uses headword-level semantics instead of counting separate `(headword, word, reading)` rows. The aggregate query groups `imm_words` by headword, uses each headword's earliest `first_seen`, and counts headwords first seen today or this week so alternate forms do not inflate the summary. The homepage tooltip copy now explicitly says the metric is based on unique headwords.
|
||||
|
||||
Added focused regression coverage for the de-duplication rule in `getQueryHints` and for the updated homepage tooltip text. Targeted `bun test` runs passed for the touched query and stats UI files. Repo verifier `--lane core` again passed `bun run typecheck`; `bun run test:fast` remains blocked by the unrelated existing `scripts/update-aur-package.sh: line 71: mapfile: command not found` failure.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: TASK-177.3
|
||||
title: Fix attached stats command flow and browser config
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-19 20:15'
|
||||
updated_date: '2026-03-19 20:17'
|
||||
labels:
|
||||
- launcher
|
||||
- stats
|
||||
- cli
|
||||
dependencies: []
|
||||
references:
|
||||
- launcher/commands/stats-command.ts
|
||||
- launcher/commands/command-modules.test.ts
|
||||
- launcher/main.test.ts
|
||||
- src/main/runtime/stats-cli-command.ts
|
||||
- src/main/runtime/stats-cli-command.test.ts
|
||||
parent_task_id: TASK-177
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make `subminer stats` stay attached to the foreground app process instead of routing through daemon startup, while keeping background/stop behavior on the daemon path. Ensure browser opening for stats respects only `stats.autoOpenBrowser` in the normal stats flow.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Default `subminer stats` forwards through the attached foreground stats command path instead of the daemon-start path
|
||||
- [x] #2 `subminer stats --background` and `subminer stats --stop` continue using the daemon control path
|
||||
- [x] #3 Normal stats launches do not open a browser when `stats.autoOpenBrowser` is false, and automated tests cover the launcher/runtime regressions
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing launcher tests first so default `stats` expects `--stats` forwarding while `--background` and `--stop` continue to expect daemon control flags.
|
||||
2. Add/adjust runtime stats command tests to prove `stats.autoOpenBrowser=false` suppresses browser opening on the normal attached stats path.
|
||||
3. Patch launcher forwarding logic in `launcher/commands/stats-command.ts` to choose foreground vs daemon flags correctly without changing cleanup handling.
|
||||
4. Run targeted launcher and stats runtime tests, then record verification results and blockers.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Confirmed root cause: launcher default `stats` flow always forwarded `--stats-daemon-start` plus `--stats-daemon-open-browser`, which detached the terminal process and bypassed `stats.autoOpenBrowser` because browser opening happened in daemon control instead of the normal stats CLI handler.
|
||||
|
||||
Updated launcher forwarding so plain `subminer stats` now uses the attached `--stats` path, while explicit `--background` and `--stop` continue using daemon control flags.
|
||||
|
||||
Added launcher regression coverage for the attached/default path and preserved background/stop expectations; added runtime coverage proving `stats.autoOpenBrowser=false` suppresses browser opening on the normal stats path.
|
||||
|
||||
Verifier passed for `launcher-plugin` and `runtime-compat` lanes. Artifact: .tmp/skill-verification/subminer-verify-20260319-131703-ZaAaUV.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed `subminer stats` so the default command now forwards to the normal attached `--stats` app path instead of the daemon-start path. That keeps the foreground process attached to the terminal as expected, while `subminer stats --background` and `subminer stats --stop` still use daemon control. Because the normal stats CLI path already respects `config.stats.autoOpenBrowser`, this also fixes the unwanted browser-open behavior that previously bypassed config via `--stats-daemon-open-browser`.
|
||||
|
||||
Added launcher command and launcher integration regressions for the new forwarding behavior, plus a runtime stats CLI regression that asserts `stats.autoOpenBrowser=false` suppresses browser opening. Verification passed with targeted launcher tests, targeted runtime stats tests, and the SubMiner verifier `launcher-plugin` + `runtime-compat` lanes.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: TASK-182.2
|
||||
title: Improve session detail known-word chart scaling
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-19 20:31'
|
||||
updated_date: '2026-03-19 20:52'
|
||||
labels:
|
||||
- bug
|
||||
- stats
|
||||
- ui
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/stats/src/components/sessions/SessionDetail.tsx
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/stats/src/lib/session-detail.test.tsx
|
||||
parent_task_id: TASK-182
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Adjust the expanded session-detail known-word percentage chart so the vertical range reflects the session's actual percent range instead of always spanning 0-100. Keep the chart easier to read while preserving the percent-based tooltip/legend behavior already used in the stats UI.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Expanded session detail scales the known/unknown percent chart to the session's observed percent range instead of hard-coding a 0-100 top bound
|
||||
- [x] #2 The chart keeps a small headroom above the highest observed known-word percent so the line remains visually readable near the top edge
|
||||
- [x] #3 Automated frontend coverage locks the new percent-domain behavior and preserves existing session-detail rendering
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add a focused frontend regression test for the session-detail ratio chart domain calculation, covering a session whose known-word percentage stays in a narrow band below 100% and expecting a dynamic top bound with headroom.
|
||||
2. Update `stats/src/components/sessions/SessionDetail.tsx` to compute a dynamic percent-axis domain and matching ticks for the ratio chart, keeping the lower bound at 0%, adding modest padding above the highest known percentage, rounding to clean tick steps, and capping at 100%.
|
||||
3. Apply the computed percent-axis bounds consistently to the right-side Y axis and the session chart pause overlays so the visual framing stays aligned.
|
||||
4. Run targeted frontend tests and the SubMiner verification helper on the touched files, then record results and any blockers in the task.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented dynamic known-percentage axis scaling in `stats/src/components/sessions/SessionDetail.tsx`: the ratio chart now keeps a 0% floor, uses the highest observed known percentage plus 5 points of headroom for the top bound, rounds that bound up to clean 10-point ticks, caps at 100%, and enables `allowDataOverflow` so the stacked area chart actually honors the tighter domain.
|
||||
|
||||
Added frontend regression coverage in `stats/src/lib/session-detail.test.tsx` for the axis-max helper, covering both a narrow-band session and near-100% cap behavior.
|
||||
|
||||
Added user-visible changelog fragment `changes/2026-03-19-session-detail-chart-scaling.md`.
|
||||
|
||||
Verification: `bun test stats/src/lib/session-detail.test.tsx` passed; `bun run typecheck` passed; `bun run changelog:lint` passed; `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core stats/src/components/sessions/SessionDetail.tsx stats/src/lib/session-detail.test.tsx` ran and passed `typecheck` but failed `bun run test:fast` on a pre-existing unrelated issue in `scripts/update-aur-package.test.ts` / `scripts/update-aur-package.sh` (`mapfile: command not found`). Artifacts: `.tmp/skill-verification/subminer-verify-20260319-134440-JRHAUJ`.
|
||||
|
||||
Docs decision: no internal docs update required; the behavior change is localized UI presentation with no API/workflow change. Changelog decision: yes, required and completed because the fix is user-visible.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Improved expanded session-detail chart readability by replacing the fixed 0-100 known-word percentage axis with a dynamic top bound based on the session’s highest observed known percentage plus modest headroom, rounded to clean ticks and capped at 100%. The ratio chart now also enables `allowDataOverflow` so Recharts preserves the tighter percent domain even though the stacked known/unknown areas sum to 100%.
|
||||
|
||||
Added frontend regression coverage for the new axis-max behavior and a changelog fragment for the user-visible stats fix.
|
||||
|
||||
Verification: `bun test stats/src/lib/session-detail.test.tsx`, `bun run typecheck`, and `bun run changelog:lint` passed. The SubMiner verification helper’s `core` lane also passed `typecheck`, but `bun run test:fast` remains red on a pre-existing unrelated bash-compat failure in `scripts/update-aur-package.test.ts` / `scripts/update-aur-package.sh` (`mapfile: command not found`).
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
id: TASK-200
|
||||
title: 'Address latest PR #19 CodeRabbit follow-ups'
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-19 07:18'
|
||||
updated_date: '2026-03-19 07:28'
|
||||
labels:
|
||||
- pr-review
|
||||
- anki-integration
|
||||
- launcher
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- launcher/mpv.test.ts
|
||||
- src/anki-integration.ts
|
||||
- src/anki-integration/card-creation.ts
|
||||
- src/anki-integration/runtime.ts
|
||||
- src/anki-integration/known-word-cache.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Validate the latest 2026-03-19 CodeRabbit review round on PR #19, implement only the confirmed fixes, and verify the touched launcher and Anki integration paths.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Each latest-round PR #19 CodeRabbit inline comment is validated against the current branch and classified as actionable or not warranted
|
||||
- [x] #2 Confirmed correctness issues in launcher and Anki integration code are fixed with focused regression coverage where practical
|
||||
- [x] #3 Targeted verification runs for the touched areas and the task notes record what changed versus what was rejected
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Validate the five inline comments from the 2026-03-19 CodeRabbit PR #19 review against current launcher and Anki integration code.
|
||||
2. Add or extend focused tests for any confirmed launcher env-sandbox, notification-state, AVIF lead-in propagation, or known-word-cache lifecycle/scope regressions.
|
||||
3. Apply the smallest safe fixes in `launcher/mpv.test.ts`, `src/anki-integration.ts`, `src/anki-integration/card-creation.ts`, `src/anki-integration/runtime.ts`, and `src/anki-integration/known-word-cache.ts` as needed.
|
||||
4. Run targeted unit tests plus the SubMiner verification helper on the touched files, then record which comments were accepted or rejected in task notes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Validated the five latest inline comments from CodeRabbit review `3973222927` on PR #19.
|
||||
|
||||
Accepted fixes:
|
||||
- Hardened the three `findAppBinary` launcher tests against host leakage by sandboxing `SUBMINER_APPIMAGE_PATH` / `SUBMINER_BINARY_PATH` and stubbing executable checks so `/opt` and PATH resolution are deterministic.
|
||||
- `showNotification()` now marks OSD/both updates as failed when `errorSuffix` is present instead of always rendering a success marker.
|
||||
- `applyRuntimeConfigPatch()` now avoids starting or stopping known-word cache lifecycle work while the runtime is stopped, while still clearing cached state when highlighting is disabled.
|
||||
- Extracted shared known-word cache lifecycle helpers and switched the persisted cache identity to the same lifecycle config used by runtime restart detection, so changes to `fields.word`, per-deck field mappings, or refresh interval invalidate stale cache state correctly.
|
||||
|
||||
Rejected fix:
|
||||
- The `createSentenceCard()` AVIF lead-in comment was technically incomplete for this branch. There is no current caller that computes an `animatedLeadInSeconds` input for sentence-card creation, and the existing lead-in resolver depends on note media fields that do not exist before the new card's media is generated.
|
||||
|
||||
Regression coverage added:
|
||||
- `src/anki-integration.test.ts` partial-failure OSD result marker.
|
||||
- `src/anki-integration/runtime.test.ts` stopped-runtime known-word lifecycle guards.
|
||||
- `src/anki-integration/known-word-cache.test.ts` cache invalidation when `fields.word` or per-deck field mappings change.
|
||||
|
||||
Verification:
|
||||
- `bun test src/anki-integration/runtime.test.ts`
|
||||
- `bun test src/anki-integration/known-word-cache.test.ts`
|
||||
- `bun test src/anki-integration.test.ts --test-name-pattern 'marks partial update notifications as failures in OSD mode'`
|
||||
- `bun test launcher/mpv.test.ts --test-name-pattern 'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists|findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist|findAppBinary finds subminer on PATH when AppImage candidates do not exist'`
|
||||
- `bun test src/anki-integration.test.ts src/anki-integration/runtime.test.ts src/anki-integration/known-word-cache.test.ts launcher/mpv.test.ts`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh launcher/mpv.test.ts src/anki-integration.ts src/anki-integration/runtime.ts src/anki-integration/known-word-cache.ts src/anki-integration/runtime.test.ts src/anki-integration/known-word-cache.test.ts src/anki-integration.test.ts`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane launcher-plugin --lane core launcher/mpv.test.ts src/anki-integration.ts src/anki-integration/runtime.ts src/anki-integration/known-word-cache.ts src/anki-integration/runtime.test.ts src/anki-integration/known-word-cache.test.ts src/anki-integration.test.ts`
|
||||
|
||||
Verifier result:
|
||||
- `launcher-plugin` lane passed (`test:launcher:smoke:src`, `test:plugin:src`).
|
||||
- `core/typecheck` passed.
|
||||
- `core/test-fast` failed for an unrelated existing environment issue in `scripts/update-aur-package.test.ts`: `scripts/update-aur-package.sh: line 71: mapfile: command not found` under the local macOS Bash environment.
|
||||
- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260319-002617-UgpKUy`
|
||||
|
||||
Classification: actionable and fixed -> `launcher/mpv.test.ts` env leakage hardening, `src/anki-integration.ts` partial-failure OSD marker, `src/anki-integration/runtime.ts` started-guard for known-word lifecycle calls, `src/anki-integration/known-word-cache.ts` cache identity alignment with runtime lifecycle config.
|
||||
|
||||
Classification: not warranted as written -> `src/anki-integration/card-creation.ts` lead-in threading comment. No current `createSentenceCard()` caller computes or owns an `animatedLeadInSeconds` value, and the existing lead-in helper derives from preexisting note media fields, so blindly adding an optional parameter would not fix a real branch behavior bug.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed four confirmed PR #19 latest-round CodeRabbit issues locally: deterministic launcher `findAppBinary` tests, correct partial-failure OSD result markers, started-state guards around known-word cache lifecycle restarts, and shared known-word cache identity logic so field-mapping changes invalidate stale cache state. Added focused regression coverage for each confirmed behavior.
|
||||
|
||||
One comment was intentionally not applied: the `createSentenceCard()` AVIF lead-in suggestion does not match the current branch architecture because no caller computes that value today and the existing resolver requires preexisting note media fields. Verification is green for all touched targeted tests plus the launcher-plugin/core typecheck lanes; the only remaining red is an unrelated existing `test:fast` failure in `scripts/update-aur-package.test.ts` caused by `mapfile` being unavailable in the local Bash environment.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: TASK-201
|
||||
title: Suppress repeated macOS overlay loading OSD during fullscreen tracker flaps
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-19 18:47'
|
||||
updated_date: '2026-03-19 19:01'
|
||||
labels:
|
||||
- bug
|
||||
- macos
|
||||
- overlay
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/main/overlay-visibility-runtime.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/main/state.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.test.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Reduce macOS fullscreen annoyance where the visible overlay briefly loses tracking and re-shows the `Overlay loading...` OSD even though the overlay runtime is already initialized and no new instance is launching. Keep the first startup/loading feedback, but suppress repeat loading notifications caused by subsequent tracker churn during fullscreen enter/leave or focus flaps.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The first macOS visible-overlay load still shows the existing `Overlay loading...` OSD when tracker data is not yet ready.
|
||||
- [x] #2 Repeated macOS tracker flaps after the overlay has already recovered do not immediately re-show `Overlay loading...` on every loss/recovery cycle.
|
||||
- [x] #3 Focused regression tests cover the repeated tracker-loss/recovery path and preserve the initial-load notification behavior.
|
||||
- [x] #4 The change does not alter overlay runtime bootstrap or single-instance behavior; only notification suppression behavior changes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add focused failing regressions in `src/core/services/overlay-visibility.test.ts` that preserve the first macOS `Overlay loading...` OSD and suppress an immediate second OSD after tracker recovery/loss churn.
|
||||
2. Extend the overlay-visibility state/runtime plumbing with a small macOS loading-OSD suppression state so tracker flap retries can be rate-limited without touching overlay bootstrap or single-instance logic.
|
||||
3. Reset the suppression when the user explicitly hides the visible overlay so intentional hide/show retries can still surface first-load feedback.
|
||||
4. Run focused verification for the touched overlay visibility/runtime tests and update the task with results.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added optional loading-OSD suppression hooks to `src/core/services/overlay-visibility.ts` so macOS can rate-limit repeated `Overlay loading...` notifications without changing overlay bootstrap behavior.
|
||||
|
||||
Implemented service-local suppression state in `src/main/overlay-visibility-runtime.ts` with a 30s cooldown and explicit reset when the visible overlay is manually hidden, so fullscreen tracker flaps stay quiet but intentional hide/show retries can still show loading feedback.
|
||||
|
||||
Added focused regressions in `src/core/services/overlay-visibility.test.ts` for `loss -> recover -> immediate loss` suppression and for manual hide resetting suppression.
|
||||
|
||||
Verification: `bun test src/core/services/overlay-visibility.test.ts`; `bun test src/main/runtime/overlay-visibility-runtime-main-deps.test.ts src/main/runtime/overlay-visibility-runtime.test.ts`; `bun run typecheck`; `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane runtime-compat src/core/services/overlay-visibility.ts src/main/overlay-visibility-runtime.ts src/core/services/overlay-visibility.test.ts` -> passed. Real-runtime lane skipped: change is notification suppression logic and cheap/runtime-compat coverage was sufficient for this scoped behavior change; no live mpv/macOS fullscreen session was run in this turn.
|
||||
|
||||
Docs update required: no. Changelog fragment required: yes; added `changes/2026-03-19-overlay-loading-osd-fullscreen-flaps.md`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Reduced repeated macOS `Overlay loading...` popups caused by fullscreen tracker flap churn without touching overlay bootstrap or single-instance behavior. `src/core/services/overlay-visibility.ts` now accepts optional suppression hooks around the loading OSD path, and `src/main/overlay-visibility-runtime.ts` uses service-local state to rate-limit that OSD for 30 seconds while resetting the suppression when the visible overlay is explicitly hidden. Added focused regressions in `src/core/services/overlay-visibility.test.ts` to preserve the first-load notification, suppress immediate repeat notifications after tracker recovery/loss churn, and keep manual hide/show retries able to surface the loading OSD again. Added changelog fragment `changes/2026-03-19-overlay-loading-osd-fullscreen-flaps.md`. Verification passed with targeted overlay tests, typecheck, and the `runtime-compat` verifier lane; live macOS/mpv fullscreen runtime validation was not run in this turn.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Reduced repeated `Overlay loading...` popups on macOS when fullscreen tracker flaps briefly hide and recover the visible overlay.
|
||||
4
changes/2026-03-19-session-detail-chart-scaling.md
Normal file
4
changes/2026-03-19-session-detail-chart-scaling.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Scaled expanded session-detail known-word charts to the session's actual percentage range so small changes no longer render as a nearly flat line.
|
||||
@@ -150,10 +150,9 @@ test('stats command launches attached app command with response path', async ()
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats-daemon-start',
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-daemon-open-browser',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
@@ -214,10 +213,9 @@ test('stats command returns after startup response even if app process stays run
|
||||
assert.equal(final, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats-daemon-start',
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-daemon-open-browser',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -69,14 +69,11 @@ export async function runStatsCommand(
|
||||
try {
|
||||
const forwarded = args.statsCleanup
|
||||
? ['--stats', '--stats-response-path', responsePath]
|
||||
: [
|
||||
args.statsStop ? '--stats-daemon-stop' : '--stats-daemon-start',
|
||||
'--stats-response-path',
|
||||
responsePath,
|
||||
];
|
||||
if (!args.statsCleanup && !args.statsBackground && !args.statsStop) {
|
||||
forwarded.push('--stats-daemon-open-browser');
|
||||
}
|
||||
: args.statsStop
|
||||
? ['--stats-daemon-stop', '--stats-response-path', responsePath]
|
||||
: args.statsBackground
|
||||
? ['--stats-daemon-start', '--stats-response-path', responsePath]
|
||||
: ['--stats', '--stats-response-path', responsePath];
|
||||
if (args.statsCleanup) {
|
||||
forwarded.push('--stats-cleanup');
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ exit 0
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
/^--stats-daemon-start\n--stats-response-path\n.+\n--stats-daemon-open-browser\n--log-level\ndebug\n$/,
|
||||
/^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -260,6 +260,12 @@ function createMockTracker(
|
||||
totalActiveMin: 120,
|
||||
totalCards: 0,
|
||||
activeDays: 7,
|
||||
totalTokensSeen: 80,
|
||||
totalLookupCount: 5,
|
||||
totalLookupHits: 4,
|
||||
totalYomitanLookupCount: 5,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async () => [],
|
||||
getSessionEvents: async () => [],
|
||||
@@ -337,6 +343,8 @@ describe('stats server API routes', () => {
|
||||
assert.equal(body.hints.totalAnimeCompleted, 0);
|
||||
assert.equal(body.hints.totalActiveMin, 120);
|
||||
assert.equal(body.hints.activeDays, 7);
|
||||
assert.equal(body.hints.totalTokensSeen, 80);
|
||||
assert.equal(body.hints.totalYomitanLookupCount, 5);
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions returns session list', async () => {
|
||||
@@ -347,6 +355,39 @@ describe('stats server API routes', () => {
|
||||
assert.ok(Array.isArray(body));
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions enriches each session with known-word metrics when cache exists', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const cachePath = path.join(dir, 'known-words.json');
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
words: ['する'],
|
||||
}),
|
||||
);
|
||||
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getSessionWordsByLine: async (sessionId: number) =>
|
||||
sessionId === 1
|
||||
? [
|
||||
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
|
||||
{ lineIndex: 2, headword: '未知', occurrenceCount: 1 },
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
{ knownWordCachePath: cachePath },
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/sessions?limit=5');
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
const first = body[0];
|
||||
assert.equal(first.knownWordsSeen, 2);
|
||||
assert.equal(first.knownWordRate, 2.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
|
||||
let seenSessionId = 0;
|
||||
let seenLimit = 0;
|
||||
|
||||
@@ -365,6 +365,12 @@ export class ImmersionTrackerService {
|
||||
totalActiveMin: number;
|
||||
totalCards: number;
|
||||
activeDays: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
}> {
|
||||
return getQueryHints(this.db);
|
||||
}
|
||||
|
||||
@@ -409,6 +409,28 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||
insert.run(10, 2, 1, 11, 0, 0, 3);
|
||||
insert.run(9, 1, 1, 10, 0, 0, 1);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/query-hints.mkv', {
|
||||
canonicalTitle: 'Query Hints Episode',
|
||||
sourcePath: '/tmp/query-hints.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
status = 2,
|
||||
tokens_seen = ?,
|
||||
yomitan_lookup_count = ?,
|
||||
lookup_count = ?,
|
||||
lookup_hits = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(1_060_000, 120, 8, 11, 7, 1_060_000, sessionId);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.totalSessions, 4);
|
||||
assert.equal(hints.totalCards, 2);
|
||||
@@ -416,6 +438,52 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||
assert.equal(hints.activeDays, 9);
|
||||
assert.equal(hints.totalEpisodesWatched, 11);
|
||||
assert.equal(hints.totalAnimeCompleted, 22);
|
||||
assert.equal(hints.totalTokensSeen, 120);
|
||||
assert.equal(hints.totalYomitanLookupCount, 8);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const now = new Date();
|
||||
const todayStartSec =
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
||||
const oneHourAgo = todayStartSec + 3_600;
|
||||
const twoDaysAgo = todayStartSec - 2 * 86_400;
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.newWordsToday, 1);
|
||||
assert.equal(hints.newWordsThisWeek, 2);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
|
||||
@@ -480,8 +480,10 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
totalActiveMin: number;
|
||||
totalCards: number;
|
||||
activeDays: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
} {
|
||||
@@ -556,18 +558,30 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(SUM(COALESCE(t.tokens_seen, s.tokens_seen, 0)), 0) AS totalTokensSeen,
|
||||
COALESCE(SUM(COALESCE(t.lookup_count, s.lookup_count, 0)), 0) AS totalLookupCount,
|
||||
COALESCE(SUM(COALESCE(t.lookup_hits, s.lookup_hits, 0)), 0) AS totalLookupHits
|
||||
COALESCE(SUM(COALESCE(t.lookup_hits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||
COALESCE(SUM(COALESCE(t.yomitan_lookup_count, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount
|
||||
FROM imm_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT session_id, MAX(lookup_count) AS lookup_count, MAX(lookup_hits) AS lookup_hits
|
||||
SELECT
|
||||
session_id,
|
||||
MAX(tokens_seen) AS tokens_seen,
|
||||
MAX(lookup_count) AS lookup_count,
|
||||
MAX(lookup_hits) AS lookup_hits,
|
||||
MAX(yomitan_lookup_count) AS yomitan_lookup_count
|
||||
FROM imm_session_telemetry
|
||||
GROUP BY session_id
|
||||
) t ON t.session_id = s.session_id
|
||||
WHERE s.ended_at_ms IS NOT NULL
|
||||
`,
|
||||
)
|
||||
.get() as { totalLookupCount: number; totalLookupHits: number } | null;
|
||||
.get() as {
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
} | null;
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
@@ -579,8 +593,10 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
totalActiveMin,
|
||||
totalCards,
|
||||
activeDays,
|
||||
totalTokensSeen: Number(lookupTotals?.totalTokensSeen ?? 0),
|
||||
totalLookupCount: Number(lookupTotals?.totalLookupCount ?? 0),
|
||||
totalLookupHits: Number(lookupTotals?.totalLookupHits ?? 0),
|
||||
totalYomitanLookupCount: Number(lookupTotals?.totalYomitanLookupCount ?? 0),
|
||||
...getNewWordCounts(db),
|
||||
};
|
||||
}
|
||||
@@ -593,11 +609,20 @@ function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsTh
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
WITH headword_first_seen AS (
|
||||
SELECT
|
||||
headword,
|
||||
MIN(first_seen) AS first_seen
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
AND headword IS NOT NULL
|
||||
AND headword != ''
|
||||
GROUP BY headword
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today,
|
||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
FROM headword_first_seen
|
||||
`,
|
||||
)
|
||||
.get(todayStartSec, weekAgoSec) as { today: number; week: number } | null;
|
||||
|
||||
@@ -234,6 +234,8 @@ export interface SessionSummaryQueryRow {
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
yomitanLookupCount: number;
|
||||
knownWordsSeen?: number;
|
||||
knownWordRate?: number;
|
||||
}
|
||||
|
||||
export interface LifetimeGlobalRow {
|
||||
|
||||
@@ -140,8 +140,10 @@ function createFakeImmersionTracker(
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
@@ -359,8 +361,10 @@ test('registerIpcHandlers returns empty stats overview shape without a tracker',
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
@@ -397,8 +401,10 @@ test('registerIpcHandlers validates and clamps stats request limits', async () =
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
@@ -472,6 +478,12 @@ test('registerIpcHandlers requests the full timeline when no limit is provided',
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async (sessionId: number, limit?: number) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
|
||||
@@ -85,6 +85,12 @@ export interface IpcServiceDeps {
|
||||
activeDays: number;
|
||||
totalEpisodesWatched: number;
|
||||
totalAnimeCompleted: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
}>;
|
||||
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
@@ -486,8 +492,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
|
||||
@@ -358,6 +358,59 @@ test('macOS keeps visible overlay hidden while tracker is not initialized yet',
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
|
||||
const { window } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
let trackerWarning = false;
|
||||
let lastLoadingOsdAtMs: number | null = null;
|
||||
let nowMs = 1_000;
|
||||
const hiddenTracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
};
|
||||
const trackedTracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
const run = (windowTracker: WindowTrackerStub) =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: windowTracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastLoadingOsdAtMs = nowMs;
|
||||
},
|
||||
} as never);
|
||||
|
||||
run(hiddenTracker);
|
||||
run(trackedTracker);
|
||||
|
||||
nowMs = 2_000;
|
||||
run(hiddenTracker);
|
||||
run(trackedTracker);
|
||||
|
||||
nowMs = 6_500;
|
||||
run(hiddenTracker);
|
||||
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
|
||||
});
|
||||
|
||||
test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly', () => {
|
||||
const calls: string[] = [];
|
||||
setVisibleOverlayVisible({
|
||||
@@ -373,10 +426,12 @@ test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly'
|
||||
assert.deepEqual(calls, ['state:true', 'update']);
|
||||
});
|
||||
|
||||
test('macOS loading OSD can show again after overlay is hidden and retried', () => {
|
||||
test('macOS explicit hide resets loading OSD suppression before retry', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
let trackerWarning = false;
|
||||
let lastLoadingOsdAtMs: number | null = null;
|
||||
let nowMs = 1_000;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
@@ -406,8 +461,17 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastLoadingOsdAtMs = nowMs;
|
||||
},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastLoadingOsdAtMs = null;
|
||||
},
|
||||
} as never);
|
||||
|
||||
nowMs = 1_500;
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: false,
|
||||
mainWindow: window as never,
|
||||
@@ -424,6 +488,9 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastLoadingOsdAtMs = null;
|
||||
},
|
||||
} as never);
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
@@ -454,6 +521,14 @@ test('macOS loading OSD can show again after overlay is hidden and retried', ()
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastLoadingOsdAtMs = nowMs;
|
||||
},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastLoadingOsdAtMs = null;
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
|
||||
|
||||
@@ -17,6 +17,9 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
isMacOSPlatform?: boolean;
|
||||
isWindowsPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
shouldShowOverlayLoadingOsd?: () => boolean;
|
||||
markOverlayLoadingOsdShown?: () => void;
|
||||
resetOverlayLoadingOsdSuppression?: () => void;
|
||||
resolveFallbackBounds?: () => WindowGeometry;
|
||||
}): void {
|
||||
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
|
||||
@@ -39,8 +42,20 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||
return;
|
||||
}
|
||||
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
|
||||
return;
|
||||
}
|
||||
args.showOverlayLoadingOsd('Overlay loading...');
|
||||
args.markOverlayLoadingOsdShown?.();
|
||||
};
|
||||
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.resetOverlayLoadingOsdSuppression?.();
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
@@ -63,9 +78,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
@@ -81,9 +94,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
|
||||
@@ -87,6 +87,62 @@ function countKnownWords(
|
||||
return { totalUniqueWords: headwords.length, knownWordCount };
|
||||
}
|
||||
|
||||
function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number {
|
||||
if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
async function enrichSessionsWithKnownWordMetrics(
|
||||
tracker: ImmersionTrackerService,
|
||||
sessions: Array<{
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
}>,
|
||||
knownWordsCachePath?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}>
|
||||
> {
|
||||
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
|
||||
if (!knownWordsSet) {
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const enriched = await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
let knownWordsSeen = 0;
|
||||
try {
|
||||
const wordsByLine = await tracker.getSessionWordsByLine(session.sessionId);
|
||||
for (const row of wordsByLine) {
|
||||
if (knownWordsSet.has(row.headword)) {
|
||||
knownWordsSeen += row.occurrenceCount;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
knownWordsSeen = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
knownWordsSeen,
|
||||
knownWordRate: toKnownWordRate(knownWordsSeen, session.tokensSeen),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export interface StatsServerConfig {
|
||||
port: number;
|
||||
staticDir: string; // Path to stats/dist/
|
||||
@@ -182,11 +238,16 @@ export function createStatsApp(
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/api/stats/overview', async (c) => {
|
||||
const [sessions, rollups, hints] = await Promise.all([
|
||||
const [rawSessions, rollups, hints] = await Promise.all([
|
||||
tracker.getSessionSummaries(5),
|
||||
tracker.getDailyRollups(14),
|
||||
tracker.getQueryHints(),
|
||||
]);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, rollups, hints });
|
||||
});
|
||||
|
||||
@@ -230,7 +291,12 @@ export function createStatsApp(
|
||||
|
||||
app.get('/api/stats/sessions', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const sessions = await tracker.getSessionSummaries(limit);
|
||||
const rawSessions = await tracker.getSessionSummaries(limit);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json(sessions);
|
||||
});
|
||||
|
||||
@@ -353,11 +419,16 @@ export function createStatsApp(
|
||||
app.get('/api/stats/media/:videoId', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.json(null, 400);
|
||||
const [detail, sessions, rollups] = await Promise.all([
|
||||
const [detail, rawSessions, rollups] = await Promise.all([
|
||||
tracker.getMediaDetail(videoId),
|
||||
tracker.getMediaSessions(videoId, 100),
|
||||
tracker.getMediaDailyRollups(videoId, 90),
|
||||
]);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ detail, sessions, rollups });
|
||||
});
|
||||
|
||||
@@ -529,9 +600,14 @@ export function createStatsApp(
|
||||
app.get('/api/stats/episode/:videoId/detail', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.body(null, 400);
|
||||
const sessions = await tracker.getEpisodeSessions(videoId);
|
||||
const rawSessions = await tracker.getEpisodeSessions(videoId);
|
||||
const words = await tracker.getEpisodeWords(videoId);
|
||||
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, words, cardEvents });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { BaseWindowTracker } from '../window-trackers';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { updateVisibleOverlayVisibility } from '../core/services';
|
||||
|
||||
const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
|
||||
|
||||
export interface OverlayVisibilityRuntimeDeps {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
@@ -29,6 +31,8 @@ export interface OverlayVisibilityRuntimeService {
|
||||
export function createOverlayVisibilityRuntimeService(
|
||||
deps: OverlayVisibilityRuntimeDeps,
|
||||
): OverlayVisibilityRuntimeService {
|
||||
let lastOverlayLoadingOsdAtMs: number | null = null;
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
updateVisibleOverlayVisibility({
|
||||
@@ -50,6 +54,15 @@ export function createOverlayVisibilityRuntimeService(
|
||||
isMacOSPlatform: deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastOverlayLoadingOsdAtMs === null ||
|
||||
Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS,
|
||||
markOverlayLoadingOsdShown: () => {
|
||||
lastOverlayLoadingOsdAtMs = Date.now();
|
||||
},
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastOverlayLoadingOsdAtMs = null;
|
||||
},
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -75,6 +75,29 @@ test('stats cli command starts tracker, server, browser, and writes success resp
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command respects stats.autoOpenBrowser=false', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 6969, autoOpenBrowser: false },
|
||||
}),
|
||||
});
|
||||
|
||||
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'ensureStatsServerStarted',
|
||||
'info:Stats dashboard available at http://127.0.0.1:6969',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true, url: 'http://127.0.0.1:6969' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command starts background daemon without opening browser', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
|
||||
@@ -88,6 +88,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
<span className="text-ctp-peach">
|
||||
{formatNumber(getSessionDisplayWordCount(s))} tokens
|
||||
</span>
|
||||
<span className="text-ctp-green">{formatNumber(s.knownWordsSeen)} known words</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useStreakCalendar } from '../../hooks/useStreakCalendar';
|
||||
import { HeroStats } from './HeroStats';
|
||||
import { StreakCalendar } from './StreakCalendar';
|
||||
import { RecentSessions } from './RecentSessions';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { Tooltip } from '../layout/Tooltip';
|
||||
import {
|
||||
confirmSessionDelete,
|
||||
confirmDayGroupDelete,
|
||||
@@ -122,10 +121,6 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
const summary = buildOverviewSummary(data);
|
||||
const streakData = buildStreakCalendar(calendar);
|
||||
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
|
||||
const knownWordPercent =
|
||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -141,133 +136,11 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
{!calLoading && <StreakCalendar data={streakData} />}
|
||||
</div>
|
||||
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
|
||||
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
|
||||
Lifetime totals sourced from summary tables.
|
||||
</p>
|
||||
{showTrackedCardNote && (
|
||||
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
|
||||
No lifetime card totals in the summary table yet. New cards mined after this fix will
|
||||
appear here.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<Tooltip text="Total immersion sessions recorded across all time">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total active watch time across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
|
||||
{summary.allTimeMinutes < 60
|
||||
? `${summary.allTimeMinutes}m`
|
||||
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of distinct days with at least one session">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Average active watch time per session in minutes">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
|
||||
{formatNumber(summary.averageSessionMinutes)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<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">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<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">Anime</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
|
||||
{formatNumber(summary.totalTrackedCards)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Percentage of dictionary lookups that matched a known word">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
|
||||
{summary.lookupRate != null ? `${summary.lookupRate}%` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total token occurrences encountered in today's sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Tokens Today</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
|
||||
{formatNumber(summary.todayTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique words seen for the first time today">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
New Words Today
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
|
||||
{formatNumber(summary.newWordsToday)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique words seen for the first time this week">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
|
||||
{formatNumber(summary.newWordsThisWeek)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
|
||||
<>
|
||||
<Tooltip text="Words matched against your known-words list out of all unique words seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
Known Words
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
|
||||
{formatNumber(knownWordsSummary.knownWordCount)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">
|
||||
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||
</span>
|
||||
{knownWordPercent != null ? (
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TrackingSnapshot
|
||||
summary={summary}
|
||||
showTrackedCardNote={showTrackedCardNote}
|
||||
knownWordsSummary={knownWordsSummary}
|
||||
/>
|
||||
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ interface AnimeGroup {
|
||||
totalCards: number;
|
||||
totalWords: number;
|
||||
totalActiveMs: number;
|
||||
totalKnownWords: number;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
@@ -65,6 +66,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
existing.totalCards += session.cardsMined;
|
||||
existing.totalWords += displayWordCount;
|
||||
existing.totalActiveMs += session.activeWatchedMs;
|
||||
existing.totalKnownWords += session.knownWordsSeen;
|
||||
} else {
|
||||
map.set(key, {
|
||||
key,
|
||||
@@ -75,6 +77,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
totalCards: session.cardsMined,
|
||||
totalWords: displayWordCount,
|
||||
totalActiveMs: session.activeWatchedMs,
|
||||
totalKnownWords: session.knownWordsSeen,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -173,6 +176,12 @@ function SessionItem({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -258,6 +267,12 @@ function AnimeGroupRow({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalKnownWords)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
@@ -327,6 +342,12 @@ function AnimeGroupRow({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
|
||||
47
stats/src/components/overview/TrackingSnapshot.test.tsx
Normal file
47
stats/src/components/overview/TrackingSnapshot.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
|
||||
const summary: OverviewSummary = {
|
||||
todayActiveMs: 0,
|
||||
todayCards: 0,
|
||||
streakDays: 0,
|
||||
allTimeMinutes: 120,
|
||||
totalTrackedCards: 9,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
averageSessionMinutes: 40,
|
||||
activeDays: 12,
|
||||
totalSessions: 15,
|
||||
lookupRate: {
|
||||
shortValue: '2.3 / 100 tokens',
|
||||
longValue: '2.3 lookups per 100 tokens',
|
||||
},
|
||||
todayTokens: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
recentWatchTime: [],
|
||||
};
|
||||
|
||||
test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Lookup Rate/);
|
||||
assert.match(markup, /2\.3 \/ 100 tokens/);
|
||||
assert.match(markup, /Lifetime Yomitan lookups normalized by total tokens seen/);
|
||||
});
|
||||
|
||||
test('TrackingSnapshot labels new words as unique headwords', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Unique headwords seen for the first time today/);
|
||||
assert.match(markup, /Unique headwords seen for the first time this week/);
|
||||
});
|
||||
151
stats/src/components/overview/TrackingSnapshot.tsx
Normal file
151
stats/src/components/overview/TrackingSnapshot.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { Tooltip } from '../layout/Tooltip';
|
||||
|
||||
interface KnownWordsSummary {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
}
|
||||
|
||||
interface TrackingSnapshotProps {
|
||||
summary: OverviewSummary;
|
||||
showTrackedCardNote?: boolean;
|
||||
knownWordsSummary: KnownWordsSummary | null;
|
||||
}
|
||||
|
||||
export function TrackingSnapshot({
|
||||
summary,
|
||||
showTrackedCardNote = false,
|
||||
knownWordsSummary,
|
||||
}: TrackingSnapshotProps) {
|
||||
const knownWordPercent =
|
||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
|
||||
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
|
||||
Lifetime totals sourced from summary tables.
|
||||
</p>
|
||||
{showTrackedCardNote && (
|
||||
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
|
||||
No lifetime card totals in the summary table yet. New cards mined after this fix will
|
||||
appear here.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<Tooltip text="Total immersion sessions recorded across all time">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total active watch time across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
|
||||
{summary.allTimeMinutes < 60
|
||||
? `${summary.allTimeMinutes}m`
|
||||
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of distinct days with at least one session">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Average active watch time per session in minutes">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
|
||||
{formatNumber(summary.averageSessionMinutes)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<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">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<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">Anime</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
|
||||
{formatNumber(summary.totalTrackedCards)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Lifetime Yomitan lookups normalized by total tokens seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
|
||||
{summary.lookupRate?.shortValue ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total token occurrences encountered in today's sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Tokens Today</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
|
||||
{formatNumber(summary.todayTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique headwords seen for the first time today">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
New Words Today
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
|
||||
{formatNumber(summary.newWordsToday)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique headwords seen for the first time this week">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
|
||||
{formatNumber(summary.newWordsThisWeek)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
|
||||
<Tooltip text="Words matched against your known-words list out of all unique words seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Known Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
|
||||
{formatNumber(knownWordsSummary.knownWordCount)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">
|
||||
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||
</span>
|
||||
{knownWordPercent != null ? (
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,8 +77,6 @@ function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
|
||||
|
||||
interface RatioChartPoint {
|
||||
tsMs: number;
|
||||
knownPct: number;
|
||||
unknownPct: number;
|
||||
knownWords: number;
|
||||
unknownWords: number;
|
||||
totalWords: number;
|
||||
@@ -326,11 +324,8 @@ function RatioView({
|
||||
if (totalWords === 0) continue;
|
||||
const knownWords = Math.min(lookupKnownWords(knownWordsMap, t.linesSeen), totalWords);
|
||||
const unknownWords = totalWords - knownWords;
|
||||
const knownPct = (knownWords / totalWords) * 100;
|
||||
chartData.push({
|
||||
tsMs: t.sampleMs,
|
||||
knownPct,
|
||||
unknownPct: 100 - knownPct,
|
||||
knownWords,
|
||||
unknownWords,
|
||||
totalWords,
|
||||
@@ -400,10 +395,10 @@ function RatioView({
|
||||
<YAxis
|
||||
yAxisId="pct"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 50, 100]}
|
||||
domain={[0, finalTotal]}
|
||||
allowDataOverflow
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
tickFormatter={(v: number) => `${v.toLocaleString()}`}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
@@ -415,13 +410,11 @@ function RatioView({
|
||||
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
|
||||
const d = props.payload;
|
||||
if (!d) return [_value, name];
|
||||
if (name === 'Known')
|
||||
return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known'];
|
||||
if (name === 'Unknown')
|
||||
return [
|
||||
`${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`,
|
||||
'Unknown',
|
||||
];
|
||||
if (name === 'Known words') {
|
||||
const knownPct = d.totalWords === 0 ? 0 : (d.knownWords / d.totalWords) * 100;
|
||||
return [`${d.knownWords.toLocaleString()} (${knownPct.toFixed(1)}%)`, name];
|
||||
}
|
||||
if (name === 'Unknown words') return [d.unknownWords.toLocaleString(), name];
|
||||
return [_value, name];
|
||||
}}
|
||||
itemSorter={() => -1}
|
||||
@@ -435,7 +428,7 @@ function RatioView({
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
y1={0}
|
||||
y2={100}
|
||||
y2={finalTotal}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
@@ -488,12 +481,12 @@ function RatioView({
|
||||
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="knownPct"
|
||||
dataKey="knownWords"
|
||||
stackId="ratio"
|
||||
stroke="#a6da95"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#knownGrad-${session.sessionId})`}
|
||||
name="Known"
|
||||
name="Known words"
|
||||
type="monotone"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
@@ -501,12 +494,12 @@ function RatioView({
|
||||
/>
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="unknownPct"
|
||||
dataKey="unknownWords"
|
||||
stackId="ratio"
|
||||
stroke="#c6a0f6"
|
||||
strokeWidth={0}
|
||||
fill={`url(#unknownGrad-${session.sessionId})`}
|
||||
name="Unknown"
|
||||
name="Unknown words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ export function SessionRow({
|
||||
deleteDisabled = false,
|
||||
}: SessionRowProps) {
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
const knownWordsSeen = session.knownWordsSeen;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
@@ -95,6 +96,12 @@ export function SessionRow({
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
|
||||
@@ -35,6 +35,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
lookupCount: 10,
|
||||
lookupHits: 8,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 10,
|
||||
knownWordRate: 12.5,
|
||||
},
|
||||
];
|
||||
const rollups: DailyRollup[] = [
|
||||
@@ -66,6 +68,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
totalCards: 9,
|
||||
totalLookupCount: 100,
|
||||
totalLookupHits: 80,
|
||||
totalTokensSeen: 1000,
|
||||
totalYomitanLookupCount: 23,
|
||||
newWordsToday: 5,
|
||||
newWordsThisWeek: 20,
|
||||
},
|
||||
@@ -80,7 +84,10 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
assert.equal(summary.allTimeMinutes, 50);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.totalSessions, 15);
|
||||
assert.equal(summary.lookupRate, 80);
|
||||
assert.deepEqual(summary.lookupRate, {
|
||||
shortValue: '2.3 / 100 tokens',
|
||||
longValue: '2.3 lookups per 100 tokens',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
||||
@@ -104,6 +111,8 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
lookupCount: 1,
|
||||
lookupHits: 1,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 2,
|
||||
knownWordRate: 20,
|
||||
},
|
||||
],
|
||||
rollups: [
|
||||
@@ -132,6 +141,8 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
totalCards: 5,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
@@ -141,6 +152,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
assert.equal(summary.totalTrackedCards, 5);
|
||||
assert.equal(summary.allTimeMinutes, 120);
|
||||
assert.equal(summary.activeDays, 40);
|
||||
assert.equal(summary.lookupRate, null);
|
||||
});
|
||||
|
||||
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
VocabularyEntry,
|
||||
} from '../types/stats';
|
||||
import { epochDayToDate, epochMsFromDbTimestamp, localDayFromMs } from './formatters';
|
||||
import { buildLookupRateDisplay, type LookupRateDisplay } from './yomitan-lookup';
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
@@ -25,7 +26,7 @@ export interface OverviewSummary {
|
||||
averageSessionMinutes: number;
|
||||
activeDays: number;
|
||||
totalSessions: number;
|
||||
lookupRate: number | null;
|
||||
lookupRate: LookupRateDisplay | null;
|
||||
todayTokens: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
@@ -181,10 +182,10 @@ export function buildOverviewSummary(
|
||||
: 0,
|
||||
activeDays: overview.hints.activeDays ?? daysWithActivity.size,
|
||||
totalSessions: overview.hints.totalSessions ?? overview.sessions.length,
|
||||
lookupRate:
|
||||
overview.hints.totalLookupCount > 0
|
||||
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
|
||||
: null,
|
||||
lookupRate: buildLookupRateDisplay(
|
||||
overview.hints.totalYomitanLookupCount,
|
||||
overview.hints.totalTokensSeen,
|
||||
),
|
||||
todayTokens: Math.max(
|
||||
todayRow?.words ?? 0,
|
||||
sumBy(todaySessions, (session) => session.tokensSeen),
|
||||
|
||||
@@ -23,6 +23,8 @@ test('MediaSessionList renders expandable session rows with delete affordance',
|
||||
lookupCount: 3,
|
||||
lookupHits: 2,
|
||||
yomitanLookupCount: 1,
|
||||
knownWordsSeen: 6,
|
||||
knownWordRate: 25,
|
||||
},
|
||||
]}
|
||||
onDeleteSession={() => {}}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SessionDetail } from '../components/sessions/SessionDetail';
|
||||
import {
|
||||
SessionDetail,
|
||||
getKnownPctAxisMax,
|
||||
} from '../components/sessions/SessionDetail';
|
||||
import { buildSessionChartEvents } from './session-events';
|
||||
import { EventType } from '../types/stats';
|
||||
|
||||
@@ -24,6 +27,8 @@ test('SessionDetail omits the misleading new words metric', () => {
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -58,3 +63,11 @@ test('buildSessionChartEvents keeps only chart-relevant events and pairs pause r
|
||||
);
|
||||
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
|
||||
});
|
||||
|
||||
test('getKnownPctAxisMax adds headroom above the highest known percentage', () => {
|
||||
assert.equal(getKnownPctAxisMax([22.4, 31.2, 29.8]), 40);
|
||||
});
|
||||
|
||||
test('getKnownPctAxisMax caps the chart top at 100%', () => {
|
||||
assert.equal(getKnownPctAxisMax([97.1, 98.6]), 100);
|
||||
});
|
||||
|
||||
@@ -157,6 +157,8 @@ test('SessionRow prefers token-based word count when available', () => {
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
}}
|
||||
isExpanded={false}
|
||||
detailsId="session-7"
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface SessionSummary {
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
yomitanLookupCount: number;
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}
|
||||
|
||||
export interface DailyRollup {
|
||||
@@ -110,8 +112,10 @@ export interface OverviewData {
|
||||
totalActiveMin: number;
|
||||
activeDays: number;
|
||||
totalCards?: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
};
|
||||
|
||||
2
vendor/texthooker-ui
vendored
2
vendor/texthooker-ui
vendored
Submodule vendor/texthooker-ui updated: 550c45eccb...a242951929
Reference in New Issue
Block a user