Compare commits

...

6 Commits

Author SHA1 Message Date
sudacode 8b0ef662bc chore: remove shipped changelog entries and demo asset
- Drop changes/kiku-field-grouping-modal-wiring.md (landed in #138)
- Drop changes/known-word-partial-reading-highlight.md (landed in #142)
- Remove assets/minecard.mkv demo video
2026-07-07 00:18:48 -07:00
sudacode 0a58c20ad7 fix(stats): start stats server on background app launch (#144) 2026-07-07 00:16:57 -07:00
sudacode 38ddb29aa0 feat(anki): reading-aware known-word matching (cache v3) (#142) 2026-07-07 00:13:10 -07:00
sudacode 8b9a70c5a6 feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips (#140) 2026-07-06 23:52:43 -07:00
sudacode 48a084914a feat(branding): replace app icon with contributed pixel-art set (#141) 2026-07-06 23:31:19 -07:00
sudacode a042b04357 fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138) 2026-07-06 22:13:14 -07:00
82 changed files with 2733 additions and 334 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed never-mined compound words (e.g. 待ち合わせてる) being highlighted green as known: subtitle tokens now carry complete readings instead of kanji-only furigana joins, and the known-word reading fallback rejects readings that don't cover the token surface. Stored word readings in the stats database are no longer truncated for new lines.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: branding
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
+1 -1
View File
@@ -569,7 +569,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
+1 -1
View File
@@ -95,7 +95,7 @@ Stats server config lives under `stats`:
- `toggleKey` is overlay-local, not a system-wide shortcut.
- `serverPort` controls the localhost dashboard URL.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -569,7 +569,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -703,8 +703,8 @@ export class AnkiIntegration {
});
}
isKnownWord(text: string): boolean {
return this.knownWordCache.isKnownWord(text);
isKnownWord(text: string, reading?: string): boolean {
return this.knownWordCache.isKnownWord(text, reading);
}
getKnownWordMatchMode(): NPlusOneMatchMode {
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
const deleted: number[][] = [];
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
const statuses: string[] = [];
const osdMessages: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{
keepNoteId: number;
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
statuses.push(message);
},
showNotification: async () => undefined,
showOsdNotification: () => undefined,
showOsdNotification: (message: string) => {
osdMessages.push(message);
},
logError: () => undefined,
logInfo: () => undefined,
truncateSentence: (value: string) => value,
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
addedTags,
rememberedMerges,
statuses,
osdMessages,
mergeCalls,
setManualChoice: (choice: typeof manualChoice) => {
manualChoice = choice;
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
assert.equal(harness.updates.length, 0);
});
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
assert.equal(harness.updates.length, 0);
});
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
});
harness.deps.client.notesInfo = async () => [];
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
});
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
if (!originalNotesInfo || originalNotesInfo.length === 0) {
// handleManual owns all user-facing notifications; callers must not re-notify on false.
this.deps.showOsdNotification('Field grouping failed: original card not found');
return false;
}
+4 -2
View File
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
assert.deepEqual(harness.manualCalls, []);
});
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
const harness = createHarness({
kikuFieldGrouping: 'manual',
noteIds: [9],
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
expression: 'word-9',
},
]);
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
});
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
+3 -4
View File
@@ -156,15 +156,14 @@ export class FieldGroupingService {
);
return;
}
const handled = await this.deps.handleFieldGroupingManual(
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
// unavailable, failed) — re-notifying on a false return here duplicated them.
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
if (!handled) {
this.deps.showOsdNotification('Field grouping cancelled');
}
});
} catch (error) {
log.error('Error triggering field grouping:', (error as Error).message);
+251 -7
View File
@@ -108,6 +108,55 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
// v2 states carry no readings, so they load usable but stale to trigger a
// prompt upgrade refresh.
assert.equal(
(
manager as unknown as {
getMsUntilNextRefresh: () => number;
}
).getMsUntilNextRefresh(),
0,
);
} finally {
Date.now = originalDateNow;
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
refreshMinutes: 60,
},
};
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
const originalDateNow = Date.now;
try {
Date.now = () => 120_000;
fs.writeFileSync(
statePath,
JSON.stringify({
version: 3,
refreshedAtMs: 120_000,
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
notes: {
'1': [{ word: '猫', reading: 'ねこ' }],
},
}),
'utf-8',
);
manager.startLifecycle();
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('猫', 'ねこ'), true);
assert.equal(manager.isKnownWord('猫', 'びょう'), false);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
assert.equal(
(
manager as unknown as {
@@ -263,13 +312,11 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
words: string[];
notes?: Record<string, string[]>;
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
};
assert.equal(persisted.version, 2);
assert.deepEqual(persisted.words.sort(), ['鳥']);
assert.equal(persisted.version, 3);
assert.deepEqual(persisted.notes, {
'1': ['鳥'],
'1': [{ word: '鳥', reading: null }],
});
} finally {
cleanup();
@@ -392,10 +439,10 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
scope: string;
words: string[];
notes: Record<string, Array<{ word: string; reading: string | null }>>;
};
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
assert.deepEqual(persisted.words, ['猫']);
assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] });
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
@@ -648,3 +695,200 @@ test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately
cleanup();
}
});
test('KnownWordCacheManager disambiguates known words by note reading', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
'Word Reading': { value: 'ゆか' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床'), true);
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
assert.equal(manager.isKnownWord('床', 'ユカ'), true);
// Same spelling, different word (床/とこ "bed") must not match.
assert.equal(manager.isKnownWord('床', 'とこ'), false);
// Note readings stay matchable as kana words.
assert.equal(manager.isKnownWord('ゆか'), true);
assert.equal(manager.isKnownWord('とこ'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Expression',
},
knownWords: {
highlightEnabled: true,
decks: {
'Kaishi 1.5k': ['Word'],
},
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
'Word Reading': { value: 'ゆか' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
assert.equal(manager.isKnownWord('床', 'とこ'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager matches words without readings in any reading', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床'), true);
assert.equal(manager.isKnownWord('床', 'とこ'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: 'お 決[き]まり' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('お決まり'), true);
assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true);
assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager treats non-kana reading fields as words', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Reading: { value: '漢字' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('漢字'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Reading: { value: 'たべる' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('たべる'), true);
assert.equal(manager.isKnownWord('タベル'), true);
} finally {
cleanup();
}
});
+206 -101
View File
@@ -5,6 +5,16 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger';
import {
DEFAULT_KNOWN_WORD_READING_FIELDS,
KnownWordEntry,
convertKatakanaToHiragana,
isReadingFieldName,
knownWordEntryListsEqual,
normalizeKnownReadingForLookup,
normalizeKnownWordEntryList,
parseFuriganaAnnotatedText,
} from './known-word-entries';
const log = createLogger('anki').child('integration.known-word-cache');
@@ -79,7 +89,16 @@ interface KnownWordCacheStateV2 {
readonly notes: Record<string, string[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
interface KnownWordCacheStateV3 {
readonly version: 3;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
const NO_READING_KEY = '';
interface KnownWordCacheClient {
findNotes: (
@@ -106,9 +125,12 @@ type KnownWordQueryScope = {
export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = '';
private knownWords: Set<string> = new Set();
private wordReferenceCounts = new Map<string, number>();
private noteWordsById = new Map<number, string[]>();
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
// entries fail open: the word matches regardless of the token's reading.
private wordReadingCounts = new Map<string, Map<string, number>>();
// hiragana reading → note count, so kana tokens still match by reading alone.
private readingCounts = new Map<string, number>();
private noteEntriesById = new Map<number, KnownWordEntry[]>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false;
@@ -120,13 +142,28 @@ export class KnownWordCacheManager {
);
}
isKnownWord(text: string): boolean {
isKnownWord(text: string, reading?: string): boolean {
if (!this.isKnownWordCacheEnabled()) {
return false;
}
const normalized = this.normalizeKnownWordForLookup(text);
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
if (normalized.length === 0) {
return false;
}
const knownReadings = this.wordReadingCounts.get(normalized);
if (knownReadings && knownReadings.size > 0) {
const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
return (
normalizedReading.length === 0 ||
knownReadings.has(NO_READING_KEY) ||
knownReadings.has(normalizedReading)
);
}
return this.readingCounts.has(convertKatakanaToHiragana(normalized));
}
refresh(force = false): Promise<void> {
@@ -173,7 +210,7 @@ export class KnownWordCacheManager {
let didMutateCache = false;
const currentStateKey = this.getKnownWordCacheStateKey();
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0;
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
this.clearKnownWordCacheState();
}
if (!this.knownWordsStateKey) {
@@ -185,8 +222,8 @@ export class KnownWordCacheManager {
return didMutateCache;
}
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
const nextEntries = this.extractKnownWordEntriesFromNoteInfo(noteInfo, preferredFields);
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextEntries);
if (!changed) {
return didMutateCache;
}
@@ -198,7 +235,7 @@ export class KnownWordCacheManager {
log.info(
'Known-word cache updated in-session',
`noteId=${noteInfo.noteId}`,
`wordCount=${nextWords.length}`,
`wordCount=${nextEntries.length}`,
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
);
return true;
@@ -236,11 +273,11 @@ export class KnownWordCacheManager {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteWordsById.size === 0) {
if (this.noteEntriesById.size === 0) {
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
} else {
const currentNoteIdSet = new Set(currentNoteIds);
for (const noteId of Array.from(this.noteWordsById.keys())) {
for (const noteId of Array.from(this.noteEntriesById.keys())) {
if (!currentNoteIdSet.has(noteId)) {
this.removeNoteSnapshot(noteId);
}
@@ -251,7 +288,7 @@ export class KnownWordCacheManager {
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(
this.extractKnownWordEntriesFromNoteInfo(
noteInfo,
noteFieldsById.get(noteInfo.noteId),
),
@@ -266,7 +303,7 @@ export class KnownWordCacheManager {
log.info(
'Known-word cache refreshed',
`noteCount=${currentNoteIds.length}`,
`wordCount=${this.knownWords.size}`,
`wordCount=${this.wordReadingCounts.size}`,
);
} catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -291,7 +328,13 @@ export class KnownWordCacheManager {
private getDefaultKnownWordFields(): string[] {
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
return this.withDefaultReadingFields([configuredWordField, 'Word']);
}
// Reading fields are always probed (even when a deck configures explicit
// word fields) so entries can carry the reading their note teaches.
private withDefaultReadingFields(fields: string[]): string[] {
return [...new Set([...fields, ...DEFAULT_KNOWN_WORD_READING_FIELDS])];
}
private getKnownWordDecks(): string[] {
@@ -337,7 +380,9 @@ export class KnownWordCacheManager {
.filter((field) => field.length > 0),
),
];
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
return normalizedFields.length > 0
? this.withDefaultReadingFields(normalizedFields)
: this.getDefaultKnownWordFields();
}
const deckFields = selectedDeckEntry[1];
@@ -351,7 +396,7 @@ export class KnownWordCacheManager {
),
];
if (normalizedFields.length > 0) {
return normalizedFields;
return this.withDefaultReadingFields(normalizedFields);
}
}
@@ -382,7 +427,10 @@ export class KnownWordCacheManager {
: [];
scopes.push({
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
fields:
normalizedFields.length > 0
? this.withDefaultReadingFields(normalizedFields)
: this.getDefaultKnownWordFields(),
});
}
if (scopes.length > 0) {
@@ -490,7 +538,7 @@ export class KnownWordCacheManager {
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
this.extractKnownWordEntriesFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
);
}
}
@@ -519,56 +567,77 @@ export class KnownWordCacheManager {
return noteInfos;
}
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
const normalizedWords = normalizeKnownWordList(nextWords);
const previousWords = this.noteWordsById.get(noteId) ?? [];
if (knownWordListsEqual(previousWords, normalizedWords)) {
private replaceNoteSnapshot(noteId: number, nextEntries: KnownWordEntry[]): boolean {
const normalizedEntries = normalizeKnownWordEntryList(nextEntries);
const previousEntries = this.noteEntriesById.get(noteId) ?? [];
if (knownWordEntryListsEqual(previousEntries, normalizedEntries)) {
return false;
}
this.removeWordsFromCounts(previousWords);
if (normalizedWords.length > 0) {
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
this.removeEntriesFromCounts(previousEntries);
if (normalizedEntries.length > 0) {
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
} else {
this.noteWordsById.delete(noteId);
this.noteEntriesById.delete(noteId);
}
return true;
}
private removeNoteSnapshot(noteId: number): void {
const previousWords = this.noteWordsById.get(noteId);
if (!previousWords) {
const previousEntries = this.noteEntriesById.get(noteId);
if (!previousEntries) {
return;
}
this.noteWordsById.delete(noteId);
this.removeWordsFromCounts(previousWords);
this.noteEntriesById.delete(noteId);
this.removeEntriesFromCounts(previousEntries);
}
private addWordsToCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
this.wordReferenceCounts.set(word, nextCount);
this.knownWords.add(word);
private addEntriesToCounts(entries: KnownWordEntry[]): void {
for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY;
let readings = this.wordReadingCounts.get(entry.word);
if (!readings) {
readings = new Map();
this.wordReadingCounts.set(entry.word, readings);
}
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
if (entry.reading) {
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
}
}
}
private removeWordsFromCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
if (nextCount > 0) {
this.wordReferenceCounts.set(word, nextCount);
} else {
this.wordReferenceCounts.delete(word);
this.knownWords.delete(word);
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY;
const readings = this.wordReadingCounts.get(entry.word);
if (readings) {
const nextCount = (readings.get(readingKey) ?? 0) - 1;
if (nextCount > 0) {
readings.set(readingKey, nextCount);
} else {
readings.delete(readingKey);
if (readings.size === 0) {
this.wordReadingCounts.delete(entry.word);
}
}
}
if (entry.reading) {
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
if (nextReadingCount > 0) {
this.readingCounts.set(entry.reading, nextReadingCount);
} else {
this.readingCounts.delete(entry.reading);
}
}
}
}
private clearInMemoryState(): void {
this.knownWords = new Set();
this.wordReferenceCounts = new Map();
this.noteWordsById = new Map();
this.wordReadingCounts = new Map();
this.readingCounts = new Map();
this.noteEntriesById = new Map();
this.knownWordsLastRefreshedAtMs = 0;
}
@@ -601,32 +670,48 @@ export class KnownWordCacheManager {
}
this.clearInMemoryState();
if (parsed.version === 3) {
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedEntries = normalizeKnownWordEntryList(entries);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
return;
}
if (parsed.version === 2) {
// Older states have no readings; load them reading-less (fail-open,
// matching the old behavior) but leave the cache marked stale so the
// next refresh upgrades entries with readings from Anki.
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedWords = normalizeKnownWordList(words);
if (normalizedWords.length === 0) {
const normalizedEntries = normalizeKnownWordEntryList(
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
);
if (normalizedEntries.length === 0) {
continue;
}
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
}
} else {
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (!normalized) {
continue;
}
this.knownWords.add(normalized);
this.wordReferenceCounts.set(normalized, 1);
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsStateKey = parsed.scope;
return;
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
// v1 has no per-note snapshots to convert; refetch from Anki.
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
} catch (error) {
log.warn('Failed to load known-word cache state:', (error as Error).message);
this.clearInMemoryState();
@@ -636,18 +721,17 @@ export class KnownWordCacheManager {
private persistKnownWordCacheState(): void {
try {
const notes: Record<string, string[]> = {};
for (const [noteId, words] of this.noteWordsById.entries()) {
if (words.length > 0) {
notes[String(noteId)] = words;
const notes: Record<string, KnownWordEntry[]> = {};
for (const [noteId, entries] of this.noteEntriesById.entries()) {
if (entries.length > 0) {
notes[String(noteId)] = entries;
}
}
const state: KnownWordCacheStateV2 = {
version: 2,
const state: KnownWordCacheStateV3 = {
version: 3,
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsStateKey,
words: Array.from(this.knownWords),
notes,
};
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
@@ -659,14 +743,18 @@ export class KnownWordCacheManager {
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1 && candidate.version !== 2) return false;
if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
return false;
}
if (candidate.version === 2) {
if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (candidate.version !== 3) {
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false;
}
}
if (candidate.version === 2 || candidate.version === 3) {
if (
typeof candidate.notes !== 'object' ||
candidate.notes === null ||
@@ -674,10 +762,18 @@ export class KnownWordCacheManager {
) {
return false;
}
const isValidNoteEntry =
candidate.version === 2
? (entry: unknown): boolean => typeof entry === 'string'
: (entry: unknown): boolean =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as KnownWordEntry).word === 'string' &&
((entry as KnownWordEntry).reading === null ||
typeof (entry as KnownWordEntry).reading === 'string');
if (
!Object.values(candidate.notes as Record<string, unknown>).every(
(entry) =>
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
)
) {
return false;
@@ -686,11 +782,12 @@ export class KnownWordCacheManager {
return true;
}
private extractNormalizedKnownWordsFromNoteInfo(
private extractKnownWordEntriesFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
preferredFields = this.getConfiguredFields(),
): string[] {
const words: string[] = [];
): KnownWordEntry[] {
const wordValues: string[] = [];
let noteReading: string | null = null;
for (const preferredField of preferredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
if (!fieldName) continue;
@@ -698,12 +795,36 @@ export class KnownWordCacheManager {
const raw = noteInfo.fields[fieldName]?.value;
if (!raw) continue;
const normalized = this.normalizeKnownWordForLookup(raw);
if (normalized) {
words.push(normalized);
const cleaned = this.normalizeRawKnownWordValue(raw);
if (!cleaned) continue;
if (isReadingFieldName(preferredField)) {
const normalizedReading = normalizeKnownReadingForLookup(cleaned);
if (normalizedReading) {
noteReading ??= normalizedReading;
continue;
}
// Non-kana content in a reading field: treat it as a word so decks
// with repurposed reading fields keep matching (fail-open).
}
wordValues.push(cleaned);
}
return normalizeKnownWordList(words);
const entries: KnownWordEntry[] = [];
for (const value of wordValues) {
const parsed = parseFuriganaAnnotatedText(value);
const word = parsed.text.trim().toLowerCase();
if (!word) continue;
const inlineReading = parsed.reading ? normalizeKnownReadingForLookup(parsed.reading) : '';
entries.push({ word, reading: inlineReading || noteReading });
}
// Kana-only notes (reading field but no word field) stay matchable.
if (entries.length === 0 && noteReading) {
entries.push({ word: noteReading, reading: noteReading });
}
return normalizeKnownWordEntryList(entries);
}
private normalizeRawKnownWordValue(value: string): string {
@@ -718,22 +839,6 @@ export class KnownWordCacheManager {
}
}
function normalizeKnownWordList(words: string[]): string[] {
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
}
function knownWordListsEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact;
+113
View File
@@ -0,0 +1,113 @@
// Known-word cache entries pair a word with the reading its Anki note teaches,
// so spelling collisions across readings (e.g. 床/ゆか vs 床/とこ) don't mark
// unrelated words as known. reading === null means the note carries no usable
// reading and the word matches in any reading (fail-open).
export interface KnownWordEntry {
word: string;
reading: string | null;
}
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
const FURIGANA_SEGMENT_PATTERN = /([^\s \[\]]*)\[([^\]]*)\]/g;
const FURIGANA_BRACKET_PATTERN = /\[[^\]]*\]/g;
const WHITESPACE_PATTERN = /[\s ]+/g;
// Reading-bearing field names probed on every known-word note, in addition to
// any configured word fields (covers Kaishi's "Word Reading" and Lapis's
// "ExpressionReading" note types).
export const DEFAULT_KNOWN_WORD_READING_FIELDS = [
'Reading',
'Word Reading',
'ExpressionReading',
'Expression Reading',
];
export function isReadingFieldName(fieldName: string): boolean {
return /reading/i.test(fieldName);
}
export function convertKatakanaToHiragana(text: string): string {
let converted = '';
for (const char of text) {
const code = char.codePointAt(0);
if (code !== undefined && code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
converted += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
continue;
}
converted += char;
}
return converted;
}
function isHiraganaReadingChar(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
return (code >= 0x3041 && code <= 0x309f) || code === 0x30fc;
}
// Splits Anki furigana syntax (`床[とこ]`, `お 決[き]まり`) into base text and
// reading. Values without brackets pass through with reading null.
export function parseFuriganaAnnotatedText(value: string): {
text: string;
reading: string | null;
} {
if (!value.includes('[')) {
return { text: value, reading: null };
}
const text = value.replace(FURIGANA_BRACKET_PATTERN, '').replace(WHITESPACE_PATTERN, '');
const reading = value.replace(FURIGANA_SEGMENT_PATTERN, '$2').replace(WHITESPACE_PATTERN, '');
return { text, reading: reading.length > 0 ? reading : null };
}
// Returns the hiragana-normalized reading, or '' when the value is not a
// plausible kana reading (callers fall back to text-only matching then).
export function normalizeKnownReadingForLookup(value: string): string {
const parsed = parseFuriganaAnnotatedText(value.trim());
const candidate = (parsed.reading ?? parsed.text).trim();
if (!candidate) {
return '';
}
const hiragana = convertKatakanaToHiragana(candidate);
for (const char of hiragana) {
if (!isHiraganaReadingChar(char)) {
return '';
}
}
return hiragana;
}
export function makeKnownWordEntryKey(entry: KnownWordEntry): string {
return `${entry.word}\u0000${entry.reading ?? ''}`;
}
export function normalizeKnownWordEntryList(entries: KnownWordEntry[]): KnownWordEntry[] {
const byKey = new Map<string, KnownWordEntry>();
for (const entry of entries) {
const word = entry.word.trim();
if (!word) {
continue;
}
const reading = entry.reading?.trim() || null;
const normalized: KnownWordEntry = { word, reading };
byKey.set(makeKnownWordEntryKey(normalized), normalized);
}
return [...byKey.values()].sort((left, right) =>
makeKnownWordEntryKey(left).localeCompare(makeKnownWordEntryKey(right)),
);
}
export function knownWordEntryListsEqual(left: KnownWordEntry[], right: KnownWordEntry[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (makeKnownWordEntryKey(left[index]!) !== makeKnownWordEntryKey(right[index]!)) {
return false;
}
}
return true;
}
@@ -317,7 +317,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'object',
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
description:
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.',
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.',
},
{
path: 'ankiConnect.isKiku.fieldGrouping',
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']);
assert.deepEqual(seenArgs, ['90d', 'month', true]);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
});
@@ -747,7 +747,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['365d', 'month']);
assert.deepEqual(seenArgs, ['365d', 'month', true]);
});
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day']);
assert.deepEqual(seenArgs, ['30d', 'day', true]);
});
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', 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=30d&groupBy=day&fillEmpty=false',
);
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day', false]);
});
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
+40
View File
@@ -374,6 +374,46 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
);
});
test('handleCliCommand ensures background stats server for initial --start --background', () => {
const ensured: number[] = [];
const { deps } = createDeps({
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
assert.equal(ensured.length, 1);
});
test('handleCliCommand ensures background stats server for second-instance --start --background', () => {
const ensured: number[] = [];
const { deps } = createDeps({
isOverlayRuntimeInitialized: () => true,
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps);
assert.equal(ensured.length, 1);
});
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
const ensured: number[] = [];
const { deps } = createDeps({
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true }), 'initial', deps);
assert.equal(ensured.length, 0);
});
test('handleCliCommand forces setup open for second-instance setup command', () => {
const { deps, calls } = createDeps();
+7
View File
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
ensureBackgroundStatsServer?: () => void;
printHelp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
@@ -185,6 +186,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
ensureBackgroundStatsServer?: () => void;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
@@ -299,6 +301,7 @@ export function createCliCommandDepsRuntime(
runUpdateCommand: options.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: options.app.ensureBackgroundStatsServer,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
@@ -393,6 +396,10 @@ export function handleCliCommand(
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
}
if (args.start && args.background) {
deps.ensureBackgroundStatsServer?.();
}
if (args.sessionAction) {
dispatchCliSessionAction(
args.sessionAction,
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
restoreOnModalClose: 'kiku',
preferModalWindow: true,
},
// Abandonment also asks the renderer hosting the modal to close its dialog.
{
channel: 'kiku:field-grouping-cancel',
restoreOnModalClose: undefined,
preferModalWindow: true,
},
],
);
assert.deepEqual(waitCalls, [
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
assert.deepEqual(warnings, [
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
]);
assert.deepEqual(closed, ['kiku']);
// Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the
// callback's abandonment cleanup. The real runtime guards this via the restore set, so
// the duplicate is a harmless no-op.
assert.deepEqual(closed, ['kiku', 'kiku']);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => {
// The field grouping modal must run the same prerequisites as every other modal
// (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay
// window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv.
const order: string[] = [];
const originalSetTimeout = globalThis.setTimeout;
// The modal acknowledges open below, so the callback stays pending and arms its response
// timeout; stub the timer so no real 90s handle leaks into the test runner.
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
try {
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
ensureOverlayStartupPrereqs: () => order.push('prereqs'),
ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'),
sendToVisibleOverlay: (channel) => {
order.push(`send:${channel}`);
return true;
},
waitForModalOpen: async () => {
order.push('wait');
return true;
},
});
// Do not await: an acknowledged modal leaves the choice pending until the user responds.
void runtime.createFieldGroupingCallback()({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
// Let the async send + modal-open ack chain run.
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
assert.deepEqual(order, [
'prereqs',
'windows-ready',
'send:kiku:field-grouping-request',
'wait',
]);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
+32 -2
View File
@@ -1,4 +1,5 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
interface WindowLike {
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
handleOverlayModalClosed?: (modal: T) => void;
logWarn?: (message: string) => void;
/**
* Prepare the overlay runtime and (re)create the visible overlay window before opening the
* modal — the same prerequisites every other modal runs via `openOverlayHostedModal`. Without
* them the field grouping modal can open with no sibling overlay window present, and on
* Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other
* modals do.
*/
ensureOverlayStartupPrereqs?: () => void;
ensureOverlayWindowsReadyForVisibilityActions?: () => void;
sendToVisibleOverlay?: (
channel: string,
payload?: unknown,
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
data: KikuFieldGroupingRequestData,
): Promise<boolean> => {
const kikuModal = 'kiku' as T;
const sendOpen = (): boolean =>
sendToVisibleOverlay('kiku:field-grouping-request', data, {
const sendOpen = (): boolean => {
// Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime
// and visible overlay window exist before handing off to the dedicated modal window.
options.ensureOverlayStartupPrereqs?.();
options.ensureOverlayWindowsReadyForVisibilityActions?.();
return sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: kikuModal,
preferModalWindow: true,
});
};
if (!options.waitForModalOpen) {
return sendOpen();
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
return opened;
};
const dismissModalUi = (): void => {
const kikuModal = 'kiku' as T;
// Best-effort: tell the renderer hosting the modal to close its dialog. When the modal
// lives in the dedicated modal window this is redundant with the teardown below, but it
// also covers the case where the request was routed into the visible overlay.
sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, {
preferModalWindow: true,
});
// Reliable teardown of main-side modal state (restore set, main-overlay passthrough,
// dedicated modal window). This is what recovers the frozen overlay when a grouping
// request times out or fails to reach a visible modal.
options.handleOverlayModalClosed?.(kikuModal);
};
const createFieldGroupingCallback = (): ((
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>) => {
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
setResolver: options.setResolver,
sendToVisibleOverlay,
sendKikuFieldGroupingRequest,
dismissModalUi,
});
};
+272
View File
@@ -0,0 +1,272 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { createFieldGroupingCallback } from './field-grouping';
function makeRequestData(): KikuFieldGroupingRequestData {
return {
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'a',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
};
}
/**
* Mirrors how main stores the resolver: it wraps the callback's resolver in a
* sequence-guarded closure, so the value read back is never identity-equal to the
* callback's own `finish`. The old `getResolver() === finish` clear-guard therefore
* never matched and leaked the resolver, wedging every later grouping attempt.
*/
function createWrappedResolverStore() {
let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null;
return {
getResolver: () => stored,
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
stored = resolver ? (choice) => resolver(choice) : null;
},
respond: (choice: KikuFieldGroupingChoice) => {
stored?.(choice);
},
};
}
test('field grouping callback clears the wrapped resolver after a renderer response', async () => {
const store = createWrappedResolverStore();
let visible = false;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => visible,
setVisibleOverlayVisible: (next) => {
visible = next;
},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
});
const pending = callback(makeRequestData());
await Promise.resolve();
assert.notEqual(store.getResolver(), null);
const choice: KikuFieldGroupingChoice = {
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
};
store.respond(choice);
assert.deepEqual(await pending, choice);
assert.equal(store.getResolver(), null);
});
test('field grouping callback does not reject the next request after a response', async () => {
const store = createWrappedResolverStore();
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
});
const first = callback(makeRequestData());
await Promise.resolve();
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
const firstChoice = await first;
assert.equal(firstChoice.cancelled, false);
// The second attempt must reach the renderer, not short-circuit to an instant cancel.
const second = callback(makeRequestData());
await Promise.resolve();
assert.notEqual(store.getResolver(), null);
store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false });
const secondChoice = await second;
assert.equal(secondChoice.cancelled, false);
assert.equal(secondChoice.keepNoteId, 2);
});
test('field grouping callback dismisses the modal UI when the send fails', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
dismissed += 1;
},
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test('field grouping callback handles modal dismiss failures on send failure', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback handles modal dismiss failures on timeout', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback reports modal dismiss failures', async () => {
const store = createWrappedResolverStore();
const errors: unknown[] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args);
};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
});
await callback(makeRequestData());
assert.equal(errors.length, 1);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback dismisses the modal UI when the response times out', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 10000,
});
const pending = callback(makeRequestData());
await Promise.resolve();
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
await pending;
assert.equal(dismissed, 0);
});
test('field grouping callback rejects a concurrent request while one is pending', async () => {
const store = createWrappedResolverStore();
let sends = 0;
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => {
sends += 1;
return true;
},
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 10000,
});
const first = callback(makeRequestData());
await Promise.resolve();
assert.equal(sends, 1);
const second = await callback(makeRequestData());
assert.equal(second.cancelled, true);
assert.equal(sends, 1);
assert.equal(dismissed, 0);
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
await first;
});
+42 -29
View File
@@ -1,21 +1,41 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
export function createFieldGroupingCallback(options: {
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
/**
* Tears down the modal UI when the request is abandoned without a renderer response
* (send failure or response timeout). Without this the dedicated modal window, its
* restore-set entry, and the forced main-overlay passthrough stay orphaned — which on
* Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive.
*/
dismissModalUi?: () => void;
responseTimeoutMs?: number;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
const cancelledChoice = (): KikuFieldGroupingChoice => ({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
const dismissModalUi = (): void => {
try {
options.dismissModalUi?.();
} catch (error) {
console.error('Failed to dismiss Kiku field grouping modal UI:', error);
}
};
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => {
if (options.getResolver()) {
resolve({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
resolve(cancelledChoice());
return;
}
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (choice: KikuFieldGroupingChoice): void => {
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
if (settled) return;
settled = true;
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
if (options.getResolver() === finish) {
options.setResolver(null);
}
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
// resolver, so an identity check against `finish` never matches and would leak
// the resolver — blocking every later grouping attempt with an instant cancel.
options.setResolver(null);
resolve(choice);
// When abandoned without a renderer response, tear down the modal window/state
// that the request path spun up. A normal response already routes through the
// renderer's close handler, so only the abandon paths need this.
if (abandoned) {
dismissModalUi();
}
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(false);
}
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
(sent) => {
if (settled) return;
if (!sent) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
return;
}
timeout = setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
}
}, 90000);
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
},
() => {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
},
);
});
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildHyprlandPlacementDispatches,
ensureHyprlandWindowFloatingByTitle,
ensureHyprlandWindowFloatingByTitleWithStatus,
findHyprlandWindowForPlacement,
hasHyprlandWindowPlacementBoundsMismatch,
shouldAttemptHyprlandWindowPlacement,
@@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
);
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => {
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: {},
execFileSync: (() => {
throw new Error('should not query the compositor when placement is not applicable');
}) as never,
});
assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false });
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => {
// The window has not been mapped by the compositor yet, so no client matches. Callers use
// this to keep retrying until the modal is actually placed above fullscreen mpv.
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 999,
execFileSync: ((command: string, args: string[]) => {
if (args.join(' ') === '-j clients') {
return JSON.stringify([]);
}
return '';
}) as never,
});
assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false });
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => {
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
execFileSync: ((command: string, args: string[]) => {
if (args.join(' ') === '-j clients') {
return JSON.stringify([
{
address: '0xmatch',
pid: 456,
title: 'SubMiner Overlay Modal',
mapped: true,
floating: false,
pinned: false,
},
]);
}
if (args.join(' ') === '-j status') {
return JSON.stringify({ configProvider: 'lua' });
}
return '';
}) as never,
});
assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true });
});
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
const calls: unknown[][] = [];
const placed = ensureHyprlandWindowFloatingByTitle({
+31 -4
View File
@@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: {
}
}
/**
* Placement outcome for a single reconcile attempt.
* - `applicable`: false when not running under Hyprland (nothing to retry).
* - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps
* asynchronously after `show()`, so early attempts can miss it — callers retry while this
* is false so the modal actually gets promoted above fullscreen mpv instead of staying
* invisible.
* - `dispatched`: whether any hyprctl dispatch was issued.
*/
export interface HyprlandPlacementStatus {
applicable: boolean;
clientFound: boolean;
dispatched: boolean;
}
export function ensureHyprlandWindowFloatingByTitle(options: {
title: string;
bounds?: HyprlandPlacementBounds | null;
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
promote?: boolean;
execFileSync?: ExecFileSync;
}): boolean {
return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched;
}
export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
title: string;
bounds?: HyprlandPlacementBounds | null;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
pid?: number;
promote?: boolean;
execFileSync?: ExecFileSync;
}): HyprlandPlacementStatus {
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
return false;
return { applicable: false, clientFound: false, dispatched: false };
}
const run = options.execFileSync ?? execFileSync;
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
title: options.title,
});
if (!client) {
return false;
return { applicable: true, clientFound: false, dispatched: false };
}
const configProvider = detectHyprlandConfigProvider(run);
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
// Best-effort reconciliation: the initial placement dispatches already ran.
}
}
return dispatches.length > 0;
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
} catch {
return false;
return { applicable: true, clientFound: false, dispatched: false };
}
}
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
async getTrendsDashboard(
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day',
fillEmptyBuckets = true,
): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy);
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
}
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
@@ -1003,6 +1003,121 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
}
});
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/30d-zerofill.mkv', {
canonicalTitle: '30d Zero Fill',
sourcePath: '/tmp/30d-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
// Local "today" for the mocked clock is epoch day 20513. Seed only two
// active days inside the 30-day window, leaving the rest empty.
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const dashboard = getTrendsDashboard(db, '30d', 'day');
// Exactly 30 calendar days, not just the two active ones.
assert.equal(dashboard.activity.watchTime.length, 30);
// Most recent day carries its seeded value; a gap day reads zero.
assert.equal(dashboard.activity.watchTime.at(-1)?.value, 30);
assert.equal(dashboard.activity.watchTime.at(-2)?.value, 0);
// Only the two seeded days contribute to the totals.
const nonZeroDays = dashboard.activity.watchTime.filter((point) => point.value > 0);
assert.equal(nonZeroDays.length, 2);
// Cumulative watch time still tops out at the sum of both active days.
assert.equal(dashboard.progress.watchTime.at(-1)?.value, 75);
// Every day-bucketed series shares the same 30-day axis.
assert.equal(dashboard.progress.episodes.length, 30);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/no-zerofill.mkv', {
canonicalTitle: 'No Zero Fill',
sourcePath: '/tmp/no-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const filled = getTrendsDashboard(db, '30d', 'day', true);
assert.equal(filled.activity.watchTime.length, 30);
const compact = getTrendsDashboard(db, '30d', 'day', false);
// Only the two active days survive; no zero-filled gaps.
assert.equal(compact.activity.watchTime.length, 2);
assert.ok(compact.activity.watchTime.every((point) => point.value > 0));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test(
'getTrendsDashboard supports 365d range and caps day buckets at 365',
{ timeout: 20_000 },
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2);
// The 30d window (mocked now Mar 1 → cutoff Jan 31) spans three calendar
// months, so January is zero-filled rather than dropped.
assert.equal(dashboard.activity.watchTime.length, 3);
assert.equal(dashboard.activity.watchTime[0]?.value, 0);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
@@ -205,6 +205,59 @@ function resolveTrendAnimeTitle(value: {
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
}
// Ordered list of bucket keys (epoch days or YYYYMM months) covering the
// selected range, so charts render one point per calendar bucket instead of
// silently collapsing to only the buckets that have activity. Returns null for
// the unbounded "all" range, where builders fall back to the buckets in data.
function buildBucketAxis(
db: DatabaseSync,
groupBy: TrendGroupBy,
cutoffMs: string | null,
referenceMs: string,
): number[] | null {
if (cutoffMs === null) {
return null;
}
if (groupBy === 'month') {
const startKey = getLocalMonthKey(db, cutoffMs);
const endKey = getLocalMonthKey(db, referenceMs);
const keys: number[] = [];
let year = Math.floor(startKey / 100);
let month = startKey % 100;
const endYear = Math.floor(endKey / 100);
const endMonth = endKey % 100;
while (year < endYear || (year === endYear && month <= endMonth)) {
keys.push(year * 100 + month);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return keys;
}
const startDay = getLocalEpochDay(db, cutoffMs);
const endDay = getLocalEpochDay(db, referenceMs);
const keys: number[] = [];
for (let day = startDay; day <= endDay; day += 1) {
keys.push(day);
}
return keys;
}
// Project a bucket→value map onto the axis, filling absent buckets with zero.
// Without an axis (the "all" range) it falls back to the populated buckets in
// ascending order, preserving the previous behaviour.
function fillAxisPoints(
axis: number[] | null,
valueByBucket: Map<number, number>,
): TrendChartPoint[] {
const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right);
return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 }));
}
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
let sum = 0;
return points.map((point) => {
@@ -216,7 +269,7 @@ function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
});
}
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
const byKey = new Map<
number,
{ activeMin: number; cards: number; words: number; sessions: number }
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
byKey.set(rollup.rollupDayOrMonth, existing);
}
return Array.from(byKey.entries())
.sort(([left], [right]) => left - right)
.map(([key, value]) => ({
const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
return keys.map((key) => {
const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
return {
label: makeTrendLabel(key),
activeMin: Math.round(value.activeMin),
cards: value.cards,
words: value.words,
sessions: value.sessions,
}));
};
});
}
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
}));
}
function dayLabel(epochDay: number): string {
const { month, day } = dayPartsFromEpochDay(epochDay);
return `${MONTH_NAMES[month - 1]} ${day}`;
}
function buildSessionSeriesByDay(
function buildSessionSeries(
sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
getValue: (session: TrendSessionMetricRow) => number,
axis: number[] | null,
): TrendChartPoint[] {
const byDay = new Map<number, number>();
const byBucket = new Map<number, number>();
for (const session of sessions) {
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session));
}
return Array.from(byDay.entries())
.sort(([left], [right]) => left - right)
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
}
function buildSessionSeriesByMonth(
sessions: TrendSessionMetricRow[],
getValue: (session: TrendSessionMetricRow) => number,
): TrendChartPoint[] {
const byMonth = new Map<number, number>();
for (const session of sessions) {
byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
return fillAxisPoints(axis, byBucket);
}
function buildLookupsPerHundredWords(
sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
axis: number[] | null,
): TrendChartPoint[] {
const lookupsByBucket = new Map<number, number>();
const wordsByBucket = new Map<number, number>();
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
);
}
return Array.from(lookupsByBucket.entries())
.sort(([left], [right]) => left - right)
.map(([bucketKey, lookups]) => {
const words = wordsByBucket.get(bucketKey) ?? 0;
return {
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
};
});
const ratioByBucket = new Map<number, number>();
for (const [bucketKey, lookups] of lookupsByBucket) {
const words = wordsByBucket.get(bucketKey) ?? 0;
ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
}
return fillAxisPoints(axis, ratioByBucket);
}
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
return result;
}
function buildEpisodesPerDayFromDailyRollups(
function buildEpisodesSeriesFromRollups(
rollups: ImmersionSessionRollupRow[],
axis: number[] | null,
): TrendChartPoint[] {
const byDay = new Map<number, Set<number>>();
const byBucket = new Map<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>();
const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byDay.set(rollup.rollupDayOrMonth, videoIds);
byBucket.set(rollup.rollupDayOrMonth, videoIds);
}
return Array.from(byDay.entries())
.sort(([left], [right]) => left - right)
.map(([epochDay, videoIds]) => ({
label: dayLabel(epochDay),
value: videoIds.size,
}));
}
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
const byMonth = new Map<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byMonth.set(rollup.rollupDayOrMonth, videoIds);
const counts = new Map<number, number>();
for (const [bucketKey, videoIds] of byBucket) {
counts.set(bucketKey, videoIds.size);
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, videoIds]) => ({
label: makeTrendLabel(monthKey),
value: videoIds.size,
}));
return fillAxisPoints(axis, counts);
}
function getTrendSessionMetrics(
@@ -639,7 +655,11 @@ function getTrendSessionMetrics(
}));
}
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
function buildNewWordsPerDay(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
wordCount: number;
}>;
return rows.map((row) => ({
label: dayLabel(row.epochDay),
value: row.wordCount,
}));
const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
return fillAxisPoints(axis, byBucket);
}
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
function buildNewWordsPerMonth(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
wordCount: number;
}>;
return rows.map((row) => ({
label: makeTrendLabel(row.monthKey),
value: row.wordCount,
}));
const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
return fillAxisPoints(axis, byBucket);
}
export function getTrendsDashboard(
db: DatabaseSync,
range: TrendRange = '30d',
groupBy: TrendGroupBy = 'day',
fillEmptyBuckets = true,
): TrendsDashboardQueryResult {
const dayLimit = getTrendDayLimit(range);
const monthlyLimit = getTrendMonthlyLimit(db, range);
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
const useMonthlyBuckets = groupBy === 'month';
const dailyRollups = getDailyRollups(db, dayLimit);
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
// A null axis makes the builders fall back to only the buckets present in the
// data; the contiguous axis zero-fills every calendar bucket in the window.
const bucketAxis = fillEmptyBuckets
? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp())
: null;
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
const sessions = getTrendSessionMetrics(db, cutoffMs);
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
dailyRollups.map((rollup) => rollup.videoId),
);
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
const efficiency = buildEfficiencyRates(aggregatedRows);
const activity = {
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
useMonthlyBuckets
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
buildEpisodesSeriesFromRollups(
useMonthlyBuckets ? monthlyRollups : dailyRollups,
bucketAxis,
),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
cardsPerHour: efficiency.cardsPerHour,
readingSpeed: efficiency.readingSpeed,
},
+2
View File
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean;
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
dismissModalUi?: () => void;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
getResolver: options.getResolver,
setResolver: options.setResolver,
dismissModalUi: options.dismissModalUi,
sendRequestToVisibleOverlay: (data) =>
options.sendKikuFieldGroupingRequest
? options.sendKikuFieldGroupingRequest(data)
+8 -6
View File
@@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window';
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
export interface OverlayManager {
getMainWindow: () => BrowserWindow | null;
@@ -9,7 +10,7 @@ export interface OverlayManager {
setModalWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: () => BrowserWindow | null;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getOverlayWindows: () => BrowserWindow[];
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
let visibleOverlayVisible = false;
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
const updateWindowBounds = (
geometry: WindowGeometry,
window: BrowserWindow | null,
): HyprlandPlacementStatus => {
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
applyOverlayBounds(geometry, window, { promote });
return applyOverlayBounds(geometry, window, { promote });
};
return {
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
setOverlayWindowBounds: (geometry) => {
updateWindowBounds(geometry, mainWindow);
},
setModalWindowBounds: (geometry) => {
updateWindowBounds(geometry, modalWindow);
},
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
+10 -4
View File
@@ -9,7 +9,11 @@ import {
handleOverlayWindowBlurred,
type OverlayWindowKind,
} from './overlay-window-input';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
import {
ensureHyprlandWindowFloatingByTitle,
ensureHyprlandWindowFloatingByTitleWithStatus,
type HyprlandPlacementStatus,
} from './hyprland-window-placement';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
options: {
promote?: boolean;
} = {},
): void {
if (!geometry || !window || window.isDestroyed()) return;
): HyprlandPlacementStatus {
if (!geometry || !window || window.isDestroyed()) {
return { applicable: false, clientFound: false, dispatched: false };
}
const bounds = normalizeOverlayWindowBoundsForPlatform(
geometry,
process.platform,
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
window,
);
window.setBounds(bounds);
ensureHyprlandWindowFloatingByTitle({
return ensureHyprlandWindowFloatingByTitleWithStatus({
title: window.getTitle(),
bounds,
promote: options.promote,
+8 -1
View File
@@ -81,6 +81,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day';
}
// Defaults to true (zero-fill empty calendar buckets); only an explicit
// "false" opts into the compact, data-only view.
function parseTrendFillEmpty(raw: string | undefined): boolean {
return raw !== 'false';
}
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
if (!raw) return undefined;
const parsed = raw
@@ -685,7 +691,8 @@ export function createStatsApp(
app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
return c.json(await tracker.getTrendsDashboard(range, groupBy));
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
});
app.get('/api/stats/sessions', async (c) => {
+4 -3
View File
@@ -42,7 +42,7 @@ export interface TokenizerServiceDeps {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
isKnownWord: (text: string, reading?: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -77,7 +77,7 @@ export interface TokenizerDepsRuntimeOptions {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
isKnownWord: (text: string, reading?: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -129,7 +129,7 @@ const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g;
function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string) => boolean {
): (text: string, reading?: string) => boolean {
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
return () => false;
}
@@ -723,6 +723,7 @@ async function parseWithYomitanInternalParser(
surface: token.surface,
reading: token.reading,
headword: token.headword,
headwordReading: token.headwordReading,
startPos: token.startPos,
endPos: token.endPos,
partOfSpeech: posMetadata.partOfSpeech,
@@ -56,6 +56,70 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
assert.equal(surfaceResult[0]?.isKnown, false);
});
test('annotateTokens passes dictionary-form reading so spelling collisions stay unknown', () => {
// とこ (colloquial ところ) resolves to headword 床/とこ; a known 床/ゆか card
// must not mark it known (#138 regression).
const cache = new Map([['床', 'ゆか']]);
const isKnownWord = (text: string, reading?: string): boolean => {
if (!cache.has(text)) {
return false;
}
return reading === undefined || cache.get(text) === reading;
};
const tokens = [
makeToken({
surface: 'とこ',
headword: '床',
reading: 'とこ',
headwordReading: 'とこ',
endPos: 2,
}),
];
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
assert.equal(result[0]?.isKnown, false);
});
test('annotateTokens keeps inflected known words matched via headword reading', () => {
const isKnownWord = (text: string, reading?: string): boolean =>
text === '行く' && (reading === undefined || reading === 'いく');
const tokens = [
makeToken({
surface: '行きたい',
headword: '行く',
reading: 'いきたい',
headwordReading: 'いく',
partOfSpeech: PartOfSpeech.verb,
endPos: 4,
}),
];
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
assert.equal(result[0]?.isKnown, true);
});
test('annotateTokens omits reading for headword match when token lacks headword reading and is inflected', () => {
// MeCab tokens have no dictionary-form reading; the surface reading of an
// inflected form must not be compared against the note's dictionary reading.
const isKnownWord = (text: string, reading?: string): boolean =>
text === '食べる' && reading === undefined;
const tokens = [
makeToken({
surface: '食べた',
headword: '食べる',
reading: 'タベタ',
partOfSpeech: PartOfSpeech.verb,
endPos: 3,
}),
];
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
assert.equal(result[0]?.isKnown, true);
});
test('annotateTokens marks known words when N+1 is disabled', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
+41 -10
View File
@@ -25,7 +25,7 @@ const jlptLevelLookupCaches = new WeakMap<
>();
export interface AnnotationStageDeps {
isKnownWord: (text: string) => boolean;
isKnownWord: (text: string, reading?: string) => boolean;
knownWordMatchMode: NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null;
}
@@ -661,26 +661,57 @@ function isCompleteReadingForSurface(surface: string, reading: string): boolean
return true;
}
// Returns the token's trimmed reading only when it plausibly covers the surface
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
// known-word reading disambiguation and the reading fallback stay in sync if the
// validity rule changes.
function resolveCompleteTokenReading(token: MergedToken): string | undefined {
const normalizedReading = token.reading.trim();
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
return undefined;
}
return normalizedReading;
}
// Reading to disambiguate the known-word text match, or undefined when the
// token has no reading that describes the match text: in headword mode an
// inflected surface's reading does not match the dictionary form's reading,
// and partial furigana readings (see isCompleteReadingForSurface) would cause
// false negatives. Undefined falls back to text-only matching (fail-open).
function resolveKnownWordReadingForMatch(
token: MergedToken,
knownWordMatchMode: NPlusOneMatchMode,
): string | undefined {
if (knownWordMatchMode === 'headword') {
const headwordReading = token.headwordReading?.trim();
if (headwordReading) {
return headwordReading;
}
if (token.surface !== token.headword) {
return undefined;
}
}
return resolveCompleteTokenReading(token);
}
function computeTokenKnownStatus(
token: MergedToken,
isKnownWord: (text: string) => boolean,
isKnownWord: (text: string, reading?: string) => boolean,
knownWordMatchMode: NPlusOneMatchMode,
): boolean {
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
if (token.isKnown || (matchText ? isKnownWord(matchText) : false)) {
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
if (token.isKnown || (matchText ? isKnownWord(matchText, matchReading) : false)) {
return true;
}
const normalizedReading = token.reading.trim();
if (!normalizedReading) {
const fallbackReading = resolveCompleteTokenReading(token);
if (!fallbackReading) {
return false;
}
if (!isCompleteReadingForSurface(token.surface, normalizedReading)) {
return false;
}
return normalizedReading !== matchText.trim() && isKnownWord(normalizedReading);
return fallbackReading !== matchText.trim() && isKnownWord(fallbackReading);
}
function filterTokenFrequencyRank(
@@ -966,6 +966,7 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
surface: '潜み',
reading: 'ひそみ',
headword: '潜む',
headwordReading: 'ひそむ',
startPos: 0,
endPos: 2,
isNameMatch: false,
@@ -1032,6 +1033,7 @@ test('requestYomitanScanTokens emits complete readings for kanji-kana compounds'
surface: '待ち合わせてる',
reading: 'まちあわせてる',
headword: '待ち合わせる',
headwordReading: 'まちあわせる',
startPos: 0,
endPos: 7,
isNameMatch: false,
@@ -1139,6 +1141,7 @@ test('requestYomitanScanTokens uses frequency from later exact-match entry when
surface: '者',
reading: 'もの',
headword: '者',
headwordReading: 'もの',
startPos: 0,
endPos: 1,
isNameMatch: false,
@@ -1240,6 +1243,7 @@ test('requestYomitanScanTokens can use frequency from later exact secondary-matc
surface: '者',
reading: 'もの',
headword: '者',
headwordReading: 'もの',
startPos: 0,
endPos: 1,
isNameMatch: false,
@@ -1340,6 +1344,7 @@ test('requestYomitanScanTokens uses exact frequency entry when selected reading
surface: '第二',
reading: 'だいに',
headword: '第二',
headwordReading: 'だいに',
startPos: 0,
endPos: 2,
isNameMatch: false,
@@ -51,6 +51,7 @@ export interface YomitanScanToken {
surface: string;
reading: string;
headword: string;
headwordReading?: string;
startPos: number;
endPos: number;
isNameMatch?: boolean;
@@ -92,6 +93,7 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
typeof entry.surface === 'string' &&
typeof entry.reading === 'string' &&
typeof entry.headword === 'string' &&
(entry.headwordReading === undefined || typeof entry.headwordReading === 'string') &&
typeof entry.startPos === 'number' &&
typeof entry.endPos === 'number' &&
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
@@ -1318,6 +1320,7 @@ ${YOMITAN_SCANNING_HELPERS}
surface: segments.map((segment) => segment.text).join("") || source,
reading: segments.map(getSegmentReadingContribution).join(""),
headword: preferredHeadword.term,
headwordReading: reading || undefined,
startPos: i,
endPos: i + originalTextLength,
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
+14 -1
View File
@@ -37,6 +37,7 @@ import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server';
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
import {
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
@@ -2324,6 +2325,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
logWarn: (message) => logger.warn(message),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
})(),
@@ -4066,6 +4070,14 @@ const statsStartupRuntime = {
}
},
} as const;
const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({
isStatsAutoStartEnabled: () => getResolvedConfig().stats.autoStartServer,
isImmersionTrackingEnabled: () => getResolvedConfig().immersionTracking?.enabled !== false,
ensureBackgroundStatsServerStarted: () =>
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
});
const runStatsCliCommand = createRunStatsCliCommandHandler({
getResolvedConfig: () => getResolvedConfig(),
@@ -4635,7 +4647,7 @@ const {
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)),
isKnownWord: (text, reading) => Boolean(appState.ankiIntegration?.isKnownWord(text, reading)),
recordLookup: (hit) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordLookup(hit);
@@ -5962,6 +5974,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
);
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
+2
View File
@@ -47,6 +47,7 @@ export interface CliCommandRuntimeServiceContext {
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
ensureBackgroundStatsServer: context.ensureBackgroundStatsServer,
runUpdateCommand: context.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
+2
View File
@@ -200,6 +200,7 @@ export interface CliCommandRuntimeServiceDepsParams {
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
ensureBackgroundStatsServer?: CliCommandDepsRuntimeOptions['app']['ensureBackgroundStatsServer'];
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
@@ -402,6 +403,7 @@ export function createCliCommandRuntimeServiceDeps(
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
ensureBackgroundStatsServer: params.app.ensureBackgroundStatsServer,
runUpdateCommand: params.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
+153
View File
@@ -926,3 +926,156 @@ test('waitForModalOpen resolves false on timeout', async () => {
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
});
test('modal placement reconcile retries until the Hyprland client is mapped', () => {
const window = createMockWindow();
const timers: Array<() => void> = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = ((cb: () => void) => {
timers.push(cb);
return { unref() {} };
}) as unknown as typeof globalThis.setTimeout;
const statuses: Array<{ applicable: boolean; clientFound: boolean }> = [];
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {
// The compositor never maps the window, so every reconcile reports pending.
const status = { applicable: true, clientFound: false, dispatched: false };
statuses.push(status);
return status;
},
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
let iterations = 0;
while (timers.length > 0 && iterations < 50) {
const next = timers.shift();
next?.();
iterations += 1;
}
// The reconcile ladder re-asserts placement across all six delays while the client
// stays unmapped, instead of the old single post-show attempt.
assert.ok(
statuses.length >= 6,
`expected at least 6 pending reconcile attempts, saw ${statuses.length}`,
);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('modal placement reconcile stops retrying once the client is mapped', () => {
const window = createMockWindow();
const timers: Array<() => void> = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = ((cb: () => void) => {
timers.push(cb);
return { unref() {} };
}) as unknown as typeof globalThis.setTimeout;
let reconcileCount = 0;
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {
reconcileCount += 1;
// Client is already mapped, so placement is settled on the first attempt.
return { applicable: true, clientFound: true, dispatched: true };
},
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
let iterations = 0;
while (timers.length > 0 && iterations < 50) {
const next = timers.shift();
next?.();
iterations += 1;
}
// No 6-deep ladder: a settled placement should not keep rescheduling.
assert.ok(
reconcileCount < 6,
`expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`,
);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => {
const window = createMockWindow();
type TimerEntry = { active: boolean; callback: () => void };
const timers: TimerEntry[] = [];
const activeTimerCount = () => timers.filter((timer) => timer.active).length;
const runNextActiveTimer = () => {
const timer = timers.find((candidate) => candidate.active);
if (!timer) return;
timer.active = false;
timer.callback();
};
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = ((cb: () => void) => {
const timer = { active: true, callback: cb, unref() {} };
timers.push(timer);
return timer;
}) as unknown as typeof globalThis.setTimeout;
globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => {
if (timeout) {
timeout.active = false;
}
}) as unknown as typeof globalThis.clearTimeout;
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }),
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
assert.equal(activeTimerCount(), 1);
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
assert.equal(activeTimerCount(), 2);
runNextActiveTimer();
assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation');
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
+42 -9
View File
@@ -1,10 +1,14 @@
import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types';
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50;
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
// before the compositor has a client to place, leaving the modal buried under fullscreen mpv.
// Re-assert placement across this ladder until the Hyprland client is found (or attempts run out).
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS = [50, 120, 250, 500, 900, 1400];
function requestOverlayApplicationFocus(): void {
try {
@@ -31,7 +35,7 @@ export interface OverlayWindowResolver {
getModalWindow: () => BrowserWindow | null;
createModalWindow: () => BrowserWindow | null;
getModalGeometry: () => WindowGeometry;
setModalWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus | void;
}
export interface OverlayModalRuntime {
@@ -73,6 +77,7 @@ export function createOverlayModalRuntimeService(
let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
@@ -145,21 +150,47 @@ export function createOverlayModalRuntimeService(
window.moveTop();
};
const reconcileModalWindowBounds = (window: BrowserWindow): void => {
const reconcileModalWindowBounds = (window: BrowserWindow): HyprlandPlacementStatus | void => {
const modalWindow = deps.getModalWindow();
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
return;
}
deps.setModalWindowBounds(deps.getModalGeometry());
return deps.setModalWindowBounds(deps.getModalGeometry());
};
const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => {
const nextModalWindowBoundsReconcileGeneration = (window: BrowserWindow): number => {
const generation = (modalWindowBoundsReconcileGenerations.get(window) ?? 0) + 1;
modalWindowBoundsReconcileGenerations.set(window, generation);
return generation;
};
const isCurrentModalWindowBoundsReconcileGeneration = (
window: BrowserWindow,
generation: number,
): boolean => modalWindowBoundsReconcileGenerations.get(window) === generation;
const scheduleModalWindowBoundsReconcile = (
window: BrowserWindow,
generation: number,
attempt = 0,
): void => {
if (attempt >= MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS.length) {
return;
}
const timeout = setTimeout(() => {
if (!isCurrentModalWindowBoundsReconcileGeneration(window, generation)) {
return;
}
if (window.isDestroyed() || !window.isVisible()) {
return;
}
reconcileModalWindowBounds(window);
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS);
const status = reconcileModalWindowBounds(window);
// Keep retrying only while a Hyprland placement is applicable but the compositor has
// not mapped the window yet. Once the client is found (or we're not on Hyprland), stop.
if (status && status.applicable && !status.clientFound) {
scheduleModalWindowBoundsReconcile(window, generation, attempt + 1);
}
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS[attempt]);
timeout.unref?.();
};
@@ -206,8 +237,9 @@ export function createOverlayModalRuntimeService(
if (!window.webContents.isFocused()) {
window.webContents.focus();
}
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
reconcileModalWindowBounds(window);
scheduleModalWindowBoundsReconcile(window);
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
};
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
@@ -219,8 +251,9 @@ export function createOverlayModalRuntimeService(
if (window.isVisible()) {
window.focus();
window.webContents.focus();
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
reconcileModalWindowBounds(window);
scheduleModalWindowBoundsReconcile(window);
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
return;
}
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createEnsureBackgroundStatsServerHandler } from './background-stats-startup';
function createDeps(
overrides: Partial<Parameters<typeof createEnsureBackgroundStatsServerHandler>[0]> = {},
) {
const calls: string[] = [];
const deps: Parameters<typeof createEnsureBackgroundStatsServerHandler>[0] = {
isStatsAutoStartEnabled: () => true,
isImmersionTrackingEnabled: () => true,
ensureBackgroundStatsServerStarted: () => {
calls.push('ensureBackgroundStatsServerStarted');
return { url: 'http://127.0.0.1:3888', runningInCurrentProcess: true };
},
logInfo: (message) => {
calls.push(`info:${message}`);
},
logWarn: (message) => {
calls.push(`warn:${message}`);
},
...overrides,
};
return { deps, calls };
}
test('ensures background stats server and logs local startup', () => {
const { deps, calls } = createDeps();
createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
assert.ok(
calls.some((value) => value.startsWith('info:') && value.includes('http://127.0.0.1:3888')),
);
});
test('logs reuse when a background stats server is already running', () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:3888',
runningInCurrentProcess: false,
}),
});
createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
);
});
test('skips when stats.autoStartServer is disabled', () => {
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('skips when immersion tracking is disabled', () => {
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('logs a warning instead of throwing when startup fails', () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => {
throw new Error('port in use');
},
});
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
assert.ok(calls.some((value) => value.startsWith('warn:')));
});
@@ -0,0 +1,35 @@
export interface EnsureBackgroundStatsServerDeps {
isStatsAutoStartEnabled: () => boolean;
isImmersionTrackingEnabled: () => boolean;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
}
export function createEnsureBackgroundStatsServerHandler(
deps: EnsureBackgroundStatsServerDeps,
): () => void {
return () => {
if (!deps.isStatsAutoStartEnabled()) {
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
return;
}
if (!deps.isImmersionTrackingEnabled()) {
deps.logInfo('Background start: immersion tracking is disabled; skipping stats server.');
return;
}
try {
const result = deps.ensureBackgroundStatsServerStarted();
deps.logInfo(
result.runningInCurrentProcess
? `Background start: stats server started at ${result.url}.`
: `Background start: stats server already running at ${result.url}; skipping.`,
);
} catch (error) {
deps.logWarn('Background start: failed to start stats server.', error);
}
};
}
@@ -45,6 +45,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -61,6 +61,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
@@ -140,6 +141,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
+2
View File
@@ -50,6 +50,7 @@ export type CliCommandContextFactoryDeps = {
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -130,6 +131,7 @@ export function createCliCommandContext(
runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -36,3 +36,50 @@ test('field grouping overlay main deps builder maps window visibility and resolv
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
});
test('field grouping overlay main deps builder forwards modal open/teardown/prereq wiring', () => {
// Regression: these are optional on the runtime options, so a missing forward compiled
// silently and left the field grouping modal with no ack/retry, no teardown, and no prereqs —
// the reason the earlier recovery fixes never took effect.
const calls: string[] = [];
const waitForModalOpen = async (modal: 'kiku', timeoutMs: number): Promise<boolean> => {
calls.push(`wait:${modal}:${timeoutMs}`);
return true;
};
const handleOverlayModalClosed = (modal: 'kiku'): void => {
calls.push(`closed:${modal}`);
};
const logWarn = (message: string): void => {
calls.push(`warn:${message}`);
};
const ensureOverlayStartupPrereqs = (): void => {
calls.push('prereqs');
};
const ensureOverlayWindowsReadyForVisibilityActions = (): void => {
calls.push('windows-ready');
};
const deps = createBuildFieldGroupingOverlayMainDepsHandler<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
waitForModalOpen,
handleOverlayModalClosed,
logWarn,
ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: () => true,
})();
assert.equal(deps.waitForModalOpen, waitForModalOpen);
assert.equal(deps.handleOverlayModalClosed, handleOverlayModalClosed);
assert.equal(deps.logWarn, logWarn);
assert.equal(deps.ensureOverlayStartupPrereqs, ensureOverlayStartupPrereqs);
assert.equal(
deps.ensureOverlayWindowsReadyForVisibilityActions,
ensureOverlayWindowsReadyForVisibilityActions,
);
});
@@ -28,6 +28,15 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
getResolver: () => deps.getResolver(),
setResolver: (resolver) => deps.setResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
// These are optional on the runtime options, so a missing forward compiles silently — but
// dropping them left the field grouping modal with no modal-open ack/retry, no teardown on
// failure, and no warn logging, which is why the earlier recovery fixes never took effect.
waitForModalOpen: deps.waitForModalOpen,
handleOverlayModalClosed: deps.handleOverlayModalClosed,
logWarn: deps.logWarn,
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
@@ -26,8 +26,9 @@ test('overlay modal runtime main deps builder maps window resolvers', () => {
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
setModalWindowBounds: (geometry) =>
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`),
setModalWindowBounds: (geometry) => {
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`);
},
})();
assert.equal(deps.getMainWindow(), mainWindow);
@@ -37,8 +37,8 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (promise: Promise<boolean> | null) =>
deps.setYomitanParserInitPromise(promise),
isKnownWord: (text: string) => {
const hit = deps.isKnownWord(text);
isKnownWord: (text: string, reading?: string) => {
const hit = deps.isKnownWord(text, reading);
deps.recordLookup(hit);
return hit;
},
+4
View File
@@ -201,6 +201,9 @@ const onKikuFieldGroupingRequestEvent =
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData,
);
const onKikuFieldGroupingCancelEvent = createQueuedIpcListener(
IPC_CHANNELS.event.kikuFieldGroupingCancel,
);
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
IPC_CHANNELS.event.subtitleSet,
(payload) => payload as SubtitleData,
@@ -396,6 +399,7 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
onKikuFieldGroupingCancel: onKikuFieldGroupingCancelEvent,
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
+7
View File
@@ -565,6 +565,13 @@ function registerModalOpenHandlers(): void {
});
},
);
window.electronAPI.onKikuFieldGroupingCancel(() => {
runGuarded('kiku:field-grouping-cancel', () => {
// Main already settled the choice (timeout/failure); just close the dialog. Using the
// plain close path avoids sending a second, redundant response back to main.
kikuModal.closeKikuFieldGroupingModal();
});
});
}
function registerKeyboardCommandHandlers(): void {
+1
View File
@@ -130,6 +130,7 @@ export const IPC_CHANNELS = {
secondarySubtitleMode: 'secondary-subtitle:mode',
subsyncOpenManual: 'subsync:open-manual',
kikuFieldGroupingRequest: 'kiku:field-grouping-request',
kikuFieldGroupingCancel: 'kiku:field-grouping-cancel',
runtimeOptionsChanged: 'runtime-options:changed',
runtimeOptionsOpen: 'runtime-options:open',
jimakuOpen: 'jimaku:open',
+1
View File
@@ -471,6 +471,7 @@ export interface ElectronAPI {
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
onKikuFieldGroupingCancel: (callback: () => void) => void;
kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
+2
View File
@@ -29,6 +29,8 @@ export interface MergedToken {
surface: string;
reading: string;
headword: string;
/** Dictionary-form reading of headword (kana), when the parser provides it. */
headwordReading?: string;
startPos: number;
endPos: number;
partOfSpeech: PartOfSpeech;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -3,8 +3,10 @@ import type { TimeRange, GroupBy } from '../../hooks/useTrends';
interface DateRangeSelectorProps {
range: TimeRange;
groupBy: GroupBy;
fillEmpty: boolean;
onRangeChange: (r: TimeRange) => void;
onGroupByChange: (g: GroupBy) => void;
onFillEmptyChange: (fillEmpty: boolean) => void;
}
function SegmentedControl<T extends string>({
@@ -46,11 +48,13 @@ function SegmentedControl<T extends string>({
export function DateRangeSelector({
range,
groupBy,
fillEmpty,
onRangeChange,
onGroupByChange,
onFillEmptyChange,
}: DateRangeSelectorProps) {
return (
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-wrap items-center gap-4 text-sm">
<SegmentedControl
label="Range"
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
@@ -65,6 +69,13 @@ export function DateRangeSelector({
onChange={onGroupByChange}
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
/>
<SegmentedControl
label="Empty days"
options={['show', 'hide']}
value={fillEmpty ? 'show' : 'hide'}
onChange={(v) => onFillEmptyChange(v === 'show')}
formatLabel={(v) => (v === 'show' ? 'Show' : 'Hide')}
/>
</div>
);
}
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildLineData,
sortTooltipEntries,
tooltipColumnCount,
type PerAnimeDataPoint,
} from './StackedTrendChart';
function makePoints(titleCount: number): PerAnimeDataPoint[] {
const points: PerAnimeDataPoint[] = [];
for (let i = 0; i < titleCount; i += 1) {
points.push({ epochDay: 20_000 + i, animeTitle: `Title ${i}`, value: titleCount - i });
}
return points;
}
test('sortTooltipEntries orders rows by value descending, tolerating string values', () => {
const sorted = sortTooltipEntries([
{ name: 'A', value: 5 },
{ name: 'B', value: 20 },
{ name: 'C', value: '12.5' },
{ name: 'D', value: undefined },
]);
assert.deepEqual(
sorted.map((entry) => entry.name),
['B', 'C', 'A', 'D'],
);
});
test('tooltipColumnCount wraps into extra columns as items grow, capped at 3', () => {
assert.equal(tooltipColumnCount(1), 1);
assert.equal(tooltipColumnCount(8), 1);
assert.equal(tooltipColumnCount(9), 2);
assert.equal(tooltipColumnCount(16), 2);
assert.equal(tooltipColumnCount(17), 3);
assert.equal(tooltipColumnCount(40), 3);
});
test('buildLineData keeps every title as a series instead of capping at the top 7', () => {
const { points, seriesKeys } = buildLineData(makePoints(17));
assert.equal(seriesKeys.length, 17);
assert.ok(seriesKeys.includes('Title 16'));
for (const row of points) {
for (const key of seriesKeys) {
assert.ok(key in row);
}
}
});
test('buildLineData caps series at maxSeries when set, keeping the top titles', () => {
const { points, seriesKeys } = buildLineData(makePoints(17), 7);
assert.equal(seriesKeys.length, 7);
assert.deepEqual(
seriesKeys,
Array.from({ length: 7 }, (_, i) => `Title ${i}`),
);
for (const row of points) {
assert.ok(!('Title 16' in row));
}
});
test('buildLineData orders series by total value descending', () => {
const { seriesKeys } = buildLineData([
{ epochDay: 20_000, animeTitle: 'Small', value: 1 },
{ epochDay: 20_000, animeTitle: 'Big', value: 10 },
{ epochDay: 20_001, animeTitle: 'Medium', value: 5 },
]);
assert.deepEqual(seriesKeys, ['Big', 'Medium', 'Small']);
});
test('buildLineData total mode ranks by final cumulative value, not the sum of snapshots', () => {
// "Early" plateaus at 10 across four days (snapshot sum 40); "Late" reaches a
// larger final value of 12 in a single day. Ranking by final value keeps Late
// first; the old snapshot-sum ranking would have put Early first.
const raw: PerAnimeDataPoint[] = [
{ epochDay: 20_000, animeTitle: 'Early', value: 10 },
{ epochDay: 20_001, animeTitle: 'Early', value: 10 },
{ epochDay: 20_002, animeTitle: 'Early', value: 10 },
{ epochDay: 20_003, animeTitle: 'Early', value: 10 },
{ epochDay: 20_003, animeTitle: 'Late', value: 12 },
];
const { seriesKeys } = buildLineData(raw, undefined, 'total');
assert.deepEqual(seriesKeys, ['Late', 'Early']);
});
test('buildLineData recent mode keeps the most recently active titles over the largest', () => {
// "Old Giant" has a huge cumulative total but stopped growing early; "Fresh"
// is small but its cumulative value increased on the latest day.
const raw: PerAnimeDataPoint[] = [
{ epochDay: 20_000, animeTitle: 'Old Giant', value: 500 },
{ epochDay: 20_001, animeTitle: 'Old Giant', value: 500 },
{ epochDay: 20_002, animeTitle: 'Old Giant', value: 500 },
{ epochDay: 20_000, animeTitle: 'Fresh', value: 1 },
{ epochDay: 20_001, animeTitle: 'Fresh', value: 2 },
{ epochDay: 20_002, animeTitle: 'Fresh', value: 5 },
];
const total = buildLineData(raw, 1, 'total');
assert.deepEqual(total.seriesKeys, ['Old Giant']);
const recent = buildLineData(raw, 1, 'recent');
assert.deepEqual(recent.seriesKeys, ['Fresh']);
});
test('buildLineData recent mode breaks ties by total then name', () => {
// Both titles last increased on day 20_002; the larger total wins the tie.
const raw: PerAnimeDataPoint[] = [
{ epochDay: 20_001, animeTitle: 'A', value: 2 },
{ epochDay: 20_002, animeTitle: 'A', value: 4 },
{ epochDay: 20_001, animeTitle: 'B', value: 3 },
{ epochDay: 20_002, animeTitle: 'B', value: 9 },
];
const { seriesKeys } = buildLineData(raw, 1, 'recent');
assert.deepEqual(seriesKeys, ['B']);
});
+155 -15
View File
@@ -20,6 +20,8 @@ interface StackedTrendChartProps {
title: string;
data: PerAnimeDataPoint[];
colorPalette?: string[];
maxSeries?: number | null;
maxSeriesMode?: SeriesRankMode;
}
const DEFAULT_LINE_COLORS = [
@@ -33,19 +35,147 @@ const DEFAULT_LINE_COLORS = [
'#f4dbd6',
];
function buildLineData(raw: PerAnimeDataPoint[]) {
const totalByAnime = new Map<string, number>();
// Wrap the tooltip into extra columns once a single column would get too tall,
// keeping it compact instead of overflowing behind the charts below it.
const TOOLTIP_ROWS_PER_COLUMN = 8;
const TOOLTIP_MAX_COLUMNS = 3;
export function tooltipColumnCount(itemCount: number): number {
const columns = Math.ceil(itemCount / TOOLTIP_ROWS_PER_COLUMN);
return Math.min(TOOLTIP_MAX_COLUMNS, Math.max(1, columns));
}
interface TooltipEntry {
name?: string | number;
value?: string | number;
color?: string;
}
interface StackedTooltipProps {
active?: boolean;
label?: string | number;
payload?: TooltipEntry[];
}
function tooltipEntryValue(entry: TooltipEntry): number {
const numeric = typeof entry.value === 'number' ? entry.value : Number(entry.value);
return Number.isFinite(numeric) ? numeric : 0;
}
// Tooltip rows are sorted by value (largest first) so the biggest contributors
// read top-down, independent of the series/stacking order.
export function sortTooltipEntries<T extends TooltipEntry>(entries: readonly T[]): T[] {
return [...entries].sort((a, b) => tooltipEntryValue(b) - tooltipEntryValue(a));
}
function StackedTooltip({ active, label, payload }: StackedTooltipProps) {
if (!active || !payload || payload.length === 0) {
return null;
}
const sorted = sortTooltipEntries(payload);
const columns = tooltipColumnCount(sorted.length);
return (
<div
style={{
...TOOLTIP_CONTENT_STYLE,
padding: '6px 10px',
maxWidth: '90vw',
}}
>
{label !== undefined && (
<div style={{ color: CHART_THEME.tooltipLabel, marginBottom: 4, fontWeight: 600 }}>
{label}
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, max-content)`,
columnGap: 16,
rowGap: 2,
}}
>
{sorted.map((entry, index) => (
<div
key={`${entry.name ?? index}`}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}
>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 9999,
background: entry.color,
flexShrink: 0,
}}
/>
<span>{entry.name}</span>
<span style={{ marginLeft: 16, color: CHART_THEME.tooltipLabel }}>{entry.value}</span>
</div>
))}
</div>
</div>
);
}
export type SeriesRankMode = 'total' | 'recent';
// Per-title ranking key. The data is cumulative per title, so `total` is the
// final (latest) cumulative value — not the sum of daily snapshots, which would
// over-weight titles that plateaued high early. `recentDay` is the latest epoch
// day the value increased, i.e. its last day of real activity — used to keep
// the most recently watched titles.
function rankTitles(raw: PerAnimeDataPoint[]): Map<string, { total: number; recentDay: number }> {
const pointsByTitle = new Map<string, PerAnimeDataPoint[]>();
for (const entry of raw) {
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
const list = pointsByTitle.get(entry.animeTitle) ?? [];
list.push(entry);
pointsByTitle.set(entry.animeTitle, list);
}
const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]);
const topTitles = sorted.slice(0, 7).map(([title]) => title);
const topSet = new Set(topTitles);
const stats = new Map<string, { total: number; recentDay: number }>();
for (const [title, points] of pointsByTitle) {
const sorted = [...points].sort((a, b) => a.epochDay - b.epochDay);
let previous = 0;
let recentDay = Number.NEGATIVE_INFINITY;
for (const point of sorted) {
if (point.value > previous) {
recentDay = point.epochDay;
}
previous = point.value;
}
const total = sorted.length > 0 ? sorted[sorted.length - 1]!.value : 0;
stats.set(title, { total, recentDay });
}
return stats;
}
export function buildLineData(
raw: PerAnimeDataPoint[],
maxSeries?: number | null,
mode: SeriesRankMode = 'total',
) {
const stats = rankTitles(raw);
let seriesKeys = [...stats.entries()]
.sort((a, b) => {
if (mode === 'recent') {
return (
b[1].recentDay - a[1].recentDay || b[1].total - a[1].total || a[0].localeCompare(b[0])
);
}
return b[1].total - a[1].total || a[0].localeCompare(b[0]);
})
.map(([title]) => title);
if (typeof maxSeries === 'number' && maxSeries > 0) {
seriesKeys = seriesKeys.slice(0, maxSeries);
}
const seriesSet = new Set(seriesKeys);
const byDay = new Map<number, Record<string, number>>();
for (const entry of raw) {
if (!topSet.has(entry.animeTitle)) continue;
if (!seriesSet.has(entry.animeTitle)) continue;
const row = byDay.get(entry.epochDay) ?? {};
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
byDay.set(entry.epochDay, row);
@@ -60,17 +190,23 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
day: 'numeric',
}),
};
for (const title of topTitles) {
for (const title of seriesKeys) {
row[title] = values[title] ?? 0;
}
return row;
});
return { points, seriesKeys: topTitles };
return { points, seriesKeys };
}
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data);
export function StackedTrendChart({
title,
data,
colorPalette,
maxSeries,
maxSeriesMode,
}: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data, maxSeries, maxSeriesMode);
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
if (points.length === 0) {
@@ -100,7 +236,11 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
tickLine={false}
width={32}
/>
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
<Tooltip
content={<StackedTooltip />}
wrapperStyle={{ zIndex: 50 }}
allowEscapeViewBox={{ x: false, y: true }}
/>
{seriesKeys.map((key, i) => (
<Area
key={key}
@@ -115,18 +255,18 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
))}
</AreaChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{seriesKeys.map((key, i) => (
<span
key={key}
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 whitespace-nowrap"
title={key}
>
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: colors[i % colors.length] }}
/>
<span className="truncate">{key}</span>
<span>{key}</span>
</span>
))}
</div>
@@ -8,9 +8,13 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={null}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
@@ -18,6 +22,64 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
assert.doesNotMatch(markup, /Anime Visibility/);
});
test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={7}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /per chart/);
assert.match(markup, /<option value="all">All<\/option>/);
assert.match(markup, /<option value="7" selected="">/);
});
test('AnimeVisibilityFilter offers top vs most-recent ranking modes', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={7}
maxTitlesMode="recent"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /<option value="total">top<\/option>/);
assert.match(markup, /<option value="recent" selected="">most recent<\/option>/);
});
test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing all titles', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={null}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /aria-label="Title ranking mode"/);
assert.doesNotMatch(markup, /aria-label="Title ranking mode"[^>]*disabled/);
});
test('TrendsTab source labels words per minute without reading speed wording', async () => {
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
+115 -19
View File
@@ -4,12 +4,28 @@ import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart } from './StackedTrendChart';
import {
MAX_TITLES_MODES,
MAX_TITLES_OPTIONS,
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
type MaxTitlesMode,
} from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection';
const MAX_TITLES_MODE_LABELS: Record<MaxTitlesMode, string> = {
recent: 'most recent',
total: 'top',
};
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
@@ -24,17 +40,25 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
interface AnimeVisibilityFilterProps {
animeTitles: string[];
hiddenAnime: ReadonlySet<string>;
maxTitles: number | null;
maxTitlesMode: MaxTitlesMode;
onShowAll: () => void;
onHideAll: () => void;
onToggleAnime: (title: string) => void;
onMaxTitlesChange: (value: number | null) => void;
onMaxTitlesModeChange: (mode: MaxTitlesMode) => void;
}
export function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
maxTitles,
maxTitlesMode,
onShowAll,
onHideAll,
onToggleAnime,
onMaxTitlesChange,
onMaxTitlesModeChange,
}: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) {
return null;
@@ -48,10 +72,41 @@ export function AnimeVisibilityFilter({
Title Visibility
</h4>
<p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything.
Shared across all anime trend charts. Default: show everything. Selections are saved.
</p>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[11px] text-ctp-subtext0">
Show
<select
aria-label="Title ranking mode"
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
value={maxTitlesMode}
onChange={(event) => onMaxTitlesModeChange(event.target.value as MaxTitlesMode)}
>
{MAX_TITLES_MODES.map((mode) => (
<option key={mode} value={mode}>
{MAX_TITLES_MODE_LABELS[mode]}
</option>
))}
</select>
<select
aria-label="Titles per chart"
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
value={maxTitles === null ? 'all' : String(maxTitles)}
onChange={(event) =>
onMaxTitlesChange(event.target.value === 'all' ? null : Number(event.target.value))
}
>
<option value="all">All</option>
{MAX_TITLES_OPTIONS.map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
per chart
</label>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
@@ -96,8 +151,28 @@ export function AnimeVisibilityFilter({
export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
const { data, loading, error } = useTrends(range, groupBy);
const [showEmptyDays, setShowEmptyDays] = useState(() => loadShowEmptyDays());
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => loadHiddenTitles());
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
const [maxTitlesMode, setMaxTitlesMode] = useState<MaxTitlesMode>(() => loadMaxTitlesMode());
const { data, loading, error } = useTrends(range, groupBy, showEmptyDays);
const updateHiddenAnime = (next: Set<string>) => {
setHiddenAnime(next);
saveHiddenTitles(next);
};
const updateMaxTitles = (value: number | null) => {
setMaxTitles(value);
saveMaxTitles(value);
};
const updateMaxTitlesMode = (mode: MaxTitlesMode) => {
setMaxTitlesMode(mode);
saveMaxTitlesMode(mode);
};
const updateShowEmptyDays = (show: boolean) => {
setShowEmptyDays(show);
saveShowEmptyDays(show);
};
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [
cardsMinedColor,
@@ -144,8 +219,10 @@ export function TrendsTab() {
<DateRangeSelector
range={range}
groupBy={groupBy}
fillEmpty={showEmptyDays}
onRangeChange={setRange}
onGroupByChange={setGroupBy}
onFillEmptyChange={updateShowEmptyDays}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
@@ -246,28 +323,47 @@ export function TrendsTab() {
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
onShowAll={() => setHiddenAnime(new Set())}
onHideAll={() => setHiddenAnime(new Set(animeTitles))}
onToggleAnime={(title) =>
setHiddenAnime((current) => {
const next = new Set(current);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
return next;
})
}
maxTitles={maxTitles}
maxTitlesMode={maxTitlesMode}
onShowAll={() => updateHiddenAnime(new Set())}
onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
onToggleAnime={(title) => {
const next = new Set(hiddenAnime);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
updateHiddenAnime(next);
}}
onMaxTitlesChange={updateMaxTitles}
onMaxTitlesModeChange={updateMaxTitlesMode}
/>
<StackedTrendChart
title="Watch Time Progress (min)"
data={filteredWatchTimeProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart
title="Episodes Progress"
data={filteredAnimeProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart
title="Cards Mined Progress"
data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart
title="Words Seen Progress"
data={filteredWordsProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Library Summary</SectionHeader>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
@@ -5,9 +5,40 @@ import type { PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
} from './anime-visibility';
function installLocalStorage(initial: Record<string, string> = {}) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
return {
values,
restore: () => {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
},
};
}
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
@@ -45,3 +76,86 @@ test('pruneHiddenAnime drops titles that are no longer available', () => {
assert.deepEqual([...hidden], ['KonoSuba']);
});
test('hidden titles round-trip through localStorage', () => {
const { restore } = installLocalStorage();
try {
saveHiddenTitles(new Set(['KonoSuba', 'One Piece']));
assert.deepEqual([...loadHiddenTitles()], ['KonoSuba', 'One Piece']);
} finally {
restore();
}
});
test('loadHiddenTitles tolerates missing or malformed stored values', () => {
const { restore } = installLocalStorage({
'subminer-stats-trends-hidden-titles': '{"not":"an array"}',
});
try {
assert.deepEqual([...loadHiddenTitles()], []);
} finally {
restore();
}
});
test('max titles preference defaults to 7 and round-trips including explicit All', () => {
const { values, restore } = installLocalStorage();
try {
// First run (nothing stored) defaults to the 7-title cap, not "All".
assert.equal(loadMaxTitles(), 7);
saveMaxTitles(5);
assert.equal(loadMaxTitles(), 5);
// "All" persists explicitly instead of collapsing back to the default.
saveMaxTitles(null);
assert.equal(loadMaxTitles(), null);
assert.equal(values.get('subminer-stats-trends-max-titles'), 'all');
} finally {
restore();
}
});
test('loadMaxTitles falls back to the default for unsupported stored values', () => {
for (const storedValue of ['8', '0', 'banana']) {
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles': storedValue });
try {
assert.equal(loadMaxTitles(), 7);
} finally {
restore();
}
}
});
test('max titles mode defaults to recent and round-trips', () => {
const { restore } = installLocalStorage();
try {
assert.equal(loadMaxTitlesMode(), 'recent');
saveMaxTitlesMode('total');
assert.equal(loadMaxTitlesMode(), 'total');
saveMaxTitlesMode('recent');
assert.equal(loadMaxTitlesMode(), 'recent');
} finally {
restore();
}
});
test('loadMaxTitlesMode falls back to recent for unknown stored values', () => {
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles-mode': 'sideways' });
try {
assert.equal(loadMaxTitlesMode(), 'recent');
} finally {
restore();
}
});
test('show empty days preference defaults to true and round-trips', () => {
const { restore } = installLocalStorage();
try {
assert.equal(loadShowEmptyDays(), true);
saveShowEmptyDays(false);
assert.equal(loadShowEmptyDays(), false);
saveShowEmptyDays(true);
assert.equal(loadShowEmptyDays(), true);
} finally {
restore();
}
});
@@ -1,5 +1,109 @@
import type { PerAnimeDataPoint } from './StackedTrendChart';
const HIDDEN_TITLES_KEY = 'subminer-stats-trends-hidden-titles';
const MAX_TITLES_KEY = 'subminer-stats-trends-max-titles';
const MAX_TITLES_MODE_KEY = 'subminer-stats-trends-max-titles-mode';
const SHOW_EMPTY_DAYS_KEY = 'subminer-stats-trends-show-empty-days';
export const MAX_TITLES_OPTIONS = [3, 5, 7, 10] as const;
// How the per-chart limit picks which titles survive: 'recent' keeps the most
// recently active titles, 'total' keeps the highest cumulative totals. 'recent'
// is listed first so it heads the dropdown.
export type MaxTitlesMode = 'total' | 'recent';
export const MAX_TITLES_MODES: readonly MaxTitlesMode[] = ['recent', 'total'];
// First-run defaults: show the 7 most recently active titles per chart.
const DEFAULT_MAX_TITLES = 7;
const DEFAULT_MAX_TITLES_MODE: MaxTitlesMode = 'recent';
const ALL_TITLES_STORED_VALUE = 'all';
function getStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
export function loadHiddenTitles(): Set<string> {
try {
const raw = getStorage()?.getItem(HIDDEN_TITLES_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return new Set();
}
}
export function saveHiddenTitles(hidden: ReadonlySet<string>): void {
try {
getStorage()?.setItem(HIDDEN_TITLES_KEY, JSON.stringify([...hidden]));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
// Returns the persisted per-chart limit: a specific count, or null for "All".
// Absent/garbage storage falls back to the default count (never null), so a
// first run shows the default cap rather than every title. "All" is stored
// explicitly so it round-trips instead of collapsing back to the default.
export function loadMaxTitles(): number | null {
try {
const raw = getStorage()?.getItem(MAX_TITLES_KEY);
if (raw === ALL_TITLES_STORED_VALUE) return null;
const value = Number(raw);
return (MAX_TITLES_OPTIONS as readonly number[]).includes(value) ? value : DEFAULT_MAX_TITLES;
} catch {
return DEFAULT_MAX_TITLES;
}
}
export function saveMaxTitles(value: number | null): void {
try {
getStorage()?.setItem(MAX_TITLES_KEY, value === null ? ALL_TITLES_STORED_VALUE : String(value));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function loadMaxTitlesMode(): MaxTitlesMode {
try {
const raw = getStorage()?.getItem(MAX_TITLES_MODE_KEY);
if (raw === 'recent' || raw === 'total') return raw;
return DEFAULT_MAX_TITLES_MODE;
} catch {
return DEFAULT_MAX_TITLES_MODE;
}
}
export function saveMaxTitlesMode(mode: MaxTitlesMode): void {
try {
getStorage()?.setItem(MAX_TITLES_MODE_KEY, mode);
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
// Whether trend charts zero-fill empty calendar buckets. Defaults to true so a
// first run shows the calendar-accurate view.
export function loadShowEmptyDays(): boolean {
try {
return getStorage()?.getItem(SHOW_EMPTY_DAYS_KEY) !== 'false';
} catch {
return true;
}
}
export function saveShowEmptyDays(show: boolean): void {
try {
getStorage()?.setItem(SHOW_EMPTY_DAYS_KEY, show ? 'true' : 'false');
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
const totals = new Map<string, number>();
for (const dataset of datasets) {
+3 -3
View File
@@ -5,7 +5,7 @@ import type { TrendsDashboardData } from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
export type GroupBy = 'day' | 'month';
export function useTrends(range: TimeRange, groupBy: GroupBy) {
export function useTrends(range: TimeRange, groupBy: GroupBy, fillEmpty = true) {
const [data, setData] = useState<TrendsDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -15,7 +15,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
setLoading(true);
setError(null);
getStatsClient()
.getTrendsDashboard(range, groupBy)
.getTrendsDashboard(range, groupBy, fillEmpty)
.then((nextData) => {
if (cancelled) return;
setData(nextData);
@@ -31,7 +31,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
return () => {
cancelled = true;
};
}, [range, groupBy]);
}, [range, groupBy, fillEmpty]);
return { data, loading, error };
}
+9 -3
View File
@@ -167,7 +167,10 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
try {
await apiClient.getTrendsDashboard('90d', 'month');
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month&fillEmpty=true`,
);
} finally {
globalThis.fetch = originalFetch;
}
@@ -208,8 +211,11 @@ test('getTrendsDashboard accepts 365d range and builds correct URL', async () =>
}) as typeof globalThis.fetch;
try {
await apiClient.getTrendsDashboard('365d', 'day');
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day`);
await apiClient.getTrendsDashboard('365d', 'day', false);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day&fillEmpty=false`,
);
} finally {
globalThis.fetch = originalFetch;
}
+6 -2
View File
@@ -162,9 +162,13 @@ export const apiClient = {
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: (range: '7d' | '30d' | '90d' | '365d' | 'all', groupBy: 'day' | 'month') =>
getTrendsDashboard: (
range: '7d' | '30d' | '90d' | '365d' | 'all',
groupBy: 'day' | 'month',
fillEmpty = true,
) =>
fetchJson<TrendsDashboardData>(
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
),
getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),