mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
Compare commits
15 Commits
windows-qo
...
stats-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
c5e778d7d2
|
|||
|
b1acbae580
|
|||
|
45d30ea66c
|
|||
|
b080add6ce
|
|||
|
b4aea0f77e
|
|||
|
6dcf7d9234
|
|||
|
cfb2396791
|
|||
|
8e25e19cac
|
|||
|
20976d63f0
|
|||
|
c1bc92f254
|
|||
|
364f7aacb7
|
|||
|
76547bb96e
|
|||
|
409a3964d2
|
|||
|
8874e2e1c6
|
|||
|
82d58a57c6
|
389
.github/workflows/prerelease.yml
vendored
389
.github/workflows/prerelease.yml
vendored
@@ -1,389 +0,0 @@
|
|||||||
name: Prerelease
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*-beta.*'
|
|
||||||
- 'v*-rc.*'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: prerelease-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality-gate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.5
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
stats/node_modules
|
|
||||||
vendor/subminer-yomitan/node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
cd stats && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Lint stats (formatting)
|
|
||||||
run: bun run lint:stats
|
|
||||||
|
|
||||||
- name: Build (TypeScript check)
|
|
||||||
run: bun run typecheck
|
|
||||||
|
|
||||||
- name: Test suite (source)
|
|
||||||
run: bun run test:fast
|
|
||||||
|
|
||||||
- name: Coverage suite (maintained source lane)
|
|
||||||
run: bun run test:coverage:src
|
|
||||||
|
|
||||||
- name: Upload coverage artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: coverage-test-src
|
|
||||||
path: coverage/test-src/lcov.info
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Launcher smoke suite (source)
|
|
||||||
run: bun run test:launcher:smoke:src
|
|
||||||
|
|
||||||
- name: Upload launcher smoke artifacts (on failure)
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: launcher-smoke
|
|
||||||
path: .tmp/launcher-smoke/**
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
- name: Build (bundle)
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Immersion SQLite verification
|
|
||||||
run: bun run test:immersion:sqlite:dist
|
|
||||||
|
|
||||||
- name: Dist smoke suite
|
|
||||||
run: bun run test:smoke:dist
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
needs: [quality-gate]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.5
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
stats/node_modules
|
|
||||||
vendor/texthooker-ui/node_modules
|
|
||||||
vendor/subminer-yomitan/node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
cd stats && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build texthooker-ui
|
|
||||||
run: |
|
|
||||||
cd vendor/texthooker-ui
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
run: bun run build:appimage
|
|
||||||
|
|
||||||
- name: Build unversioned AppImage
|
|
||||||
run: |
|
|
||||||
shopt -s nullglob
|
|
||||||
appimages=(release/SubMiner-*.AppImage)
|
|
||||||
if [ "${#appimages[@]}" -eq 0 ]; then
|
|
||||||
echo "No versioned AppImage found to create unversioned artifact."
|
|
||||||
ls -la release
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cp "${appimages[0]}" release/SubMiner.AppImage
|
|
||||||
|
|
||||||
- name: Upload AppImage artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: appimage
|
|
||||||
path: release/*.AppImage
|
|
||||||
|
|
||||||
build-macos:
|
|
||||||
needs: [quality-gate]
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.5
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
stats/node_modules
|
|
||||||
vendor/texthooker-ui/node_modules
|
|
||||||
vendor/subminer-yomitan/node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Validate macOS signing/notarization secrets
|
|
||||||
run: |
|
|
||||||
missing=0
|
|
||||||
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
|
|
||||||
if [ -z "${!name}" ]; then
|
|
||||||
echo "Missing required secret: $name"
|
|
||||||
missing=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$missing" -ne 0 ]; then
|
|
||||||
echo "Set all required macOS signing/notarization secrets and rerun."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
env:
|
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
cd stats && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build texthooker-ui
|
|
||||||
run: |
|
|
||||||
cd vendor/texthooker-ui
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Build signed + notarized macOS artifacts
|
|
||||||
run: bun run build:mac
|
|
||||||
env:
|
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
|
|
||||||
- name: Upload macOS artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: macos
|
|
||||||
path: |
|
|
||||||
release/*.dmg
|
|
||||||
release/*.zip
|
|
||||||
|
|
||||||
build-windows:
|
|
||||||
needs: [quality-gate]
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.5
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
stats/node_modules
|
|
||||||
vendor/texthooker-ui/node_modules
|
|
||||||
vendor/subminer-yomitan/node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
cd stats && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build texthooker-ui
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
Set-Location vendor/texthooker-ui
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Build unsigned Windows artifacts
|
|
||||||
run: bun run build:win:unsigned
|
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: windows
|
|
||||||
path: |
|
|
||||||
release/*.exe
|
|
||||||
release/*.zip
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: [build-linux, build-macos, build-windows]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Download AppImage
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: appimage
|
|
||||||
path: release
|
|
||||||
|
|
||||||
- name: Download macOS artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: macos
|
|
||||||
path: release
|
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: windows
|
|
||||||
path: release
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.5
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build Bun subminer wrapper
|
|
||||||
run: make build-launcher
|
|
||||||
|
|
||||||
- name: Verify Bun subminer wrapper
|
|
||||||
run: dist/launcher/subminer --help >/dev/null
|
|
||||||
|
|
||||||
- name: Enforce generated launcher workflow
|
|
||||||
run: bash scripts/verify-generated-launcher.sh
|
|
||||||
|
|
||||||
- name: Verify generated config examples
|
|
||||||
run: bun run verify:config-example
|
|
||||||
|
|
||||||
- name: Package optional assets bundle
|
|
||||||
run: |
|
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
|
||||||
config.example.jsonc \
|
|
||||||
plugin/subminer \
|
|
||||||
plugin/subminer.conf \
|
|
||||||
assets/themes/subminer.rasi
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
run: |
|
|
||||||
shopt -s nullglob
|
|
||||||
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
|
|
||||||
if [ "${#files[@]}" -eq 0 ]; then
|
|
||||||
echo "No release artifacts found for checksum generation."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sha256sum "${files[@]}" > release/SHA256SUMS.txt
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Generate prerelease notes from pending fragments
|
|
||||||
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
|
|
||||||
|
|
||||||
- name: Publish Prerelease
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
|
||||||
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
|
||||||
--draft=false \
|
|
||||||
--prerelease \
|
|
||||||
--title "${{ steps.version.outputs.VERSION }}" \
|
|
||||||
--notes-file release/prerelease-notes.md
|
|
||||||
else
|
|
||||||
gh release create "${{ steps.version.outputs.VERSION }}" \
|
|
||||||
--latest=false \
|
|
||||||
--prerelease \
|
|
||||||
--title "${{ steps.version.outputs.VERSION }}" \
|
|
||||||
--notes-file release/prerelease-notes.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
shopt -s nullglob
|
|
||||||
artifacts=(
|
|
||||||
release/*.AppImage
|
|
||||||
release/*.dmg
|
|
||||||
release/*.exe
|
|
||||||
release/*.zip
|
|
||||||
release/*.tar.gz
|
|
||||||
release/SHA256SUMS.txt
|
|
||||||
dist/launcher/subminer
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "${#artifacts[@]}" -eq 0 ]; then
|
|
||||||
echo "No release artifacts found for upload."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for asset in "${artifacts[@]}"; do
|
|
||||||
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
|
||||||
done
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -4,8 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- '!v*-beta.*'
|
|
||||||
- '!v*-rc.*'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
|
|
||||||
- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
|
|
||||||
@@ -30,9 +30,3 @@ Rules:
|
|||||||
- each non-empty body line becomes a bullet
|
- each non-empty body line becomes a bullet
|
||||||
- `README.md` is ignored by the generator
|
- `README.md` is ignored by the generator
|
||||||
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
||||||
|
|
||||||
Prerelease notes:
|
|
||||||
|
|
||||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
|
||||||
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
|
||||||
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
|
|
||||||
10
changes/stats-dashboard-feedback-pass.md
Normal file
10
changes/stats-dashboard-feedback-pass.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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.
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
# Releasing
|
# Releasing
|
||||||
|
|
||||||
## Stable Release
|
|
||||||
|
|
||||||
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
||||||
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
|
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
|
||||||
3. Run `bun run changelog:lint`.
|
3. Run `bun run changelog:lint`.
|
||||||
@@ -26,37 +24,15 @@
|
|||||||
10. Tag the commit: `git tag v<version>`.
|
10. Tag the commit: `git tag v<version>`.
|
||||||
11. Push commit + tag.
|
11. Push commit + tag.
|
||||||
|
|
||||||
## Prerelease
|
|
||||||
|
|
||||||
1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
|
|
||||||
2. Run `bun run changelog:lint`.
|
|
||||||
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
|
|
||||||
4. Run the prerelease gate locally:
|
|
||||||
`bun run changelog:prerelease-notes --version <version>`
|
|
||||||
`bun run verify:config-example`
|
|
||||||
`bun run typecheck`
|
|
||||||
`bun run test:fast`
|
|
||||||
`bun run test:env`
|
|
||||||
`bun run build`
|
|
||||||
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
|
|
||||||
6. Tag the commit: `git tag v<version>`.
|
|
||||||
7. Push commit + tag.
|
|
||||||
|
|
||||||
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
|
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
|
||||||
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
|
||||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `changelog:check` now rejects tag/package version mismatches.
|
||||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
|
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
||||||
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
|
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
|
||||||
- Do not tag while `changes/*.md` fragments still exist.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
|
||||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
|
||||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||||
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
||||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||||
|
|||||||
1609
docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md
Normal file
1609
docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
|||||||
|
# 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.`
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.12.0-beta.1",
|
"version": "0.11.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
||||||
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
||||||
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
|
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
|
||||||
"changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes",
|
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"format:src": "bash scripts/prettier-scope.sh --write",
|
"format:src": "bash scripts/prettier-scope.sh --write",
|
||||||
@@ -70,7 +69,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
|
|||||||
@@ -310,186 +310,3 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
|
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
|
||||||
const workspace = createWorkspace('prerelease-beta-notes');
|
|
||||||
const projectRoot = path.join(workspace, 'SubMiner');
|
|
||||||
const changelogPath = path.join(projectRoot, 'CHANGELOG.md');
|
|
||||||
const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md');
|
|
||||||
const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n';
|
|
||||||
const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n';
|
|
||||||
|
|
||||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'package.json'),
|
|
||||||
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(changelogPath, existingChangelog, 'utf8');
|
|
||||||
fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8');
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'changes', '001.md'),
|
|
||||||
['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'changes', '002.md'),
|
|
||||||
['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const outputPath = writePrereleaseNotesForVersion({
|
|
||||||
cwd: projectRoot,
|
|
||||||
version: '0.11.3-beta.1',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
|
|
||||||
assert.equal(
|
|
||||||
fs.readFileSync(changelogPath, 'utf8'),
|
|
||||||
existingChangelog,
|
|
||||||
'stable CHANGELOG.md should remain unchanged',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
fs.readFileSync(docsChangelogPath, 'utf8'),
|
|
||||||
existingDocsChangelog,
|
|
||||||
'docs-site changelog should remain unchanged',
|
|
||||||
);
|
|
||||||
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
|
|
||||||
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
|
|
||||||
|
|
||||||
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
|
||||||
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
|
||||||
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./);
|
|
||||||
assert.match(
|
|
||||||
prereleaseNotes,
|
|
||||||
/### Fixed\n- Launcher: Fixed prerelease packaging checks\./,
|
|
||||||
);
|
|
||||||
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
|
||||||
const workspace = createWorkspace('prerelease-rc-notes');
|
|
||||||
const projectRoot = path.join(workspace, 'SubMiner');
|
|
||||||
|
|
||||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'package.json'),
|
|
||||||
JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'changes', '001.md'),
|
|
||||||
['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const outputPath = writePrereleaseNotesForVersion({
|
|
||||||
cwd: projectRoot,
|
|
||||||
version: '0.11.3-rc.1',
|
|
||||||
});
|
|
||||||
|
|
||||||
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
|
||||||
assert.match(
|
|
||||||
prereleaseNotes,
|
|
||||||
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => {
|
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
|
||||||
const workspace = createWorkspace('prerelease-alpha-reject');
|
|
||||||
const projectRoot = path.join(workspace, 'SubMiner');
|
|
||||||
|
|
||||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'package.json'),
|
|
||||||
JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'changes', '001.md'),
|
|
||||||
['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
writePrereleaseNotesForVersion({
|
|
||||||
cwd: projectRoot,
|
|
||||||
version: '0.11.3-alpha.1',
|
|
||||||
}),
|
|
||||||
/Unsupported prerelease version \(0\.11\.3-alpha\.1\)/,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => {
|
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
|
||||||
const workspace = createWorkspace('prerelease-version-mismatch');
|
|
||||||
const projectRoot = path.join(workspace, 'SubMiner');
|
|
||||||
|
|
||||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'package.json'),
|
|
||||||
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'changes', '001.md'),
|
|
||||||
['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
writePrereleaseNotesForVersion({
|
|
||||||
cwd: projectRoot,
|
|
||||||
version: '0.11.3-beta.2',
|
|
||||||
}),
|
|
||||||
/package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => {
|
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
|
||||||
const workspace = createWorkspace('prerelease-no-fragments');
|
|
||||||
const projectRoot = path.join(workspace, 'SubMiner');
|
|
||||||
|
|
||||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectRoot, 'package.json'),
|
|
||||||
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
writePrereleaseNotesForVersion({
|
|
||||||
cwd: projectRoot,
|
|
||||||
version: '0.11.3-beta.1',
|
|
||||||
}),
|
|
||||||
/No changelog fragments found in changes\//,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ type PullRequestChangelogOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
||||||
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
|
|
||||||
const CHANGELOG_HEADER = '# Changelog';
|
const CHANGELOG_HEADER = '# Changelog';
|
||||||
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
||||||
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
||||||
@@ -76,10 +75,6 @@ function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'dep
|
|||||||
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedPrereleaseVersion(version: string): boolean {
|
|
||||||
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyRequestedVersionMatchesPackageVersion(
|
function verifyRequestedVersionMatchesPackageVersion(
|
||||||
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
||||||
): void {
|
): void {
|
||||||
@@ -319,15 +314,8 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[
|
|||||||
return [path.join(cwd, 'CHANGELOG.md')];
|
return [path.join(cwd, 'CHANGELOG.md')];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReleaseNotes(
|
function renderReleaseNotes(changes: string): string {
|
||||||
changes: string,
|
|
||||||
options?: {
|
|
||||||
disclaimer?: string;
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
|
||||||
return [
|
return [
|
||||||
...prefix,
|
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
changes,
|
changes,
|
||||||
'',
|
'',
|
||||||
@@ -346,21 +334,13 @@ function renderReleaseNotes(
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeReleaseNotesFile(
|
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
|
||||||
cwd: string,
|
|
||||||
changes: string,
|
|
||||||
deps?: ChangelogFsDeps,
|
|
||||||
options?: {
|
|
||||||
disclaimer?: string;
|
|
||||||
outputPath?: string;
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
||||||
const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH);
|
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
||||||
|
|
||||||
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
||||||
writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8');
|
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8');
|
||||||
return releaseNotesPath;
|
return releaseNotesPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,30 +613,6 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
|||||||
return writeReleaseNotesFile(cwd, changes, options?.deps);
|
return writeReleaseNotesFile(cwd, changes, options?.deps);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
|
|
||||||
verifyRequestedVersionMatchesPackageVersion(options ?? {});
|
|
||||||
|
|
||||||
const cwd = options?.cwd ?? process.cwd();
|
|
||||||
const version = resolveVersion(options ?? {});
|
|
||||||
if (!isSupportedPrereleaseVersion(version)) {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragments = readChangeFragments(cwd, options?.deps);
|
|
||||||
if (fragments.length === 0) {
|
|
||||||
throw new Error('No changelog fragments found in changes/.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const changes = renderGroupedChanges(fragments);
|
|
||||||
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
|
||||||
disclaimer:
|
|
||||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
|
||||||
outputPath: PRERELEASE_NOTES_PATH,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCliArgs(argv: string[]): {
|
function parseCliArgs(argv: string[]): {
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -754,11 +710,6 @@ function main(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'prerelease-notes') {
|
|
||||||
writePrereleaseNotesForVersion(options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === 'docs') {
|
if (command === 'docs') {
|
||||||
generateDocsChangelog(options);
|
generateDocsChangelog(options);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -601,6 +601,22 @@ describe('stats server API routes', () => {
|
|||||||
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
|
assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
||||||
let seenArgs: unknown[] = [];
|
let seenArgs: unknown[] = [];
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTrendsDashboard(
|
async getTrendsDashboard(
|
||||||
range: '7d' | '30d' | '90d' | 'all' = '30d',
|
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||||
groupBy: 'day' | 'month' = 'day',
|
groupBy: 'day' | 'month' = 'day',
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return getTrendsDashboard(this.db, range, groupBy);
|
return getTrendsDashboard(this.db, range, groupBy);
|
||||||
|
|||||||
@@ -835,6 +835,65 @@ 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', () => {
|
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from './query-shared';
|
} from './query-shared';
|
||||||
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
|
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
|
||||||
|
|
||||||
type TrendRange = '7d' | '30d' | '90d' | 'all';
|
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||||
type TrendGroupBy = 'day' | 'month';
|
type TrendGroupBy = 'day' | 'month';
|
||||||
|
|
||||||
interface TrendChartPoint {
|
interface TrendChartPoint {
|
||||||
@@ -85,6 +85,7 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
|||||||
'7d': 7,
|
'7d': 7,
|
||||||
'30d': 30,
|
'30d': 30,
|
||||||
'90d': 90,
|
'90d': 90,
|
||||||
|
'365d': 365,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
buildMpvSubtitleAddCommands,
|
|
||||||
collectDroppedSubtitlePaths,
|
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
parseClipboardVideoPath,
|
parseClipboardVideoPath,
|
||||||
type DropDataTransferLike,
|
type DropDataTransferLike,
|
||||||
@@ -43,33 +41,6 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
|
|||||||
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => {
|
|
||||||
const transfer = makeTransfer({
|
|
||||||
files: [
|
|
||||||
{ path: '/subs/ep02.ass' },
|
|
||||||
{ path: '/subs/readme.txt' },
|
|
||||||
{ path: '/subs/ep03.SRT' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = collectDroppedSubtitlePaths(transfer);
|
|
||||||
|
|
||||||
assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => {
|
|
||||||
const transfer = makeTransfer({
|
|
||||||
getData: (format: string) =>
|
|
||||||
format === 'text/uri-list'
|
|
||||||
? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n'
|
|
||||||
: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = collectDroppedSubtitlePaths(transfer);
|
|
||||||
|
|
||||||
assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
||||||
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
||||||
|
|
||||||
@@ -88,15 +59,6 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () =>
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => {
|
|
||||||
const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']);
|
|
||||||
|
|
||||||
assert.deepEqual(commands, [
|
|
||||||
['sub-add', '/tmp/ep01.ass', 'select'],
|
|
||||||
['sub-add', '/tmp/ep02.srt'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
||||||
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ const VIDEO_EXTENSIONS = new Set([
|
|||||||
'.wmv',
|
'.wmv',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
|
|
||||||
|
|
||||||
function getPathExtension(pathValue: string): string {
|
function getPathExtension(pathValue: string): string {
|
||||||
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
||||||
const dot = normalized.lastIndexOf('.');
|
const dot = normalized.lastIndexOf('.');
|
||||||
@@ -34,11 +32,7 @@ function isSupportedVideoPath(pathValue: string): boolean {
|
|||||||
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedSubtitlePath(pathValue: string): boolean {
|
function parseUriList(data: string): string[] {
|
||||||
return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] {
|
|
||||||
if (!data.trim()) return [];
|
if (!data.trim()) return [];
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
@@ -53,7 +47,7 @@ function parseUriList(data: string, isSupportedPath: (pathValue: string) => bool
|
|||||||
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
||||||
filePath = filePath.slice(1);
|
filePath = filePath.slice(1);
|
||||||
}
|
}
|
||||||
if (filePath && isSupportedPath(filePath)) {
|
if (filePath && isSupportedVideoPath(filePath)) {
|
||||||
out.push(filePath);
|
out.push(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -93,19 +87,6 @@ export function parseClipboardVideoPath(text: string): string | null {
|
|||||||
|
|
||||||
export function collectDroppedVideoPaths(
|
export function collectDroppedVideoPaths(
|
||||||
dataTransfer: DropDataTransferLike | null | undefined,
|
dataTransfer: DropDataTransferLike | null | undefined,
|
||||||
): string[] {
|
|
||||||
return collectDroppedPaths(dataTransfer, isSupportedVideoPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectDroppedSubtitlePaths(
|
|
||||||
dataTransfer: DropDataTransferLike | null | undefined,
|
|
||||||
): string[] {
|
|
||||||
return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectDroppedPaths(
|
|
||||||
dataTransfer: DropDataTransferLike | null | undefined,
|
|
||||||
isSupportedPath: (pathValue: string) => boolean,
|
|
||||||
): string[] {
|
): string[] {
|
||||||
if (!dataTransfer) return [];
|
if (!dataTransfer) return [];
|
||||||
|
|
||||||
@@ -115,7 +96,7 @@ function collectDroppedPaths(
|
|||||||
const addPath = (candidate: string | null | undefined): void => {
|
const addPath = (candidate: string | null | undefined): void => {
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
const trimmed = candidate.trim();
|
const trimmed = candidate.trim();
|
||||||
if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
|
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
};
|
};
|
||||||
@@ -128,7 +109,7 @@ function collectDroppedPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof dataTransfer.getData === 'function') {
|
if (typeof dataTransfer.getData === 'function') {
|
||||||
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
|
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
|
||||||
addPath(pathValue);
|
addPath(pathValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,9 +130,3 @@ export function buildMpvLoadfileCommands(
|
|||||||
index === 0 ? 'replace' : 'append',
|
index === 0 ? 'replace' : 'append',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> {
|
|
||||||
return paths.map((pathValue, index) =>
|
|
||||||
index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ type WindowTrackerStub = {
|
|||||||
|
|
||||||
function createMainWindowRecorder() {
|
function createMainWindowRecorder() {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let visible = false;
|
|
||||||
const window = {
|
const window = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
isVisible: () => visible,
|
|
||||||
hide: () => {
|
hide: () => {
|
||||||
visible = false;
|
|
||||||
calls.push('hide');
|
calls.push('hide');
|
||||||
},
|
},
|
||||||
show: () => {
|
show: () => {
|
||||||
visible = true;
|
|
||||||
calls.push('show');
|
calls.push('show');
|
||||||
},
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
@@ -204,134 +200,6 @@ test('Windows visible overlay stays click-through and does not steal focus while
|
|||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tracked Windows overlay refresh preserves renderer-managed mouse interaction when already visible', () => {
|
|
||||||
const { window, calls } = createMainWindowRecorder();
|
|
||||||
const tracker: WindowTrackerStub = {
|
|
||||||
isTracking: () => true,
|
|
||||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
|
||||||
visibleOverlayVisible: true,
|
|
||||||
mainWindow: window as never,
|
|
||||||
windowTracker: tracker as never,
|
|
||||||
trackerNotReadyWarningShown: false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {
|
|
||||||
calls.push('update-bounds');
|
|
||||||
},
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {
|
|
||||||
calls.push('sync-layer');
|
|
||||||
},
|
|
||||||
enforceOverlayLayerOrder: () => {
|
|
||||||
calls.push('enforce-order');
|
|
||||||
},
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
isMacOSPlatform: false,
|
|
||||||
isWindowsPlatform: true,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
calls.length = 0;
|
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
|
||||||
visibleOverlayVisible: true,
|
|
||||||
mainWindow: window as never,
|
|
||||||
windowTracker: tracker as never,
|
|
||||||
trackerNotReadyWarningShown: false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {
|
|
||||||
calls.push('update-bounds');
|
|
||||||
},
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {
|
|
||||||
calls.push('sync-layer');
|
|
||||||
},
|
|
||||||
enforceOverlayLayerOrder: () => {
|
|
||||||
calls.push('enforce-order');
|
|
||||||
},
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
isMacOSPlatform: false,
|
|
||||||
isWindowsPlatform: true,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
assert.ok(!calls.includes('mouse-ignore:true:forward'));
|
|
||||||
assert.ok(!calls.includes('show'));
|
|
||||||
assert.ok(calls.includes('ensure-level'));
|
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('forced passthrough still reapplies while visible on Windows', () => {
|
|
||||||
const { window, calls } = createMainWindowRecorder();
|
|
||||||
const tracker: WindowTrackerStub = {
|
|
||||||
isTracking: () => true,
|
|
||||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
|
||||||
visibleOverlayVisible: true,
|
|
||||||
mainWindow: window as never,
|
|
||||||
windowTracker: tracker as never,
|
|
||||||
trackerNotReadyWarningShown: false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {
|
|
||||||
calls.push('update-bounds');
|
|
||||||
},
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {
|
|
||||||
calls.push('sync-layer');
|
|
||||||
},
|
|
||||||
enforceOverlayLayerOrder: () => {
|
|
||||||
calls.push('enforce-order');
|
|
||||||
},
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
isMacOSPlatform: false,
|
|
||||||
isWindowsPlatform: true,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
calls.length = 0;
|
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
|
||||||
visibleOverlayVisible: true,
|
|
||||||
mainWindow: window as never,
|
|
||||||
windowTracker: tracker as never,
|
|
||||||
trackerNotReadyWarningShown: false,
|
|
||||||
setTrackerNotReadyWarningShown: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {
|
|
||||||
calls.push('update-bounds');
|
|
||||||
},
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {
|
|
||||||
calls.push('sync-layer');
|
|
||||||
},
|
|
||||||
enforceOverlayLayerOrder: () => {
|
|
||||||
calls.push('enforce-order');
|
|
||||||
},
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
isMacOSPlatform: false,
|
|
||||||
isWindowsPlatform: true,
|
|
||||||
forceMousePassthrough: true,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('visible overlay stays hidden while a modal window is active', () => {
|
test('visible overlay stays hidden while a modal window is active', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
|
|||||||
@@ -37,21 +37,13 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
|
|
||||||
const showPassiveVisibleOverlay = (): void => {
|
const showPassiveVisibleOverlay = (): void => {
|
||||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||||
const shouldDefaultToPassthrough =
|
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
|
||||||
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
|
|
||||||
const wasVisible = mainWindow.isVisible();
|
|
||||||
|
|
||||||
if (!wasVisible || forceMousePassthrough) {
|
|
||||||
if (shouldDefaultToPassthrough) {
|
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
args.ensureOverlayWindowLevel(mainWindow);
|
args.ensureOverlayWindowLevel(mainWindow);
|
||||||
if (!wasVisible) {
|
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
}
|
|
||||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
|
|||||||
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' {
|
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
||||||
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
|
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
||||||
|
? raw
|
||||||
|
: '30d';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
|
|
||||||
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8');
|
|
||||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
|
||||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
|
||||||
scripts: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
test('prerelease workflow triggers on beta and rc tags only', () => {
|
|
||||||
assert.match(prereleaseWorkflow, /name: Prerelease/);
|
|
||||||
assert.match(prereleaseWorkflow, /tags:\s*\n\s*-\s*'v\*-beta\.\*'/);
|
|
||||||
assert.match(prereleaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'v\*-rc\.\*'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('package scripts expose prerelease notes generation separately from stable changelog build', () => {
|
|
||||||
assert.equal(
|
|
||||||
packageJson.scripts['changelog:prerelease-notes'],
|
|
||||||
'bun run scripts/build-changelog.ts prerelease-notes',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prerelease workflow generates prerelease notes from pending fragments', () => {
|
|
||||||
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
|
|
||||||
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
|
|
||||||
assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/);
|
|
||||||
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/);
|
|
||||||
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prerelease workflow builds and uploads all release platforms', () => {
|
|
||||||
assert.match(prereleaseWorkflow, /build-linux:/);
|
|
||||||
assert.match(prereleaseWorkflow, /build-macos:/);
|
|
||||||
assert.match(prereleaseWorkflow, /build-windows:/);
|
|
||||||
assert.match(prereleaseWorkflow, /name: appimage/);
|
|
||||||
assert.match(prereleaseWorkflow, /name: macos/);
|
|
||||||
assert.match(prereleaseWorkflow, /name: windows/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prerelease workflow publishes the same release assets as the stable workflow', () => {
|
|
||||||
assert.match(
|
|
||||||
prereleaseWorkflow,
|
|
||||||
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
prereleaseWorkflow,
|
|
||||||
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prerelease workflow does not publish to AUR', () => {
|
|
||||||
assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/);
|
|
||||||
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
|
|
||||||
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
|
|
||||||
});
|
|
||||||
@@ -22,12 +22,6 @@ test('publish release leaves prerelease unset so gh creates a normal release', (
|
|||||||
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stable release workflow excludes prerelease beta and rc tags', () => {
|
|
||||||
assert.match(releaseWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
|
|
||||||
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-beta\.\*'/);
|
|
||||||
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('publish release forces an existing draft tag release to become public', () => {
|
test('publish release forces an existing draft tag release to become public', () => {
|
||||||
assert.ok(releaseWorkflow.includes('--draft=false'));
|
assert.ok(releaseWorkflow.includes('--draft=false'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createRendererRecoveryController } from './error-recovery.js';
|
import { createRendererRecoveryController } from './error-recovery.js';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HOST_SELECTOR,
|
|
||||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
|
||||||
hasYomitanPopupIframe,
|
hasYomitanPopupIframe,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
@@ -286,25 +284,9 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
|||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
|
|
||||||
const selectors: string[] = [];
|
|
||||||
const root = {
|
|
||||||
querySelector: (value: string) => {
|
|
||||||
selectors.push(value);
|
|
||||||
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
} as unknown as ParentNode;
|
|
||||||
|
|
||||||
assert.equal(hasYomitanPopupIframe(root), true);
|
|
||||||
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
const selectors: string[] = [];
|
let selector = '';
|
||||||
const visibleFrame = {
|
const visibleFrame = {
|
||||||
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
} as unknown as HTMLIFrameElement;
|
} as unknown as HTMLIFrameElement;
|
||||||
@@ -327,40 +309,18 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
|||||||
try {
|
try {
|
||||||
const root = {
|
const root = {
|
||||||
querySelectorAll: (value: string) => {
|
querySelectorAll: (value: string) => {
|
||||||
selectors.push(value);
|
selector = value;
|
||||||
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [hiddenFrame, visibleFrame];
|
return [hiddenFrame, visibleFrame];
|
||||||
},
|
},
|
||||||
} as unknown as ParentNode;
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
assert.equal(isYomitanPopupVisible(root), true);
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
assert.deepEqual(selectors, [
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
|
||||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
|
|
||||||
let selector = '';
|
|
||||||
const root = {
|
|
||||||
querySelectorAll: (value: string) => {
|
|
||||||
selector = value;
|
|
||||||
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
|
||||||
return [{ getAttribute: () => 'true' }];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
} as unknown as ParentNode;
|
|
||||||
|
|
||||||
assert.equal(isYomitanPopupVisible(root), true);
|
|
||||||
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||||
const activeItem = {
|
const activeItem = {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { SPECIAL_COMMANDS } from '../../config/definitions';
|
|||||||
import type { Keybinding, ShortcutsConfig } from '../../types';
|
import type { Keybinding, ShortcutsConfig } from '../../types';
|
||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HOST_SELECTOR,
|
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
YOMITAN_POPUP_COMMAND_EVENT,
|
YOMITAN_POPUP_COMMAND_EVENT,
|
||||||
@@ -62,9 +61,6 @@ export function createKeyboardHandlers(
|
|||||||
if (target.closest('.modal')) return true;
|
if (target.closest('.modal')) return true;
|
||||||
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||||
if (isYomitanPopupIframe(target)) return true;
|
if (isYomitanPopupIframe(target)) return true;
|
||||||
if (target.closest && target.closest(YOMITAN_POPUP_HOST_SELECTOR)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import type { SubtitleSidebarConfig } from '../../types';
|
import type { SubtitleSidebarConfig } from '../../types';
|
||||||
import { createMouseHandlers } from './mouse.js';
|
import { createMouseHandlers } from './mouse.js';
|
||||||
import {
|
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
|
||||||
YOMITAN_POPUP_HOST_SELECTOR,
|
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
|
||||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
|
||||||
} from '../yomitan-popup.js';
|
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
@@ -83,13 +78,11 @@ function createMouseTestContext() {
|
|||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
shouldToggleMouseIgnore: false,
|
shouldToggleMouseIgnore: false,
|
||||||
isLinuxPlatform: false,
|
|
||||||
isMacOSPlatform: false,
|
isMacOSPlatform: false,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
isOverSubtitleSidebar: false,
|
isOverSubtitleSidebar: false,
|
||||||
yomitanPopupVisible: false,
|
|
||||||
subtitleSidebarModalOpen: false,
|
subtitleSidebarModalOpen: false,
|
||||||
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -719,257 +712,6 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nested popup close reasserts interactive state and focus when another popup remains visible on Windows', async () => {
|
|
||||||
const ctx = createMouseTestContext();
|
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
|
||||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
|
||||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
|
||||||
const previousNode = (globalThis as { Node?: unknown }).Node;
|
|
||||||
const windowListeners = new Map<string, Array<() => void>>();
|
|
||||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
|
||||||
let focusMainWindowCalls = 0;
|
|
||||||
let windowFocusCalls = 0;
|
|
||||||
let overlayFocusCalls = 0;
|
|
||||||
|
|
||||||
ctx.platform.shouldToggleMouseIgnore = true;
|
|
||||||
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
|
||||||
overlayFocusCalls += 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const visiblePopupHost = {
|
|
||||||
tagName: 'DIV',
|
|
||||||
getAttribute: (name: string) =>
|
|
||||||
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
addEventListener: (type: string, listener: () => void) => {
|
|
||||||
const bucket = windowListeners.get(type) ?? [];
|
|
||||||
bucket.push(listener);
|
|
||||||
windowListeners.set(type, bucket);
|
|
||||||
},
|
|
||||||
electronAPI: {
|
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
|
||||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
|
||||||
},
|
|
||||||
focusMainWindow: () => {
|
|
||||||
focusMainWindowCalls += 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
focus: () => {
|
|
||||||
windowFocusCalls += 1;
|
|
||||||
},
|
|
||||||
getComputedStyle: () => ({
|
|
||||||
visibility: 'visible',
|
|
||||||
display: 'block',
|
|
||||||
opacity: '1',
|
|
||||||
}),
|
|
||||||
innerHeight: 1000,
|
|
||||||
getSelection: () => null,
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
querySelector: () => null,
|
|
||||||
querySelectorAll: (selector: string) => {
|
|
||||||
if (
|
|
||||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
|
||||||
selector === YOMITAN_POPUP_HOST_SELECTOR
|
|
||||||
) {
|
|
||||||
return [visiblePopupHost];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
body: {},
|
|
||||||
elementFromPoint: () => null,
|
|
||||||
addEventListener: () => {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
|
||||||
configurable: true,
|
|
||||||
value: class {
|
|
||||||
observe() {}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'Node', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
ELEMENT_NODE: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const handlers = createMouseHandlers(ctx as never, {
|
|
||||||
modalStateReader: {
|
|
||||||
isAnySettingsModalOpen: () => false,
|
|
||||||
isAnyModalOpen: () => false,
|
|
||||||
},
|
|
||||||
applyYPercent: () => {},
|
|
||||||
getCurrentYPercent: () => 10,
|
|
||||||
persistSubtitlePositionPatch: () => {},
|
|
||||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
|
||||||
getYomitanPopupAutoPauseEnabled: () => false,
|
|
||||||
getPlaybackPaused: async () => false,
|
|
||||||
sendMpvCommand: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
handlers.setupYomitanObserver();
|
|
||||||
ignoreCalls.length = 0;
|
|
||||||
|
|
||||||
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
|
||||||
listener();
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
|
||||||
assert.equal(focusMainWindowCalls, 1);
|
|
||||||
assert.equal(windowFocusCalls, 1);
|
|
||||||
assert.equal(overlayFocusCalls, 1);
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
|
||||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
|
||||||
configurable: true,
|
|
||||||
value: previousMutationObserver,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
|
||||||
const ctx = createMouseTestContext();
|
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
|
||||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
|
||||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
|
||||||
const previousNode = (globalThis as { Node?: unknown }).Node;
|
|
||||||
const windowListeners = new Map<string, Array<() => void>>();
|
|
||||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
|
||||||
let focusMainWindowCalls = 0;
|
|
||||||
let windowFocusCalls = 0;
|
|
||||||
let overlayFocusCalls = 0;
|
|
||||||
|
|
||||||
ctx.platform.shouldToggleMouseIgnore = true;
|
|
||||||
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
|
||||||
overlayFocusCalls += 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const visiblePopupHost = {
|
|
||||||
tagName: 'DIV',
|
|
||||||
getAttribute: (name: string) =>
|
|
||||||
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
addEventListener: (type: string, listener: () => void) => {
|
|
||||||
const bucket = windowListeners.get(type) ?? [];
|
|
||||||
bucket.push(listener);
|
|
||||||
windowListeners.set(type, bucket);
|
|
||||||
},
|
|
||||||
electronAPI: {
|
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
|
||||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
|
||||||
},
|
|
||||||
focusMainWindow: () => {
|
|
||||||
focusMainWindowCalls += 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
focus: () => {
|
|
||||||
windowFocusCalls += 1;
|
|
||||||
},
|
|
||||||
getComputedStyle: () => ({
|
|
||||||
visibility: 'visible',
|
|
||||||
display: 'block',
|
|
||||||
opacity: '1',
|
|
||||||
}),
|
|
||||||
innerHeight: 1000,
|
|
||||||
getSelection: () => null,
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
visibilityState: 'visible',
|
|
||||||
querySelector: () => null,
|
|
||||||
querySelectorAll: (selector: string) => {
|
|
||||||
if (
|
|
||||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
|
||||||
selector === YOMITAN_POPUP_HOST_SELECTOR
|
|
||||||
) {
|
|
||||||
return [visiblePopupHost];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
body: {},
|
|
||||||
elementFromPoint: () => null,
|
|
||||||
addEventListener: () => {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
|
||||||
configurable: true,
|
|
||||||
value: class {
|
|
||||||
observe() {}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'Node', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
ELEMENT_NODE: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const handlers = createMouseHandlers(ctx as never, {
|
|
||||||
modalStateReader: {
|
|
||||||
isAnySettingsModalOpen: () => false,
|
|
||||||
isAnyModalOpen: () => false,
|
|
||||||
},
|
|
||||||
applyYPercent: () => {},
|
|
||||||
getCurrentYPercent: () => 10,
|
|
||||||
persistSubtitlePositionPatch: () => {},
|
|
||||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
|
||||||
getYomitanPopupAutoPauseEnabled: () => false,
|
|
||||||
getPlaybackPaused: async () => false,
|
|
||||||
sendMpvCommand: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
handlers.setupYomitanObserver();
|
|
||||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
|
||||||
ignoreCalls.length = 0;
|
|
||||||
|
|
||||||
for (const listener of windowListeners.get('blur') ?? []) {
|
|
||||||
listener();
|
|
||||||
}
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
|
||||||
assert.equal(focusMainWindowCalls, 1);
|
|
||||||
assert.equal(windowFocusCalls, 1);
|
|
||||||
assert.equal(overlayFocusCalls, 1);
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
|
||||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
|
||||||
configurable: true,
|
|
||||||
value: previousMutationObserver,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -1174,8 +916,10 @@ test('pointer tracking restores click-through after the cursor leaves subtitles'
|
|||||||
|
|
||||||
assert.equal(ctx.state.isOverSubtitle, false);
|
assert.equal(ctx.state.isOverSubtitle, false);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
assert.equal(ignoreCalls[0]?.ignore, false);
|
assert.deepEqual(ignoreCalls, [
|
||||||
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
|
{ ignore: false, forward: undefined },
|
||||||
|
{ ignore: true, forward: true },
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type { ModalStateReader, RendererContext } from '../context';
|
|||||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
|
||||||
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
@@ -36,60 +34,6 @@ export function createMouseHandlers(
|
|||||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||||
let pendingPointerResync = false;
|
let pendingPointerResync = false;
|
||||||
|
|
||||||
function getPopupVisibilityFromDom(): boolean {
|
|
||||||
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncPopupVisibilityState(assumeVisible = false): boolean {
|
|
||||||
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
|
|
||||||
yomitanPopupVisible = popupVisible;
|
|
||||||
ctx.state.yomitanPopupVisible = popupVisible;
|
|
||||||
return popupVisible;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reclaimOverlayWindowFocusForPopup(): void {
|
|
||||||
if (!ctx.platform.shouldToggleMouseIgnore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window.electronAPI.focusMainWindow === 'function') {
|
|
||||||
void window.electronAPI.focusMainWindow();
|
|
||||||
}
|
|
||||||
window.focus();
|
|
||||||
if (typeof ctx.dom.overlay.focus === 'function') {
|
|
||||||
ctx.dom.overlay.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sustainPopupInteraction(): void {
|
|
||||||
syncPopupVisibilityState(true);
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconcilePopupInteraction(args: {
|
|
||||||
assumeVisible?: boolean;
|
|
||||||
reclaimFocus?: boolean;
|
|
||||||
allowPause?: boolean;
|
|
||||||
} = {}): boolean {
|
|
||||||
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
|
|
||||||
if (!popupVisible) {
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
if (args.reclaimFocus === true) {
|
|
||||||
reclaimOverlayWindowFocusForPopup();
|
|
||||||
}
|
|
||||||
if (args.allowPause === true) {
|
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
@@ -261,14 +205,18 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enablePopupInteraction(): void {
|
function enablePopupInteraction(): void {
|
||||||
sustainPopupInteraction();
|
yomitanPopupVisible = true;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
if (ctx.platform.isMacOSPlatform) {
|
if (ctx.platform.isMacOSPlatform) {
|
||||||
window.focus();
|
window.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disablePopupInteractionIfIdle(): void {
|
function disablePopupInteractionIfIdle(): void {
|
||||||
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
||||||
|
yomitanPopupVisible = true;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,37 +356,19 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupYomitanObserver(): void {
|
function setupYomitanObserver(): void {
|
||||||
reconcilePopupInteraction({ allowPause: true });
|
yomitanPopupVisible = isYomitanPopupVisible(document);
|
||||||
|
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
enablePopupInteraction();
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
disablePopupInteractionIfIdle();
|
disablePopupInteractionIfIdle();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
|
||||||
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
|
||||||
reconcilePopupInteraction({ assumeVisible: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('focus', () => {
|
|
||||||
reconcilePopupInteraction();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('blur', () => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reconcilePopupInteraction({ reclaimFocus: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
|||||||
@@ -61,62 +61,3 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
|
|||||||
Object.assign(globalThis, { window: originalWindow });
|
Object.assign(globalThis, { window: originalWindow });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
|
|
||||||
const classList = createClassList();
|
|
||||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
|
||||||
const originalWindow = globalThis.window;
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
|
|
||||||
Object.assign(globalThis, {
|
|
||||||
window: {
|
|
||||||
electronAPI: {
|
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
|
||||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getComputedStyle: () => ({
|
|
||||||
visibility: 'visible',
|
|
||||||
display: 'block',
|
|
||||||
opacity: '1',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
document: {
|
|
||||||
querySelectorAll: (selector: string) =>
|
|
||||||
selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
|
|
||||||
? [{ getAttribute: () => 'true' }]
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
syncOverlayMouseIgnoreState({
|
|
||||||
dom: {
|
|
||||||
overlay: { classList },
|
|
||||||
},
|
|
||||||
platform: {
|
|
||||||
shouldToggleMouseIgnore: true,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
isOverSubtitle: false,
|
|
||||||
isOverSubtitleSidebar: false,
|
|
||||||
yomitanPopupVisible: false,
|
|
||||||
controllerSelectModalOpen: false,
|
|
||||||
controllerDebugModalOpen: false,
|
|
||||||
jimakuModalOpen: false,
|
|
||||||
youtubePickerModalOpen: false,
|
|
||||||
kikuModalOpen: false,
|
|
||||||
runtimeOptionsModalOpen: false,
|
|
||||||
subsyncModalOpen: false,
|
|
||||||
sessionHelpModalOpen: false,
|
|
||||||
subtitleSidebarModalOpen: false,
|
|
||||||
subtitleSidebarConfig: null,
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
assert.equal(classList.contains('interactive'), true);
|
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
|
||||||
} finally {
|
|
||||||
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { RendererContext } from './context';
|
import type { RendererContext } from './context';
|
||||||
import type { RendererState } from './state';
|
import type { RendererState } from './state';
|
||||||
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
|
||||||
|
|
||||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -15,21 +14,11 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
|
||||||
if (state.yomitanPopupVisible) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return isYomitanPopupVisible(document);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||||
const shouldStayInteractive =
|
const shouldStayInteractive =
|
||||||
ctx.state.isOverSubtitle ||
|
ctx.state.isOverSubtitle ||
|
||||||
ctx.state.isOverSubtitleSidebar ||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
isYomitanPopupInteractionActive(ctx.state) ||
|
ctx.state.yomitanPopupVisible ||
|
||||||
isBlockingOverlayModalOpen(ctx.state);
|
isBlockingOverlayModalOpen(ctx.state);
|
||||||
|
|
||||||
if (shouldStayInteractive) {
|
if (shouldStayInteractive) {
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ import { resolveRendererDom } from './utils/dom.js';
|
|||||||
import { resolvePlatformInfo } from './utils/platform.js';
|
import { resolvePlatformInfo } from './utils/platform.js';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
buildMpvSubtitleAddCommands,
|
|
||||||
collectDroppedSubtitlePaths,
|
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
} from '../core/services/overlay-drop.js';
|
} from '../core/services/overlay-drop.js';
|
||||||
|
|
||||||
@@ -708,28 +706,18 @@ function setupDragDropToMpvQueue(): void {
|
|||||||
if (!event.dataTransfer) return;
|
if (!event.dataTransfer) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
|
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||||
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
|
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
|
||||||
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
|
|
||||||
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
|
|
||||||
for (const command of loadCommands) {
|
for (const command of loadCommands) {
|
||||||
window.electronAPI.sendMpvCommand(command);
|
window.electronAPI.sendMpvCommand(command);
|
||||||
}
|
}
|
||||||
for (const command of subtitleCommands) {
|
|
||||||
window.electronAPI.sendMpvCommand(command);
|
|
||||||
}
|
|
||||||
const osdParts: string[] = [];
|
|
||||||
if (loadCommands.length > 0) {
|
if (loadCommands.length > 0) {
|
||||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||||
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
|
window.electronAPI.sendMpvCommand([
|
||||||
}
|
'show-text',
|
||||||
if (subtitleCommands.length > 0) {
|
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
|
||||||
osdParts.push(
|
'1500',
|
||||||
`Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
|
]);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (osdParts.length > 0) {
|
|
||||||
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDropInteractive();
|
clearDropInteractive();
|
||||||
|
|||||||
@@ -684,8 +684,7 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.settings-modal-open iframe.yomitan-popup,
|
body.settings-modal-open iframe.yomitan-popup,
|
||||||
body.settings-modal-open iframe[id^='yomitan-popup'],
|
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
||||||
body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
|
||||||
display: none !important;
|
display: none !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
@@ -1152,8 +1151,7 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iframe.yomitan-popup,
|
iframe.yomitan-popup,
|
||||||
iframe[id^='yomitan-popup'],
|
iframe[id^='yomitan-popup'] {
|
||||||
[data-subminer-yomitan-popup-host='true'] {
|
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
z-index: 2147483647 !important;
|
z-index: 2147483647 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
||||||
export const YOMITAN_POPUP_HOST_SELECTOR = '[data-subminer-yomitan-popup-host="true"]';
|
|
||||||
export const YOMITAN_POPUP_VISIBLE_HOST_SELECTOR =
|
|
||||||
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]';
|
|
||||||
const YOMITAN_POPUP_VISIBLE_ATTRIBUTE = 'data-subminer-yomitan-popup-visible';
|
|
||||||
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
||||||
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
||||||
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
||||||
@@ -33,56 +29,21 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||||
return (
|
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
||||||
typeof root.querySelector === 'function' &&
|
|
||||||
(root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
|
|
||||||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVisiblePopupElement(element: Element): boolean {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
if (rect.width <= 0 || rect.height <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = window.getComputedStyle(element);
|
|
||||||
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMarkedVisiblePopupHost(element: Element): boolean {
|
|
||||||
return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryPopupElements<T extends Element>(root: ParentNode, selector: string): T[] {
|
|
||||||
if (typeof root.querySelectorAll !== 'function') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Array.from(root.querySelectorAll<T>(selector));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||||
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
if (visiblePopupHosts.length > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
|
|
||||||
for (const iframe of popupIframes) {
|
for (const iframe of popupIframes) {
|
||||||
if (isVisiblePopupElement(iframe)) {
|
const rect = iframe.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const styles = window.getComputedStyle(iframe);
|
||||||
|
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
|
|
||||||
for (const host of popupHosts) {
|
|
||||||
if (isMarkedVisiblePopupHost(host)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function AnimeTab({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search anime..."
|
placeholder="Search library..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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"
|
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>
|
||||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||||
{filtered.length} anime · {formatDuration(totalMs)}
|
{filtered.length} titles · {formatDuration(totalMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
|
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
||||||
{filtered.map((item) => (
|
{filtered.map((item) => (
|
||||||
|
|||||||
60
stats/src/components/anime/EpisodeDetail.test.tsx
Normal file
60
stats/src/components/anime/EpisodeDetail.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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,10 +16,32 @@ interface NoteInfo {
|
|||||||
expression: string;
|
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) {
|
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
|
||||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||||
|
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||||
}
|
}
|
||||||
setNoteInfos(map);
|
setNoteInfos(map);
|
||||||
|
setNoteInfosLoaded(true);
|
||||||
})
|
})
|
||||||
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
|
.catch((err) => {
|
||||||
|
console.warn('Failed to fetch Anki note info:', err);
|
||||||
|
if (!cancelled) setNoteInfosLoaded(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!cancelled) setNoteInfosLoaded(true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
|
|
||||||
const { sessions, cardEvents } = data;
|
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 (
|
return (
|
||||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cardEvents.length > 0 && (
|
{filteredCardEvents.length > 0 && (
|
||||||
<div className="p-3 border-b border-ctp-surface1">
|
<div className="p-3 border-b border-ctp-surface1">
|
||||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{cardEvents.map((ev) => (
|
{filteredCardEvents.map((ev) => (
|
||||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||||
{ev.noteIds.length > 0 ? (
|
{ev.noteIds.length > 0 ? (
|
||||||
@@ -144,6 +182,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { getRelatedCollectionLabel } from './MediaDetailView';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { getRelatedCollectionLabel, buildDeleteEpisodeHandler } from './MediaDetailView';
|
||||||
|
|
||||||
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
|
|||||||
'View Anime',
|
'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,12 +1,34 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
|
||||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||||
import { MediaHeader } from './MediaHeader';
|
import { MediaHeader } from './MediaHeader';
|
||||||
import { MediaSessionList } from './MediaSessionList';
|
import { MediaSessionList } from './MediaSessionList';
|
||||||
import type { MediaDetailData, SessionSummary } from '../../types/stats';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
if (!opts.confirmFn(opts.title)) return;
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
||||||
if (detail?.channelName?.trim()) {
|
if (detail?.channelName?.trim()) {
|
||||||
return 'View Channel';
|
return 'View Channel';
|
||||||
@@ -79,6 +101,15 @@ export function MediaDetailView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteEpisode = buildDeleteEpisodeHandler({
|
||||||
|
videoId,
|
||||||
|
title: detail.canonicalTitle,
|
||||||
|
apiClient,
|
||||||
|
confirmFn: confirmEpisodeDelete,
|
||||||
|
onBack,
|
||||||
|
setDeleteError,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -99,7 +130,7 @@ export function MediaDetailView({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<MediaHeader detail={detail} />
|
<MediaHeader detail={detail} onDeleteEpisode={handleDeleteEpisode} />
|
||||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||||
<MediaSessionList
|
<MediaSessionList
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
|||||||
@@ -12,9 +12,14 @@ interface MediaHeaderProps {
|
|||||||
totalUniqueWords: number;
|
totalUniqueWords: number;
|
||||||
knownWordCount: number;
|
knownWordCount: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
onDeleteEpisode?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
|
export function MediaHeader({
|
||||||
|
detail,
|
||||||
|
initialKnownWordsSummary = null,
|
||||||
|
onDeleteEpisode,
|
||||||
|
}: MediaHeaderProps) {
|
||||||
const knownTokenRate =
|
const knownTokenRate =
|
||||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||||
const avgSessionMs =
|
const avgSessionMs =
|
||||||
@@ -50,7 +55,18 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
|||||||
className="w-32 h-44 rounded-lg shrink-0"
|
className="w-32 h-44 rounded-lg shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||||
|
{onDeleteEpisode != null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDeleteEpisode}
|
||||||
|
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity"
|
||||||
|
>
|
||||||
|
Delete Episode
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{detail.channelName ? (
|
{detail.channelName ? (
|
||||||
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
||||||
{detail.channelUrl ? (
|
{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="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Active Anime"
|
label="Active Titles"
|
||||||
value={formatNumber(summary.activeAnimeCount)}
|
value={formatNumber(summary.activeAnimeCount)}
|
||||||
color="text-ctp-mauve"
|
color="text-ctp-mauve"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function TrackingSnapshot({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text="Total unique episodes (videos) watched across all anime">
|
<Tooltip text="Total unique videos watched across all titles in your library">
|
||||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
|
<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">
|
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||||
@@ -79,9 +79,9 @@ export function TrackingSnapshot({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text="Number of anime series fully completed">
|
<Tooltip text="Number of titles fully completed">
|
||||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
|
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Titles</div>
|
||||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||||
{formatNumber(summary.totalAnimeCompleted)}
|
{formatNumber(summary.totalAnimeCompleted)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
import { epochDayToDate } from '../../lib/formatters';
|
import { epochDayToDate } from '../../lib/formatters';
|
||||||
import { CHART_THEME } from '../../lib/chart-theme';
|
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||||
import type { DailyRollup } from '../../types/stats';
|
import type { DailyRollup } from '../../types/stats';
|
||||||
|
|
||||||
interface WatchTimeChartProps {
|
interface WatchTimeChartProps {
|
||||||
@@ -52,28 +60,23 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
|
||||||
|
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={30}
|
width={32}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||||
background: CHART_THEME.tooltipBg,
|
|
||||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
|
||||||
borderRadius: 6,
|
|
||||||
color: CHART_THEME.tooltipText,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||||
formatter={formatActiveMinutes}
|
formatter={formatActiveMinutes}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function SessionRow({
|
|||||||
}}
|
}}
|
||||||
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
|
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"
|
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 anime overview"
|
title="View in Library"
|
||||||
>
|
>
|
||||||
{'\u2197'}
|
{'\u2197'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
156
stats/src/components/sessions/SessionsTab.test.tsx
Normal file
156
stats/src/components/sessions/SessionsTab.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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,8 +3,9 @@ import { useSessions } from '../../hooks/useSessions';
|
|||||||
import { SessionRow } from './SessionRow';
|
import { SessionRow } from './SessionRow';
|
||||||
import { SessionDetail } from './SessionDetail';
|
import { SessionDetail } from './SessionDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||||
import { formatSessionDayLabel } from '../../lib/formatters';
|
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||||
|
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
|
||||||
import type { SessionSummary } from '../../types/stats';
|
import type { SessionSummary } from '../../types/stats';
|
||||||
|
|
||||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||||
@@ -23,6 +24,35 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
|
|||||||
return groups;
|
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 {
|
interface SessionsTabProps {
|
||||||
initialSessionId?: number | null;
|
initialSessionId?: number | null;
|
||||||
onClearInitialSession?: () => void;
|
onClearInitialSession?: () => void;
|
||||||
@@ -36,10 +66,12 @@ export function SessionsTab({
|
|||||||
}: SessionsTabProps = {}) {
|
}: SessionsTabProps = {}) {
|
||||||
const { sessions, loading, error } = useSessions();
|
const { sessions, loading, error } = useSessions();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||||
|
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleSessions(sessions);
|
setVisibleSessions(sessions);
|
||||||
@@ -76,7 +108,16 @@ export function SessionsTab({
|
|||||||
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
|
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
|
||||||
}, [visibleSessions, search]);
|
}, [visibleSessions, search]);
|
||||||
|
|
||||||
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
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 handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!confirmSessionDelete()) return;
|
||||||
@@ -94,6 +135,33 @@ 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 (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||||
|
|
||||||
@@ -110,7 +178,9 @@ export function SessionsTab({
|
|||||||
|
|
||||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||||
|
|
||||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
|
{Array.from(dayGroups.entries()).map(([dayLabel, daySessions]) => {
|
||||||
|
const buckets = groupSessionsByVideo(daySessions);
|
||||||
|
return (
|
||||||
<div key={dayLabel}>
|
<div key={dayLabel}>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||||
@@ -119,7 +189,78 @@ export function SessionsTab({
|
|||||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{daySessions.map((s) => {
|
{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}`;
|
const detailsId = `session-details-${s.sessionId}`;
|
||||||
return (
|
return (
|
||||||
<div key={s.sessionId}>
|
<div key={s.sessionId}>
|
||||||
@@ -127,7 +268,11 @@ export function SessionsTab({
|
|||||||
session={s}
|
session={s}
|
||||||
isExpanded={expandedId === s.sessionId}
|
isExpanded={expandedId === s.sessionId}
|
||||||
detailsId={detailsId}
|
detailsId={detailsId}
|
||||||
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
onToggle={() =>
|
||||||
|
setExpandedId(
|
||||||
|
expandedId === s.sessionId ? null : s.sessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
onDelete={() => void handleDeleteSession(s)}
|
onDelete={() => void handleDeleteSession(s)}
|
||||||
deleteDisabled={deletingSessionId === s.sessionId}
|
deleteDisabled={deletingSessionId === s.sessionId}
|
||||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||||
@@ -141,8 +286,14 @@ export function SessionsTab({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="text-ctp-overlay2 text-sm">
|
<div className="text-ctp-overlay2 text-sm">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function DateRangeSelector({
|
|||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
label="Range"
|
label="Range"
|
||||||
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
||||||
value={range}
|
value={range}
|
||||||
onChange={onRangeChange}
|
onChange={onRangeChange}
|
||||||
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||||
import { epochDayToDate } from '../../lib/formatters';
|
import { epochDayToDate } from '../../lib/formatters';
|
||||||
|
|
||||||
export interface PerAnimeDataPoint {
|
export interface PerAnimeDataPoint {
|
||||||
@@ -64,14 +73,6 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
|||||||
const { points, seriesKeys } = buildLineData(data);
|
const { points, seriesKeys } = buildLineData(data);
|
||||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||||
|
|
||||||
const tooltipStyle = {
|
|
||||||
background: '#363a4f',
|
|
||||||
border: '1px solid #494d64',
|
|
||||||
borderRadius: 6,
|
|
||||||
color: '#cad3f5',
|
|
||||||
fontSize: 12,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
@@ -84,21 +85,22 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
|||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<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>
|
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||||
<AreaChart data={points}>
|
<AreaChart data={points} margin={CHART_DEFAULTS.margin}>
|
||||||
|
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={28}
|
width={32}
|
||||||
/>
|
/>
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
|
||||||
{seriesKeys.map((key, i) => (
|
{seriesKeys.map((key, i) => (
|
||||||
<Area
|
<Area
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
CartesianGrid,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||||
|
|
||||||
interface TrendChartProps {
|
interface TrendChartProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -19,35 +21,29 @@ interface TrendChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TrendChart({ title, data, color, type, formatter, onBarClick }: 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]);
|
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<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>
|
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||||
{type === 'bar' ? (
|
{type === 'bar' ? (
|
||||||
<BarChart data={data}>
|
<BarChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||||
|
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={28}
|
width={32}
|
||||||
|
tickFormatter={formatter}
|
||||||
/>
|
/>
|
||||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
fill={color}
|
fill={color}
|
||||||
@@ -59,20 +55,22 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
|
|||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
) : (
|
) : (
|
||||||
<LineChart data={data}>
|
<LineChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||||
|
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||||
axisLine={false}
|
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={28}
|
width={32}
|
||||||
|
tickFormatter={formatter}
|
||||||
/>
|
/>
|
||||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export function TrendsTab() {
|
|||||||
type="line"
|
type="line"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader>Anime — Per Day</SectionHeader>
|
<SectionHeader>Library — Per Day</SectionHeader>
|
||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={animeTitles}
|
animeTitles={animeTitles}
|
||||||
hiddenAnime={activeHiddenAnime}
|
hiddenAnime={activeHiddenAnime}
|
||||||
@@ -239,21 +239,21 @@ export function TrendsTab() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
|
<StackedTrendChart title="Videos per Title" data={filteredEpisodesPerAnime} />
|
||||||
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
<StackedTrendChart title="Watch Time per Title (min)" data={filteredWatchTimePerAnime} />
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
title="Cards Mined per Anime"
|
title="Cards Mined per Title"
|
||||||
data={filteredCardsPerAnime}
|
data={filteredCardsPerAnime}
|
||||||
colorPalette={cardsMinedStackedColors}
|
colorPalette={cardsMinedStackedColors}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
<StackedTrendChart title="Words Seen per Title" data={filteredWordsPerAnime} />
|
||||||
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
<StackedTrendChart title="Lookups per Title" data={filteredLookupsPerAnime} />
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
title="Lookups/100w per Anime"
|
title="Lookups/100w per Title"
|
||||||
data={filteredLookupsPerHundredPerAnime}
|
data={filteredLookupsPerHundredPerAnime}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader>Anime — Cumulative</SectionHeader>
|
<SectionHeader>Library — Cumulative</SectionHeader>
|
||||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function CrossAnimeWordsTable({
|
|||||||
>
|
>
|
||||||
{'\u25B6'}
|
{'\u25B6'}
|
||||||
</span>
|
</span>
|
||||||
Words In Multiple Anime
|
Words Across Multiple Titles
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{hasKnownData && (
|
{hasKnownData && (
|
||||||
@@ -97,8 +97,8 @@ export function CrossAnimeWordsTable({
|
|||||||
{collapsed ? null : ranked.length === 0 ? (
|
{collapsed ? null : ranked.length === 0 ? (
|
||||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||||
{hideKnown
|
{hideKnown
|
||||||
? 'All multi-anime words are already known!'
|
? 'All words that span multiple titles are already known!'
|
||||||
: 'No words found across multiple anime.'}
|
: 'No words found across multiple titles.'}
|
||||||
</div>
|
</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">Word</th>
|
||||||
<th className="text-left py-2 pr-3 font-medium">Reading</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-left py-2 pr-3 font-medium w-20">POS</th>
|
||||||
<th className="text-right py-2 pr-3 font-medium w-16">Anime</th>
|
<th className="text-right py-2 pr-3 font-medium w-16">Titles</th>
|
||||||
<th className="text-right py-2 font-medium w-16">Seen</th>
|
<th className="text-right py-2 font-medium w-16">Seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
40
stats/src/components/vocabulary/FrequencyRankTable.test.tsx
Normal file
40
stats/src/components/vocabulary/FrequencyRankTable.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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 reading in brackets when equal to headword');
|
||||||
|
});
|
||||||
@@ -113,7 +113,6 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
<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 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">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-left py-2 pr-3 font-medium w-20">POS</th>
|
||||||
<th className="text-right py-2 font-medium w-20">Seen</th>
|
<th className="text-right py-2 font-medium w-20">Seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -128,9 +127,17 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
||||||
#{w.frequencyRank!.toLocaleString()}
|
#{w.frequencyRank!.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
|
<td className="py-1.5 pr-3">
|
||||||
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
<span className="text-ctp-text font-medium">{w.headword}</span>
|
||||||
{fullReading(w.headword, w.reading) || w.headword}
|
{(() => {
|
||||||
|
const reading = fullReading(w.headword, w.reading);
|
||||||
|
if (!reading || reading === w.headword) return null;
|
||||||
|
return (
|
||||||
|
<span className="text-ctp-subtext0 text-xs ml-1.5">
|
||||||
|
【{reading}】
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-3">
|
<td className="py-1.5 pr-3">
|
||||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
import type { TrendsDashboardData } from '../types/stats';
|
import type { TrendsDashboardData } from '../types/stats';
|
||||||
|
|
||||||
export type TimeRange = '7d' | '30d' | '90d' | 'all';
|
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||||
export type GroupBy = 'day' | 'month';
|
export type GroupBy = 'day' | 'month';
|
||||||
|
|
||||||
export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
||||||
|
|||||||
@@ -115,6 +115,55 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getTrendsDashboard accepts 365d range and builds correct URL', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let seenUrl = '';
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
seenUrl = String(input);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
activity: { watchTime: [], cards: [], words: [], sessions: [] },
|
||||||
|
progress: {
|
||||||
|
watchTime: [],
|
||||||
|
sessions: [],
|
||||||
|
words: [],
|
||||||
|
newWords: [],
|
||||||
|
cards: [],
|
||||||
|
episodes: [],
|
||||||
|
lookups: [],
|
||||||
|
},
|
||||||
|
ratios: { lookupsPerHundred: [] },
|
||||||
|
animePerDay: {
|
||||||
|
episodes: [],
|
||||||
|
watchTime: [],
|
||||||
|
cards: [],
|
||||||
|
words: [],
|
||||||
|
lookups: [],
|
||||||
|
lookupsPerHundred: [],
|
||||||
|
},
|
||||||
|
animeCumulative: {
|
||||||
|
watchTime: [],
|
||||||
|
episodes: [],
|
||||||
|
cards: [],
|
||||||
|
words: [],
|
||||||
|
},
|
||||||
|
patterns: {
|
||||||
|
watchTimeByDayOfWeek: [],
|
||||||
|
watchTimeByHour: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.getTrendsDashboard('365d', 'day');
|
||||||
|
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day`);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getSessionEvents can request only specific event types', async () => {
|
test('getSessionEvents can request only specific event types', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let seenUrl = '';
|
let seenUrl = '';
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const apiClient = {
|
|||||||
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||||
getWatchTimePerAnime: (limit = 90) =>
|
getWatchTimePerAnime: (limit = 90) =>
|
||||||
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||||
getTrendsDashboard: (range: '7d' | '30d' | '90d' | 'all', groupBy: 'day' | 'month') =>
|
getTrendsDashboard: (range: '7d' | '30d' | '90d' | '365d' | 'all', groupBy: 'day' | 'month') =>
|
||||||
fetchJson<TrendsDashboardData>(
|
fetchJson<TrendsDashboardData>(
|
||||||
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
|
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
|
||||||
),
|
),
|
||||||
|
|||||||
16
stats/src/lib/chart-theme.test.ts
Normal file
16
stats/src/lib/chart-theme.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from './chart-theme';
|
||||||
|
|
||||||
|
test('CHART_THEME exposes a grid color', () => {
|
||||||
|
assert.equal(CHART_THEME.grid, '#494d64');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CHART_DEFAULTS uses 11px ticks for legibility', () => {
|
||||||
|
assert.equal(CHART_DEFAULTS.tickFontSize, 11);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TOOLTIP_CONTENT_STYLE mirrors the shared tooltip colors', () => {
|
||||||
|
assert.equal(TOOLTIP_CONTENT_STYLE.background, CHART_THEME.tooltipBg);
|
||||||
|
assert.ok(String(TOOLTIP_CONTENT_STYLE.border).includes(CHART_THEME.tooltipBorder));
|
||||||
|
});
|
||||||
@@ -5,4 +5,21 @@ export const CHART_THEME = {
|
|||||||
tooltipText: '#cad3f5',
|
tooltipText: '#cad3f5',
|
||||||
tooltipLabel: '#b8c0e0',
|
tooltipLabel: '#b8c0e0',
|
||||||
barFill: '#8aadf4',
|
barFill: '#8aadf4',
|
||||||
|
grid: '#494d64',
|
||||||
|
axisLine: '#494d64',
|
||||||
} as const;
|
} 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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
confirmBucketDelete,
|
||||||
confirmDayGroupDelete,
|
confirmDayGroupDelete,
|
||||||
confirmEpisodeDelete,
|
confirmEpisodeDelete,
|
||||||
confirmSessionDelete,
|
confirmSessionDelete,
|
||||||
@@ -54,6 +55,41 @@ test('confirmDayGroupDelete uses singular for one session', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('confirmBucketDelete asks about merging multiple sessions of the same episode', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const originalConfirm = globalThis.confirm;
|
||||||
|
globalThis.confirm = ((message?: string) => {
|
||||||
|
calls.push(message ?? '');
|
||||||
|
return true;
|
||||||
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(confirmBucketDelete('My Episode', 3), true);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.match(calls[0]!, /3/);
|
||||||
|
assert.match(calls[0]!, /My Episode/);
|
||||||
|
assert.match(calls[0]!, /sessions/);
|
||||||
|
} finally {
|
||||||
|
globalThis.confirm = originalConfirm;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirmBucketDelete uses singular for one session', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const originalConfirm = globalThis.confirm;
|
||||||
|
globalThis.confirm = ((message?: string) => {
|
||||||
|
calls.push(message ?? '');
|
||||||
|
return false;
|
||||||
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(confirmBucketDelete('Solo Episode', 1), false);
|
||||||
|
assert.match(calls[0]!, /1 session of/);
|
||||||
|
} finally {
|
||||||
|
globalThis.confirm = originalConfirm;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
|
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
|
|||||||
@@ -17,3 +17,9 @@ export function confirmAnimeGroupDelete(title: string, count: number): boolean {
|
|||||||
export function confirmEpisodeDelete(title: string): boolean {
|
export function confirmEpisodeDelete(title: string): boolean {
|
||||||
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
|
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmBucketDelete(title: string, count: number): boolean {
|
||||||
|
return globalThis.confirm(
|
||||||
|
`Delete all ${count} session${count === 1 ? '' : 's'} of "${title}" from this day?`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
72
stats/src/lib/session-grouping.test.ts
Normal file
72
stats/src/lib/session-grouping.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { SessionSummary } from '../types/stats';
|
||||||
|
import { groupSessionsByVideo } from './session-grouping';
|
||||||
|
|
||||||
|
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||||
|
return {
|
||||||
|
sessionId: overrides.sessionId,
|
||||||
|
canonicalTitle: null,
|
||||||
|
videoId: null,
|
||||||
|
animeId: null,
|
||||||
|
animeTitle: null,
|
||||||
|
startedAtMs: 1000,
|
||||||
|
endedAtMs: null,
|
||||||
|
totalWatchedMs: 0,
|
||||||
|
activeWatchedMs: 0,
|
||||||
|
linesSeen: 0,
|
||||||
|
tokensSeen: 0,
|
||||||
|
cardsMined: 0,
|
||||||
|
lookupCount: 0,
|
||||||
|
lookupHits: 0,
|
||||||
|
yomitanLookupCount: 0,
|
||||||
|
knownWordsSeen: 0,
|
||||||
|
knownWordRate: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('empty input returns empty array', () => {
|
||||||
|
assert.deepEqual(groupSessionsByVideo([]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two unique videoIds produce 2 singleton buckets', () => {
|
||||||
|
const sessions = [
|
||||||
|
makeSession({ sessionId: 1, videoId: 10, startedAtMs: 1000, activeWatchedMs: 100, cardsMined: 2 }),
|
||||||
|
makeSession({ sessionId: 2, videoId: 20, startedAtMs: 2000, activeWatchedMs: 200, cardsMined: 3 }),
|
||||||
|
];
|
||||||
|
const buckets = groupSessionsByVideo(sessions);
|
||||||
|
assert.equal(buckets.length, 2);
|
||||||
|
const keys = buckets.map((b) => b.key).sort();
|
||||||
|
assert.deepEqual(keys, ['v-10', 'v-20']);
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
assert.equal(bucket.sessions.length, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two sessions sharing a videoId collapse into 1 bucket with summed totals and most-recent representative', () => {
|
||||||
|
const older = makeSession({ sessionId: 1, videoId: 42, startedAtMs: 1000, activeWatchedMs: 300, cardsMined: 5 });
|
||||||
|
const newer = makeSession({ sessionId: 2, videoId: 42, startedAtMs: 9000, activeWatchedMs: 500, cardsMined: 7 });
|
||||||
|
const buckets = groupSessionsByVideo([older, newer]);
|
||||||
|
assert.equal(buckets.length, 1);
|
||||||
|
const [bucket] = buckets;
|
||||||
|
assert.equal(bucket!.key, 'v-42');
|
||||||
|
assert.equal(bucket!.videoId, 42);
|
||||||
|
assert.equal(bucket!.sessions.length, 2);
|
||||||
|
assert.equal(bucket!.totalActiveMs, 800);
|
||||||
|
assert.equal(bucket!.totalCardsMined, 12);
|
||||||
|
assert.equal(bucket!.representativeSession.sessionId, 2); // most recent (highest startedAtMs)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sessions with null videoId become singleton buckets keyed by sessionId', () => {
|
||||||
|
const s1 = makeSession({ sessionId: 101, videoId: null, activeWatchedMs: 50, cardsMined: 1 });
|
||||||
|
const s2 = makeSession({ sessionId: 202, videoId: null, activeWatchedMs: 75, cardsMined: 2 });
|
||||||
|
const buckets = groupSessionsByVideo([s1, s2]);
|
||||||
|
assert.equal(buckets.length, 2);
|
||||||
|
const keys = buckets.map((b) => b.key).sort();
|
||||||
|
assert.deepEqual(keys, ['s-101', 's-202']);
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
assert.equal(bucket.videoId, null);
|
||||||
|
assert.equal(bucket.sessions.length, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
43
stats/src/lib/session-grouping.ts
Normal file
43
stats/src/lib/session-grouping.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { SessionSummary } from '../types/stats';
|
||||||
|
|
||||||
|
export interface SessionBucket {
|
||||||
|
key: string;
|
||||||
|
videoId: number | null;
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
totalActiveMs: number;
|
||||||
|
totalCardsMined: number;
|
||||||
|
representativeSession: SessionSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[] {
|
||||||
|
const byKey = new Map<string, SessionSummary[]>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
const hasVideoId =
|
||||||
|
typeof session.videoId === 'number' &&
|
||||||
|
Number.isFinite(session.videoId) &&
|
||||||
|
session.videoId > 0;
|
||||||
|
const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`;
|
||||||
|
const existing = byKey.get(key);
|
||||||
|
if (existing) existing.push(session);
|
||||||
|
else byKey.set(key, [session]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets: SessionBucket[] = [];
|
||||||
|
for (const [key, group] of byKey) {
|
||||||
|
const sorted = [...group].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
||||||
|
const representative = sorted[0]!;
|
||||||
|
buckets.push({
|
||||||
|
key,
|
||||||
|
videoId:
|
||||||
|
typeof representative.videoId === 'number' && representative.videoId > 0
|
||||||
|
? representative.videoId
|
||||||
|
: null,
|
||||||
|
sessions: sorted,
|
||||||
|
totalActiveMs: sorted.reduce((s, x) => s + x.activeWatchedMs, 0),
|
||||||
|
totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0),
|
||||||
|
representativeSession: representative,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: ed31b7a3ee...69620abcbc
Reference in New Issue
Block a user