feat(anime): filter episodes and mark them watched by hand

- Add a filter box above the episode list for a number, range, or name substring, with a "N of M" counter
- Read watch marks from the immersion tracker stats and show them per episode, with a watched count in the header, refreshed on window focus
- Add a right-click menu to mark one episode or a whole catch-up span (this and everything below) watched/unwatched
- Add setWatched/getWatchState IPC plumbing so a manual mark creates the stats row for an episode that was never played
This commit is contained in:
2026-08-15 21:44:51 -07:00
parent 3dd0750b98
commit 8d55d64ee0
23 changed files with 1601 additions and 111 deletions
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterEpisodes, parseEpisodeFilter } from './episode-filter';
const episode = (number: number | null, name: string) => ({ number, name });
const SEASON = [
episode(1, 'Episode 1 - Departure'),
episode(2, 'Episode 2 - The Long Road'),
episode(12, 'Episode 12 - Homecoming'),
episode(12.5, 'Episode 12.5 - Recap'),
episode(18, 'Episode 18 - Finale'),
episode(null, 'OVA: Beach Special'),
];
test('an empty query keeps the whole list', () => {
assert.deepEqual(filterEpisodes(SEASON, ' '), SEASON);
assert.equal(parseEpisodeFilter(''), null);
});
test('a number matches that episode, not every episode containing the digits', () => {
assert.deepEqual(
filterEpisodes(SEASON, '12').map((item) => item.number),
[12, 12.5],
);
});
test('a number still matches names when the source reported no numbers', () => {
const unnumbered = [episode(null, 'Episode 3'), episode(null, 'Episode 4')];
assert.deepEqual(
filterEpisodes(unnumbered, '3').map((item) => item.name),
['Episode 3'],
);
});
test('a range keeps the episodes inside it, in either order', () => {
assert.deepEqual(
filterEpisodes(SEASON, '12-18').map((item) => item.number),
[12, 12.5, 18],
);
assert.deepEqual(
filterEpisodes(SEASON, '18 12').map((item) => item.number),
[12, 12.5, 18],
);
});
test('a range skips episodes the source gave no number', () => {
assert.deepEqual(filterEpisodes([episode(null, 'OVA')], '1-99'), []);
});
test('anything else is a case-insensitive substring of the name', () => {
assert.deepEqual(
filterEpisodes(SEASON, 'home').map((item) => item.number),
[12],
);
assert.deepEqual(
filterEpisodes(SEASON, 'BEACH').map((item) => item.name),
['OVA: Beach Special'],
);
assert.deepEqual(filterEpisodes(SEASON, 'nothing here'), []);
});