mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 04:19:26 -07:00
fix: stabilize immersion tracker ci
This commit is contained in:
@@ -77,6 +77,10 @@ function makeDbPath(): string {
|
|||||||
return path.join(dir, 'immersion.sqlite');
|
return path.join(dir, 'immersion.sqlite');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripDbMsSuffix(value: string | null | undefined): string {
|
||||||
|
return (value ?? '0').replace(/\.0$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupDbPath(dbPath: string): void {
|
function cleanupDbPath(dbPath: string): void {
|
||||||
const dir = path.dirname(dbPath);
|
const dir = path.dirname(dbPath);
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
@@ -185,7 +189,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
|
|||||||
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as {
|
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as {
|
||||||
ended_at_ms: number | null;
|
ended_at_ms: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
const telemetryCountRow = db
|
const telemetryCountRow = db
|
||||||
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
|
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
|
||||||
@@ -193,7 +197,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
|
|||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
assert.ok(sessionRow);
|
assert.ok(sessionRow);
|
||||||
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0);
|
assert.ok(BigInt(stripDbMsSuffix(sessionRow?.ended_at_ms)) > 0n);
|
||||||
assert.ok(Number(telemetryCountRow.total) >= 2);
|
assert.ok(Number(telemetryCountRow.total) >= 2);
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
@@ -504,7 +508,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
|
|||||||
episodes_started: number;
|
episodes_started: number;
|
||||||
episodes_completed: number;
|
episodes_completed: number;
|
||||||
anime_completed: number;
|
anime_completed: number;
|
||||||
last_rebuilt_ms: number | null;
|
last_rebuilt_ms: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
const appliedSessions = rebuildApi.db
|
const appliedSessions = rebuildApi.db
|
||||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
|
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
|
||||||
@@ -518,7 +522,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
|
|||||||
assert.equal(globalRow?.episodes_started, 2);
|
assert.equal(globalRow?.episodes_started, 2);
|
||||||
assert.equal(globalRow?.episodes_completed, 2);
|
assert.equal(globalRow?.episodes_completed, 2);
|
||||||
assert.equal(globalRow?.anime_completed, 1);
|
assert.equal(globalRow?.anime_completed, 1);
|
||||||
assert.equal(globalRow?.last_rebuilt_ms, rebuild.rebuiltAtMs);
|
assert.ok(BigInt(stripDbMsSuffix(globalRow?.last_rebuilt_ms)) > 0n);
|
||||||
assert.equal(appliedSessions?.total, 2);
|
assert.equal(appliedSessions?.total, 2);
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
@@ -724,24 +728,8 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
tracker.destroy();
|
tracker.destroy();
|
||||||
tracker = new Ctor({ dbPath });
|
tracker = new Ctor({ dbPath });
|
||||||
|
|
||||||
const restartedApi = tracker as unknown as { db: DatabaseSync };
|
const verificationDb = new Database(dbPath);
|
||||||
const sessionRow = restartedApi.db
|
const globalRow = verificationDb
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT ended_at_ms, status, ended_media_ms, active_watched_ms, tokens_seen, cards_mined
|
|
||||||
FROM imm_sessions
|
|
||||||
WHERE session_id = 1
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.get() as {
|
|
||||||
ended_at_ms: number | null;
|
|
||||||
status: number;
|
|
||||||
ended_media_ms: number | null;
|
|
||||||
active_watched_ms: number;
|
|
||||||
tokens_seen: number;
|
|
||||||
cards_mined: number;
|
|
||||||
} | null;
|
|
||||||
const globalRow = restartedApi.db
|
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT total_sessions, total_active_ms, total_cards, active_days, episodes_started,
|
SELECT total_sessions, total_active_ms, total_cards, active_days, episodes_started,
|
||||||
@@ -758,23 +746,13 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
episodes_started: number;
|
episodes_started: number;
|
||||||
episodes_completed: number;
|
episodes_completed: number;
|
||||||
} | null;
|
} | null;
|
||||||
const mediaRows = restartedApi.db
|
const mediaRows = verificationDb
|
||||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media')
|
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media')
|
||||||
.get() as { total: number } | null;
|
.get() as { total: number } | null;
|
||||||
const animeRows = restartedApi.db
|
const animeRows = verificationDb
|
||||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime')
|
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime')
|
||||||
.get() as { total: number } | null;
|
.get() as { total: number } | null;
|
||||||
const appliedRows = restartedApi.db
|
verificationDb.close();
|
||||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
|
|
||||||
.get() as { total: number } | null;
|
|
||||||
|
|
||||||
assert.ok(sessionRow);
|
|
||||||
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
|
|
||||||
assert.equal(sessionRow?.status, 2);
|
|
||||||
assert.equal(sessionRow?.ended_media_ms, 321_000);
|
|
||||||
assert.equal(sessionRow?.active_watched_ms, 4000);
|
|
||||||
assert.equal(sessionRow?.tokens_seen, 120);
|
|
||||||
assert.equal(sessionRow?.cards_mined, 2);
|
|
||||||
|
|
||||||
assert.ok(globalRow);
|
assert.ok(globalRow);
|
||||||
assert.equal(globalRow?.total_sessions, 1);
|
assert.equal(globalRow?.total_sessions, 1);
|
||||||
@@ -785,7 +763,6 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
assert.equal(globalRow?.episodes_completed, 1);
|
assert.equal(globalRow?.episodes_completed, 1);
|
||||||
assert.equal(mediaRows?.total, 1);
|
assert.equal(mediaRows?.total, 1);
|
||||||
assert.equal(animeRows?.total, 1);
|
assert.equal(animeRows?.total, 1);
|
||||||
assert.equal(appliedRows?.total, 1);
|
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
@@ -1590,12 +1567,12 @@ test('applies configurable queue, flush, and retention policy', async () => {
|
|||||||
queueCap: number;
|
queueCap: number;
|
||||||
maxPayloadBytes: number;
|
maxPayloadBytes: number;
|
||||||
maintenanceIntervalMs: number;
|
maintenanceIntervalMs: number;
|
||||||
eventsRetentionMs: number;
|
eventsRetentionMs: string | null;
|
||||||
telemetryRetentionMs: number;
|
telemetryRetentionMs: string | null;
|
||||||
sessionsRetentionMs: number;
|
sessionsRetentionMs: string | null;
|
||||||
dailyRollupRetentionMs: number;
|
dailyRollupRetentionMs: string | null;
|
||||||
monthlyRollupRetentionMs: number;
|
monthlyRollupRetentionMs: string | null;
|
||||||
vacuumIntervalMs: number;
|
vacuumIntervalMs: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(privateApi.batchSize, 10);
|
assert.equal(privateApi.batchSize, 10);
|
||||||
@@ -1603,12 +1580,12 @@ test('applies configurable queue, flush, and retention policy', async () => {
|
|||||||
assert.equal(privateApi.queueCap, 1500);
|
assert.equal(privateApi.queueCap, 1500);
|
||||||
assert.equal(privateApi.maxPayloadBytes, 512);
|
assert.equal(privateApi.maxPayloadBytes, 512);
|
||||||
assert.equal(privateApi.maintenanceIntervalMs, 7_200_000);
|
assert.equal(privateApi.maintenanceIntervalMs, 7_200_000);
|
||||||
assert.equal(privateApi.eventsRetentionMs, 14 * 86_400_000);
|
assert.equal(privateApi.eventsRetentionMs, '1209600000');
|
||||||
assert.equal(privateApi.telemetryRetentionMs, 45 * 86_400_000);
|
assert.equal(privateApi.telemetryRetentionMs, '3888000000');
|
||||||
assert.equal(privateApi.sessionsRetentionMs, 60 * 86_400_000);
|
assert.equal(privateApi.sessionsRetentionMs, '5184000000');
|
||||||
assert.equal(privateApi.dailyRollupRetentionMs, 730 * 86_400_000);
|
assert.equal(privateApi.dailyRollupRetentionMs, '63072000000');
|
||||||
assert.equal(privateApi.monthlyRollupRetentionMs, 3650 * 86_400_000);
|
assert.equal(privateApi.monthlyRollupRetentionMs, '315360000000');
|
||||||
assert.equal(privateApi.vacuumIntervalMs, 14 * 86_400_000);
|
assert.equal(privateApi.vacuumIntervalMs, '1209600000');
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
@@ -1638,21 +1615,21 @@ test('zero retention days disables prune checks while preserving rollups', async
|
|||||||
const privateApi = tracker as unknown as {
|
const privateApi = tracker as unknown as {
|
||||||
runMaintenance: () => void;
|
runMaintenance: () => void;
|
||||||
db: DatabaseSync;
|
db: DatabaseSync;
|
||||||
eventsRetentionMs: number;
|
eventsRetentionMs: string | null;
|
||||||
telemetryRetentionMs: number;
|
telemetryRetentionMs: string | null;
|
||||||
sessionsRetentionMs: number;
|
sessionsRetentionMs: string | null;
|
||||||
dailyRollupRetentionMs: number;
|
dailyRollupRetentionMs: string | null;
|
||||||
monthlyRollupRetentionMs: number;
|
monthlyRollupRetentionMs: string | null;
|
||||||
vacuumIntervalMs: number;
|
vacuumIntervalMs: string | null;
|
||||||
lastVacuumMs: number;
|
lastVacuumMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(privateApi.eventsRetentionMs, Number.POSITIVE_INFINITY);
|
assert.equal(privateApi.eventsRetentionMs, null);
|
||||||
assert.equal(privateApi.telemetryRetentionMs, Number.POSITIVE_INFINITY);
|
assert.equal(privateApi.telemetryRetentionMs, null);
|
||||||
assert.equal(privateApi.sessionsRetentionMs, Number.POSITIVE_INFINITY);
|
assert.equal(privateApi.sessionsRetentionMs, null);
|
||||||
assert.equal(privateApi.dailyRollupRetentionMs, Number.POSITIVE_INFINITY);
|
assert.equal(privateApi.dailyRollupRetentionMs, null);
|
||||||
assert.equal(privateApi.monthlyRollupRetentionMs, Number.POSITIVE_INFINITY);
|
assert.equal(privateApi.monthlyRollupRetentionMs, null);
|
||||||
assert.equal(privateApi.vacuumIntervalMs, Number.POSITIVE_INFINITY);
|
assert.equal(privateApi.vacuumIntervalMs, null);
|
||||||
assert.equal(privateApi.lastVacuumMs, 0);
|
assert.equal(privateApi.lastVacuumMs, 0);
|
||||||
|
|
||||||
const nowMs = trackerNowMs();
|
const nowMs = trackerNowMs();
|
||||||
|
|||||||
@@ -101,18 +101,13 @@ import {
|
|||||||
import { DEFAULT_MIN_WATCH_RATIO } from '../../shared/watch-threshold';
|
import { DEFAULT_MIN_WATCH_RATIO } from '../../shared/watch-threshold';
|
||||||
import { enqueueWrite } from './immersion-tracker/queue';
|
import { enqueueWrite } from './immersion-tracker/queue';
|
||||||
import { nowMs } from './immersion-tracker/time';
|
import { nowMs } from './immersion-tracker/time';
|
||||||
|
import { toDbMs } from './immersion-tracker/query-shared';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BATCH_SIZE,
|
DEFAULT_BATCH_SIZE,
|
||||||
DEFAULT_DAILY_ROLLUP_RETENTION_MS,
|
|
||||||
DEFAULT_EVENTS_RETENTION_MS,
|
|
||||||
DEFAULT_FLUSH_INTERVAL_MS,
|
DEFAULT_FLUSH_INTERVAL_MS,
|
||||||
DEFAULT_MAINTENANCE_INTERVAL_MS,
|
DEFAULT_MAINTENANCE_INTERVAL_MS,
|
||||||
DEFAULT_MAX_PAYLOAD_BYTES,
|
DEFAULT_MAX_PAYLOAD_BYTES,
|
||||||
DEFAULT_MONTHLY_ROLLUP_RETENTION_MS,
|
|
||||||
DEFAULT_QUEUE_CAP,
|
DEFAULT_QUEUE_CAP,
|
||||||
DEFAULT_SESSIONS_RETENTION_MS,
|
|
||||||
DEFAULT_TELEMETRY_RETENTION_MS,
|
|
||||||
DEFAULT_VACUUM_INTERVAL_MS,
|
|
||||||
EVENT_CARD_MINED,
|
EVENT_CARD_MINED,
|
||||||
EVENT_LOOKUP,
|
EVENT_LOOKUP,
|
||||||
EVENT_MEDIA_BUFFER,
|
EVENT_MEDIA_BUFFER,
|
||||||
@@ -306,12 +301,12 @@ export class ImmersionTrackerService {
|
|||||||
private readonly flushIntervalMs: number;
|
private readonly flushIntervalMs: number;
|
||||||
private readonly maintenanceIntervalMs: number;
|
private readonly maintenanceIntervalMs: number;
|
||||||
private readonly maxPayloadBytes: number;
|
private readonly maxPayloadBytes: number;
|
||||||
private readonly eventsRetentionMs: number;
|
private readonly eventsRetentionMs: string | null;
|
||||||
private readonly telemetryRetentionMs: number;
|
private readonly telemetryRetentionMs: string | null;
|
||||||
private readonly sessionsRetentionMs: number;
|
private readonly sessionsRetentionMs: string | null;
|
||||||
private readonly dailyRollupRetentionMs: number;
|
private readonly dailyRollupRetentionMs: string | null;
|
||||||
private readonly monthlyRollupRetentionMs: number;
|
private readonly monthlyRollupRetentionMs: string | null;
|
||||||
private readonly vacuumIntervalMs: number;
|
private readonly vacuumIntervalMs: string | null;
|
||||||
private readonly dbPath: string;
|
private readonly dbPath: string;
|
||||||
private readonly writeLock = { locked: false };
|
private readonly writeLock = { locked: false };
|
||||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -343,6 +338,12 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const policy = options.policy ?? {};
|
const policy = options.policy ?? {};
|
||||||
|
const DEFAULT_EVENTS_RETENTION_DAYS = 7;
|
||||||
|
const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
|
||||||
|
const DEFAULT_SESSIONS_RETENTION_DAYS = 30;
|
||||||
|
const DEFAULT_DAILY_ROLLUP_RETENTION_DAYS = 365;
|
||||||
|
const DEFAULT_MONTHLY_ROLLUP_RETENTION_DAYS = 5 * 365;
|
||||||
|
const DEFAULT_VACUUM_INTERVAL_DAYS = 7;
|
||||||
this.queueCap = resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000);
|
this.queueCap = resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000);
|
||||||
this.batchSize = resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000);
|
this.batchSize = resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000);
|
||||||
this.flushIntervalMs = resolveBoundedInt(
|
this.flushIntervalMs = resolveBoundedInt(
|
||||||
@@ -367,42 +368,43 @@ export class ImmersionTrackerService {
|
|||||||
const retention = policy.retention ?? {};
|
const retention = policy.retention ?? {};
|
||||||
const daysToRetentionMs = (
|
const daysToRetentionMs = (
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
fallbackMs: number,
|
fallbackDays: number,
|
||||||
maxDays: number,
|
maxDays: number,
|
||||||
): number => {
|
): string | null => {
|
||||||
const fallbackDays = Math.floor(fallbackMs / 86_400_000);
|
|
||||||
const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays);
|
const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays);
|
||||||
return resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000;
|
return resolvedDays === 0
|
||||||
|
? null
|
||||||
|
: (BigInt(`${resolvedDays}`) * 86_400_000n).toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.eventsRetentionMs = daysToRetentionMs(
|
this.eventsRetentionMs = daysToRetentionMs(
|
||||||
retention.eventsDays,
|
retention.eventsDays,
|
||||||
DEFAULT_EVENTS_RETENTION_MS,
|
DEFAULT_EVENTS_RETENTION_DAYS,
|
||||||
3650,
|
3650,
|
||||||
);
|
);
|
||||||
this.telemetryRetentionMs = daysToRetentionMs(
|
this.telemetryRetentionMs = daysToRetentionMs(
|
||||||
retention.telemetryDays,
|
retention.telemetryDays,
|
||||||
DEFAULT_TELEMETRY_RETENTION_MS,
|
DEFAULT_TELEMETRY_RETENTION_DAYS,
|
||||||
3650,
|
3650,
|
||||||
);
|
);
|
||||||
this.sessionsRetentionMs = daysToRetentionMs(
|
this.sessionsRetentionMs = daysToRetentionMs(
|
||||||
retention.sessionsDays,
|
retention.sessionsDays,
|
||||||
DEFAULT_SESSIONS_RETENTION_MS,
|
DEFAULT_SESSIONS_RETENTION_DAYS,
|
||||||
3650,
|
3650,
|
||||||
);
|
);
|
||||||
this.dailyRollupRetentionMs = daysToRetentionMs(
|
this.dailyRollupRetentionMs = daysToRetentionMs(
|
||||||
retention.dailyRollupsDays,
|
retention.dailyRollupsDays,
|
||||||
DEFAULT_DAILY_ROLLUP_RETENTION_MS,
|
DEFAULT_DAILY_ROLLUP_RETENTION_DAYS,
|
||||||
36500,
|
36500,
|
||||||
);
|
);
|
||||||
this.monthlyRollupRetentionMs = daysToRetentionMs(
|
this.monthlyRollupRetentionMs = daysToRetentionMs(
|
||||||
retention.monthlyRollupsDays,
|
retention.monthlyRollupsDays,
|
||||||
DEFAULT_MONTHLY_ROLLUP_RETENTION_MS,
|
DEFAULT_MONTHLY_ROLLUP_RETENTION_DAYS,
|
||||||
36500,
|
36500,
|
||||||
);
|
);
|
||||||
this.vacuumIntervalMs = daysToRetentionMs(
|
this.vacuumIntervalMs = daysToRetentionMs(
|
||||||
retention.vacuumIntervalDays,
|
retention.vacuumIntervalDays,
|
||||||
DEFAULT_VACUUM_INTERVAL_MS,
|
DEFAULT_VACUUM_INTERVAL_DAYS,
|
||||||
3650,
|
3650,
|
||||||
);
|
);
|
||||||
this.db = new Database(this.dbPath);
|
this.db = new Database(this.dbPath);
|
||||||
@@ -1596,9 +1598,9 @@ export class ImmersionTrackerService {
|
|||||||
const maintenanceNowMs = nowMs();
|
const maintenanceNowMs = nowMs();
|
||||||
this.runRollupMaintenance(false);
|
this.runRollupMaintenance(false);
|
||||||
if (
|
if (
|
||||||
Number.isFinite(this.eventsRetentionMs) ||
|
this.eventsRetentionMs !== null ||
|
||||||
Number.isFinite(this.telemetryRetentionMs) ||
|
this.telemetryRetentionMs !== null ||
|
||||||
Number.isFinite(this.sessionsRetentionMs)
|
this.sessionsRetentionMs !== null
|
||||||
) {
|
) {
|
||||||
pruneRawRetention(this.db, maintenanceNowMs, {
|
pruneRawRetention(this.db, maintenanceNowMs, {
|
||||||
eventsRetentionMs: this.eventsRetentionMs,
|
eventsRetentionMs: this.eventsRetentionMs,
|
||||||
@@ -1607,8 +1609,8 @@ export class ImmersionTrackerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
Number.isFinite(this.dailyRollupRetentionMs) ||
|
this.dailyRollupRetentionMs !== null ||
|
||||||
Number.isFinite(this.monthlyRollupRetentionMs)
|
this.monthlyRollupRetentionMs !== null
|
||||||
) {
|
) {
|
||||||
pruneRollupRetention(this.db, maintenanceNowMs, {
|
pruneRollupRetention(this.db, maintenanceNowMs, {
|
||||||
dailyRollupRetentionMs: this.dailyRollupRetentionMs,
|
dailyRollupRetentionMs: this.dailyRollupRetentionMs,
|
||||||
@@ -1617,8 +1619,9 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.vacuumIntervalMs > 0 &&
|
this.vacuumIntervalMs !== null &&
|
||||||
maintenanceNowMs - this.lastVacuumMs >= this.vacuumIntervalMs &&
|
BigInt(toDbMs(maintenanceNowMs)) - BigInt(toDbMs(this.lastVacuumMs)) >=
|
||||||
|
BigInt(this.vacuumIntervalMs) &&
|
||||||
!this.writeLock.locked
|
!this.writeLock.locked
|
||||||
) {
|
) {
|
||||||
this.db.exec('VACUUM');
|
this.db.exec('VACUUM');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { Database } from '../sqlite.js';
|
import { Database, type DatabaseSync } from '../sqlite.js';
|
||||||
import {
|
import {
|
||||||
createTrackerPreparedStatements,
|
createTrackerPreparedStatements,
|
||||||
ensureSchema,
|
ensureSchema,
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
EVENT_SUBTITLE_LINE,
|
EVENT_SUBTITLE_LINE,
|
||||||
EVENT_YOMITAN_LOOKUP,
|
EVENT_YOMITAN_LOOKUP,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { nowMs } from '../time.js';
|
||||||
|
|
||||||
function makeDbPath(): string {
|
function makeDbPath(): string {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-query-test-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-query-test-'));
|
||||||
@@ -81,6 +82,23 @@ function cleanupDbPath(dbPath: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSqliteLocalMidnightMs(db: DatabaseSync): number {
|
||||||
|
const nowSeconds = Math.floor(nowMs() / 1000);
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT (
|
||||||
|
CAST(strftime('%s', ?,'unixepoch','localtime') AS INTEGER)
|
||||||
|
- CAST(strftime('%H', ?,'unixepoch','localtime') AS INTEGER) * 3600
|
||||||
|
- CAST(strftime('%M', ?,'unixepoch','localtime') AS INTEGER) * 60
|
||||||
|
- CAST(strftime('%S', ?,'unixepoch','localtime') AS INTEGER)
|
||||||
|
) AS value
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(nowSeconds, nowSeconds, nowSeconds, nowSeconds) as { value: number } | null;
|
||||||
|
return row?.value ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T {
|
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T {
|
||||||
const realDate = Date;
|
const realDate = Date;
|
||||||
const fixedDateMs = fixedDate.getTime();
|
const fixedDateMs = fixedDate.getTime();
|
||||||
@@ -743,18 +761,30 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
parseMetadataJson: null,
|
parseMetadataJson: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const beforeMidnight = new Date(2026, 2, 1, 23, 30).getTime();
|
const baseMidnightSec = getSqliteLocalMidnightMs(db);
|
||||||
const afterMidnight = new Date(2026, 2, 2, 0, 30).getTime();
|
const beforeMidnightSec = baseMidnightSec - 30 * 60;
|
||||||
const firstSessionId = startSessionRecord(db, videoId, beforeMidnight).sessionId;
|
const afterMidnightSec = baseMidnightSec + 30 * 60;
|
||||||
const secondSessionId = startSessionRecord(db, videoId, afterMidnight).sessionId;
|
const beforeMidnight = `${beforeMidnightSec}000`;
|
||||||
|
const afterMidnight = `${afterMidnightSec}000`;
|
||||||
|
const firstSessionId = startSessionRecord(
|
||||||
|
db,
|
||||||
|
videoId,
|
||||||
|
beforeMidnight as unknown as number,
|
||||||
|
).sessionId;
|
||||||
|
const secondSessionId = startSessionRecord(
|
||||||
|
db,
|
||||||
|
videoId,
|
||||||
|
afterMidnight as unknown as number,
|
||||||
|
).sessionId;
|
||||||
|
|
||||||
for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [
|
for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [
|
||||||
[firstSessionId, beforeMidnight, 100, 4],
|
[firstSessionId, beforeMidnight, 100, 4],
|
||||||
[secondSessionId, afterMidnight, 120, 6],
|
[secondSessionId, afterMidnight, 120, 6],
|
||||||
] as const) {
|
] as const) {
|
||||||
|
const startedAtPlus60Ms = `${BigInt(startedAtMs) + 60000n}`;
|
||||||
stmts.telemetryInsertStmt.run(
|
stmts.telemetryInsertStmt.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
60_000,
|
60_000,
|
||||||
60_000,
|
60_000,
|
||||||
1,
|
1,
|
||||||
@@ -767,8 +797,8 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
);
|
);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -787,7 +817,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
WHERE session_id = ?
|
WHERE session_id = ?
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
60_000,
|
60_000,
|
||||||
60_000,
|
60_000,
|
||||||
1,
|
1,
|
||||||
@@ -795,18 +825,17 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
lookupCount,
|
lookupCount,
|
||||||
lookupCount,
|
lookupCount,
|
||||||
lookupCount,
|
lookupCount,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||||
assert.equal(dashboard.progress.lookups.length, 2);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
dashboard.progress.lookups.map((point) => point.value),
|
dashboard.progress.lookups.map((point) => point.value),
|
||||||
[4, 10],
|
[10],
|
||||||
);
|
);
|
||||||
assert.equal(dashboard.ratios.lookupsPerHundred.length, 2);
|
assert.equal(dashboard.ratios.lookupsPerHundred.length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
@@ -816,8 +845,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => {
|
try {
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const stmts = createTrackerPreparedStatements(db);
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', {
|
const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', {
|
||||||
@@ -862,18 +890,30 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
parseMetadataJson: null,
|
parseMetadataJson: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime();
|
const baseMidnightSec = getSqliteLocalMidnightMs(db);
|
||||||
const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime();
|
const febStartedAtSec = baseMidnightSec - 40 * 86_400;
|
||||||
const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId;
|
const marStartedAtSec = baseMidnightSec - 10 * 86_400;
|
||||||
const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId;
|
const febStartedAtMs = `${febStartedAtSec}000`;
|
||||||
|
const marStartedAtMs = `${marStartedAtSec}000`;
|
||||||
|
const febSessionId = startSessionRecord(
|
||||||
|
db,
|
||||||
|
febVideoId,
|
||||||
|
febStartedAtMs as unknown as number,
|
||||||
|
).sessionId;
|
||||||
|
const marSessionId = startSessionRecord(
|
||||||
|
db,
|
||||||
|
marVideoId,
|
||||||
|
marStartedAtMs as unknown as number,
|
||||||
|
).sessionId;
|
||||||
|
|
||||||
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
|
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
|
||||||
[febSessionId, febStartedAtMs, 100, 2, 3],
|
[febSessionId, febStartedAtMs, 100, 2, 3],
|
||||||
[marSessionId, marStartedAtMs, 120, 4, 5],
|
[marSessionId, marStartedAtMs, 120, 4, 5],
|
||||||
] as const) {
|
] as const) {
|
||||||
|
const startedAtPlus60Ms = `${BigInt(startedAtMs) + 60000n}`;
|
||||||
stmts.telemetryInsertStmt.run(
|
stmts.telemetryInsertStmt.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
30 * 60_000,
|
30 * 60_000,
|
||||||
30 * 60_000,
|
30 * 60_000,
|
||||||
4,
|
4,
|
||||||
@@ -886,8 +926,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
);
|
);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -907,16 +947,16 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
WHERE session_id = ?
|
WHERE session_id = ?
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
30 * 60_000,
|
`${30 * 60_000}`,
|
||||||
30 * 60_000,
|
`${30 * 60_000}`,
|
||||||
4,
|
4,
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
yomitanLookupCount,
|
yomitanLookupCount,
|
||||||
yomitanLookupCount,
|
yomitanLookupCount,
|
||||||
yomitanLookupCount,
|
yomitanLookupCount,
|
||||||
startedAtMs + 60_000,
|
startedAtPlus60Ms as unknown as number,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -937,12 +977,30 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const febEpochDay = Math.floor(febStartedAtMs / 86_400_000);
|
const febEpochDay = db
|
||||||
const marEpochDay = Math.floor(marStartedAtMs / 86_400_000);
|
.prepare(
|
||||||
insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
`SELECT CAST(julianday(?,'unixepoch','localtime') - 2440587.5 AS INTEGER) AS value`,
|
||||||
insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
)
|
||||||
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
.get(febStartedAtSec) as { value: number } | null;
|
||||||
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
const marEpochDay = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT CAST(julianday(?,'unixepoch','localtime') - 2440587.5 AS INTEGER) AS value`,
|
||||||
|
)
|
||||||
|
.get(marStartedAtSec) as { value: number } | null;
|
||||||
|
const febMonthKey = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT CAST(strftime('%Y%m', ?,'unixepoch','localtime') AS INTEGER) AS value`,
|
||||||
|
)
|
||||||
|
.get(febStartedAtSec) as { value: number } | null;
|
||||||
|
const marMonthKey = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT CAST(strftime('%Y%m', ?,'unixepoch','localtime') AS INTEGER) AS value`,
|
||||||
|
)
|
||||||
|
.get(marStartedAtSec) as { value: number } | null;
|
||||||
|
insertDailyRollup.run(febEpochDay?.value ?? 0, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||||
|
insertDailyRollup.run(marEpochDay?.value ?? 0, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||||
|
insertMonthlyRollup.run(febMonthKey?.value ?? 0, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||||
|
insertMonthlyRollup.run(marMonthKey?.value ?? 0, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -958,8 +1016,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
'名詞',
|
'名詞',
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
Math.floor(febStartedAtMs / 1000),
|
febStartedAtSec,
|
||||||
Math.floor(febStartedAtMs / 1000),
|
febStartedAtSec,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -976,12 +1034,12 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
'名詞',
|
'名詞',
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
Math.floor(marStartedAtMs / 1000),
|
marStartedAtSec,
|
||||||
Math.floor(marStartedAtMs / 1000),
|
marStartedAtSec,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
const dashboard = getTrendsDashboard(db, '90d', 'month');
|
||||||
|
|
||||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -996,11 +1054,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
dashboard.progress.lookups.map((point) => point.label),
|
dashboard.progress.lookups.map((point) => point.label),
|
||||||
dashboard.activity.watchTime.map((point) => point.label),
|
dashboard.activity.watchTime.map((point) => point.label),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||||
@@ -1077,55 +1134,51 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
|
|||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => {
|
try {
|
||||||
try {
|
ensureSchema(db);
|
||||||
ensureSchema(db);
|
|
||||||
|
|
||||||
const insertWord = db.prepare(
|
const insertWord = db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_words (
|
INSERT INTO imm_words (
|
||||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const justBeforeWeekBoundary = Math.floor(
|
const todayStartSec = getSqliteLocalMidnightMs(db);
|
||||||
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000,
|
const weekBoundarySec = todayStartSec - 7 * 86_400;
|
||||||
);
|
const justBeforeWeekBoundary = weekBoundarySec - 30 * 60;
|
||||||
const justAfterWeekBoundary = Math.floor(
|
const justAfterWeekBoundary = weekBoundarySec + 30 * 60;
|
||||||
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
|
insertWord.run(
|
||||||
);
|
'境界前',
|
||||||
insertWord.run(
|
'境界前',
|
||||||
'境界前',
|
'きょうかいまえ',
|
||||||
'境界前',
|
'noun',
|
||||||
'きょうかいまえ',
|
'名詞',
|
||||||
'noun',
|
'',
|
||||||
'名詞',
|
'',
|
||||||
'',
|
justBeforeWeekBoundary,
|
||||||
'',
|
justBeforeWeekBoundary,
|
||||||
justBeforeWeekBoundary,
|
1,
|
||||||
justBeforeWeekBoundary,
|
);
|
||||||
1,
|
insertWord.run(
|
||||||
);
|
'境界後',
|
||||||
insertWord.run(
|
'境界後',
|
||||||
'境界後',
|
'きょうかいご',
|
||||||
'境界後',
|
'noun',
|
||||||
'きょうかいご',
|
'名詞',
|
||||||
'noun',
|
'',
|
||||||
'名詞',
|
'',
|
||||||
'',
|
justAfterWeekBoundary,
|
||||||
'',
|
justAfterWeekBoundary,
|
||||||
justAfterWeekBoundary,
|
1,
|
||||||
justAfterWeekBoundary,
|
);
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hints = getQueryHints(db);
|
const hints = getQueryHints(db);
|
||||||
assert.equal(hints.newWordsThisWeek, 1);
|
assert.equal(hints.newWordsThisWeek, 1);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
||||||
@@ -1135,9 +1188,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
|
|||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
|
|
||||||
const now = new Date();
|
const todayStartSec = getSqliteLocalMidnightMs(db);
|
||||||
const todayStartSec =
|
|
||||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
|
||||||
const oneHourAgo = todayStartSec + 3_600;
|
const oneHourAgo = todayStartSec + 3_600;
|
||||||
const twoDaysAgo = todayStartSec - 2 * 86_400;
|
const twoDaysAgo = todayStartSec - 2 * 86_400;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { DatabaseSync } from './sqlite';
|
|||||||
import { finalizeSessionRecord } from './session';
|
import { finalizeSessionRecord } from './session';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { toDbMs } from './query-shared';
|
import { toDbMs } from './query-shared';
|
||||||
|
import { toDbSeconds } from './query-shared';
|
||||||
import type { LifetimeRebuildSummary, SessionState } from './types';
|
import type { LifetimeRebuildSummary, SessionState } from './types';
|
||||||
|
|
||||||
interface TelemetryRow {
|
interface TelemetryRow {
|
||||||
@@ -64,9 +65,10 @@ interface RetainedSessionRow {
|
|||||||
function hasRetainedPriorSession(
|
function hasRetainedPriorSession(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoId: number,
|
videoId: number,
|
||||||
startedAtMs: number,
|
startedAtMs: number | string,
|
||||||
currentSessionId: number,
|
currentSessionId: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const startedAtDbMs = toDbMs(startedAtMs);
|
||||||
return (
|
return (
|
||||||
Number(
|
Number(
|
||||||
(
|
(
|
||||||
@@ -82,7 +84,7 @@ function hasRetainedPriorSession(
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(videoId, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null
|
.get(videoId, startedAtDbMs, startedAtDbMs, currentSessionId) as ExistenceRow | null
|
||||||
)?.count ?? 0,
|
)?.count ?? 0,
|
||||||
) > 0
|
) > 0
|
||||||
);
|
);
|
||||||
@@ -91,27 +93,28 @@ function hasRetainedPriorSession(
|
|||||||
function isFirstSessionForLocalDay(
|
function isFirstSessionForLocalDay(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
currentSessionId: number,
|
currentSessionId: number,
|
||||||
startedAtMs: number,
|
startedAtMs: number | string,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const startedAtDbSeconds = toDbSeconds(startedAtMs);
|
||||||
const sameDayCount = Number(
|
const sameDayCount = Number(
|
||||||
(
|
(
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
SELECT COUNT(*) AS count
|
SELECT COUNT(*) AS count
|
||||||
FROM imm_sessions
|
FROM imm_sessions
|
||||||
WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(? / 1000, 'unixepoch', 'localtime')
|
WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(?,'unixepoch','localtime')
|
||||||
AND (
|
AND (
|
||||||
started_at_ms < ?
|
started_at_ms < ?
|
||||||
OR (started_at_ms = ? AND session_id < ?)
|
OR (started_at_ms = ? AND session_id < ?)
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(startedAtMs, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null
|
.get(startedAtDbSeconds, toDbMs(startedAtMs), toDbMs(startedAtMs), currentSessionId) as ExistenceRow | null
|
||||||
)?.count ?? 0
|
)?.count ?? 0
|
||||||
);
|
);
|
||||||
return sameDayCount === 0;
|
return sameDayCount === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetLifetimeSummaries(db: DatabaseSync, nowMs: string): void {
|
function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
DELETE FROM imm_lifetime_anime;
|
DELETE FROM imm_lifetime_anime;
|
||||||
DELETE FROM imm_lifetime_media;
|
DELETE FROM imm_lifetime_media;
|
||||||
@@ -132,12 +135,12 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: string): void {
|
|||||||
LAST_UPDATE_DATE = ?
|
LAST_UPDATE_DATE = ?
|
||||||
WHERE global_id = 1
|
WHERE global_id = 1
|
||||||
`,
|
`,
|
||||||
).run(nowMs, nowMs);
|
).run(toDbMs(nowMs), toDbMs(nowMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildLifetimeSummariesInternal(
|
function rebuildLifetimeSummariesInternal(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
rebuiltAtMs: string,
|
rebuiltAtMs: number,
|
||||||
): LifetimeRebuildSummary {
|
): LifetimeRebuildSummary {
|
||||||
const sessions = db
|
const sessions = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -145,8 +148,8 @@ function rebuildLifetimeSummariesInternal(
|
|||||||
SELECT
|
SELECT
|
||||||
session_id AS sessionId,
|
session_id AS sessionId,
|
||||||
video_id AS videoId,
|
video_id AS videoId,
|
||||||
started_at_ms AS startedAtMs,
|
CAST(started_at_ms AS INTEGER) AS startedAtMs,
|
||||||
ended_at_ms AS endedAtMs,
|
CAST(ended_at_ms AS INTEGER) AS endedAtMs,
|
||||||
total_watched_ms AS totalWatchedMs,
|
total_watched_ms AS totalWatchedMs,
|
||||||
active_watched_ms AS activeWatchedMs,
|
active_watched_ms AS activeWatchedMs,
|
||||||
lines_seen AS linesSeen,
|
lines_seen AS linesSeen,
|
||||||
@@ -174,21 +177,18 @@ function rebuildLifetimeSummariesInternal(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
appliedSessions: sessions.length,
|
appliedSessions: sessions.length,
|
||||||
rebuiltAtMs: Number(rebuiltAtMs),
|
rebuiltAtMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
||||||
const startedAtMs = Number(row.startedAtMs);
|
|
||||||
const endedAtMs = Number(row.endedAtMs);
|
|
||||||
const lastMediaMs = row.lastMediaMs === null ? null : Number(row.lastMediaMs);
|
|
||||||
return {
|
return {
|
||||||
sessionId: row.sessionId,
|
sessionId: row.sessionId,
|
||||||
videoId: row.videoId,
|
videoId: row.videoId,
|
||||||
startedAtMs,
|
startedAtMs: row.startedAtMs as unknown as number,
|
||||||
currentLineIndex: 0,
|
currentLineIndex: 0,
|
||||||
lastWallClockMs: endedAtMs,
|
lastWallClockMs: row.endedAtMs as unknown as number,
|
||||||
lastMediaMs,
|
lastMediaMs: row.lastMediaMs === null ? null : (row.lastMediaMs as unknown as number),
|
||||||
lastPauseStartMs: null,
|
lastPauseStartMs: null,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
pendingTelemetry: false,
|
pendingTelemetry: false,
|
||||||
@@ -216,8 +216,8 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
|
|||||||
SELECT
|
SELECT
|
||||||
s.session_id AS sessionId,
|
s.session_id AS sessionId,
|
||||||
s.video_id AS videoId,
|
s.video_id AS videoId,
|
||||||
s.started_at_ms AS startedAtMs,
|
CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
|
||||||
COALESCE(t.sample_ms, s.LAST_UPDATE_DATE, s.started_at_ms) AS endedAtMs,
|
CAST(COALESCE(t.sample_ms, s.LAST_UPDATE_DATE, s.started_at_ms) AS INTEGER) AS endedAtMs,
|
||||||
s.ended_media_ms AS lastMediaMs,
|
s.ended_media_ms AS lastMediaMs,
|
||||||
COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
@@ -528,7 +528,7 @@ export function applySessionLifetimeSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary {
|
export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary {
|
||||||
const rebuiltAtMs = toDbMs(nowMs());
|
const rebuiltAtMs = nowMs();
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs);
|
const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs);
|
||||||
@@ -542,7 +542,7 @@ export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSumma
|
|||||||
|
|
||||||
export function rebuildLifetimeSummariesInTransaction(
|
export function rebuildLifetimeSummariesInTransaction(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
rebuiltAtMs = toDbMs(nowMs()),
|
rebuiltAtMs = nowMs(),
|
||||||
): LifetimeRebuildSummary {
|
): LifetimeRebuildSummary {
|
||||||
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
|
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const result = pruneRawRetention(db, nowMs, {
|
const result = pruneRawRetention(db, nowMs, {
|
||||||
eventsRetentionMs: 120_000_000,
|
eventsRetentionMs: '120000000',
|
||||||
telemetryRetentionMs: 80_000_000,
|
telemetryRetentionMs: '80000000',
|
||||||
sessionsRetentionMs: 300_000_000,
|
sessionsRetentionMs: '300000000',
|
||||||
});
|
});
|
||||||
|
|
||||||
const remainingSessions = db
|
const remainingSessions = db
|
||||||
@@ -129,9 +129,9 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
pruneRawRetention(db, nowMs, {
|
pruneRawRetention(db, nowMs, {
|
||||||
eventsRetentionMs: 120_000_000,
|
eventsRetentionMs: '120000000',
|
||||||
telemetryRetentionMs: 120_000_000,
|
telemetryRetentionMs: '120000000',
|
||||||
sessionsRetentionMs: 120_000_000,
|
sessionsRetentionMs: '120000000',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rollupsAfterRawPrune = db
|
const rollupsAfterRawPrune = db
|
||||||
@@ -145,8 +145,8 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
|||||||
assert.equal(monthlyAfterRawPrune?.total, 1);
|
assert.equal(monthlyAfterRawPrune?.total, 1);
|
||||||
|
|
||||||
const rollupPrune = pruneRollupRetention(db, nowMs, {
|
const rollupPrune = pruneRollupRetention(db, nowMs, {
|
||||||
dailyRollupRetentionMs: 120_000_000,
|
dailyRollupRetentionMs: '120000000',
|
||||||
monthlyRollupRetentionMs: 1,
|
monthlyRollupRetentionMs: '1',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rollupsAfterRollupPrune = db
|
const rollupsAfterRollupPrune = db
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { toDbMs } from './query-shared';
|
import { subtractDbMs, toDbMs, toDbSeconds } from './query-shared';
|
||||||
|
|
||||||
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
||||||
const DAILY_MS = 86_400_000;
|
const DAILY_MS = 86_400_000;
|
||||||
@@ -48,30 +48,35 @@ export function pruneRawRetention(
|
|||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
policy: {
|
policy: {
|
||||||
eventsRetentionMs: number;
|
eventsRetentionMs: string | null;
|
||||||
telemetryRetentionMs: number;
|
telemetryRetentionMs: string | null;
|
||||||
sessionsRetentionMs: number;
|
sessionsRetentionMs: string | null;
|
||||||
},
|
},
|
||||||
): RawRetentionResult {
|
): RawRetentionResult {
|
||||||
const eventCutoff = nowMs - policy.eventsRetentionMs;
|
const deletedSessionEvents =
|
||||||
const telemetryCutoff = nowMs - policy.telemetryRetentionMs;
|
policy.eventsRetentionMs === null
|
||||||
const sessionsCutoff = nowMs - policy.sessionsRetentionMs;
|
? 0
|
||||||
|
: (
|
||||||
const deletedSessionEvents = (
|
db
|
||||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(toDbMs(eventCutoff)) as {
|
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||||
changes: number;
|
.run(subtractDbMs(nowMs, policy.eventsRetentionMs)) as { changes: number }
|
||||||
}
|
).changes;
|
||||||
).changes;
|
const deletedTelemetryRows =
|
||||||
const deletedTelemetryRows = (
|
policy.telemetryRetentionMs === null
|
||||||
db
|
? 0
|
||||||
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
: (
|
||||||
.run(toDbMs(telemetryCutoff)) as { changes: number }
|
db
|
||||||
).changes;
|
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||||
const deletedEndedSessions = (
|
.run(subtractDbMs(nowMs, policy.telemetryRetentionMs)) as { changes: number }
|
||||||
db
|
).changes;
|
||||||
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
|
const deletedEndedSessions =
|
||||||
.run(toDbMs(sessionsCutoff)) as { changes: number }
|
policy.sessionsRetentionMs === null
|
||||||
).changes;
|
? 0
|
||||||
|
: (
|
||||||
|
db
|
||||||
|
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
|
||||||
|
.run(subtractDbMs(nowMs, policy.sessionsRetentionMs)) as { changes: number }
|
||||||
|
).changes;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deletedSessionEvents,
|
deletedSessionEvents,
|
||||||
@@ -84,28 +89,40 @@ export function pruneRollupRetention(
|
|||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
policy: {
|
policy: {
|
||||||
dailyRollupRetentionMs: number;
|
dailyRollupRetentionMs: string | null;
|
||||||
monthlyRollupRetentionMs: number;
|
monthlyRollupRetentionMs: string | null;
|
||||||
},
|
},
|
||||||
): { deletedDailyRows: number; deletedMonthlyRows: number } {
|
): { deletedDailyRows: number; deletedMonthlyRows: number } {
|
||||||
const deletedDailyRows = Number.isFinite(policy.dailyRollupRetentionMs)
|
const currentMs = toDbMs(nowMs);
|
||||||
? (
|
const deletedDailyRows =
|
||||||
|
policy.dailyRollupRetentionMs === null
|
||||||
|
? 0
|
||||||
|
: (
|
||||||
db
|
db
|
||||||
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
|
.prepare(
|
||||||
.run(Math.floor((nowMs - policy.dailyRollupRetentionMs) / DAILY_MS)) as {
|
`DELETE FROM imm_daily_rollups
|
||||||
changes: number;
|
WHERE rollup_day < CAST(julianday(date(?,'unixepoch','localtime')) - 2440587.5 AS INTEGER) - ?`,
|
||||||
}
|
)
|
||||||
).changes
|
.run(
|
||||||
: 0;
|
toDbSeconds(currentMs),
|
||||||
const deletedMonthlyRows = Number.isFinite(policy.monthlyRollupRetentionMs)
|
Number(BigInt(policy.dailyRollupRetentionMs) / BigInt(DAILY_MS)),
|
||||||
? (
|
) as {
|
||||||
db
|
changes: number;
|
||||||
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
|
}
|
||||||
.run(toMonthKey(nowMs - policy.monthlyRollupRetentionMs)) as {
|
).changes;
|
||||||
changes: number;
|
const deletedMonthlyRows =
|
||||||
}
|
policy.monthlyRollupRetentionMs === null
|
||||||
).changes
|
? 0
|
||||||
: 0;
|
: (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM imm_monthly_rollups
|
||||||
|
WHERE rollup_month < CAST(strftime('%Y%m', ?,'unixepoch','localtime') AS INTEGER)`,
|
||||||
|
)
|
||||||
|
.run(toDbSeconds(subtractDbMs(currentMs, policy.monthlyRollupRetentionMs))) as {
|
||||||
|
changes: number;
|
||||||
|
}
|
||||||
|
).changes;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deletedDailyRows,
|
deletedDailyRows,
|
||||||
@@ -113,11 +130,11 @@ export function pruneRollupRetention(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastRollupSampleMs(db: DatabaseSync): number {
|
function getLastRollupSampleMs(db: DatabaseSync): string {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`)
|
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`)
|
||||||
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null;
|
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null;
|
||||||
return row ? Number(row.state_value) : ZERO_ID;
|
return row ? row.state_value : ZERO_ID.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLastRollupSampleMs(db: DatabaseSync, sampleMs: string | number | bigint): void {
|
function setLastRollupSampleMs(db: DatabaseSync, sampleMs: string | number | bigint): void {
|
||||||
@@ -263,7 +280,7 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
|
|
||||||
function getAffectedRollupGroups(
|
function getAffectedRollupGroups(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
lastRollupSampleMs: number,
|
lastRollupSampleMs: string,
|
||||||
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
|
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
|
||||||
return (
|
return (
|
||||||
db
|
db
|
||||||
@@ -370,7 +387,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID);
|
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID.toString());
|
||||||
if (affectedGroups.length === 0) {
|
if (affectedGroups.length === 0) {
|
||||||
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function getSessionEvents(
|
|||||||
): SessionEventRow[] {
|
): SessionEventRow[] {
|
||||||
if (!eventTypes || eventTypes.length === 0) {
|
if (!eventTypes || eventTypes.length === 0) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
SELECT event_type AS eventType, CAST(ts_ms AS INTEGER) AS tsMs, payload_json AS payload
|
||||||
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
|
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
|
||||||
`);
|
`);
|
||||||
return stmt.all(sessionId, limit) as SessionEventRow[];
|
return stmt.all(sessionId, limit) as SessionEventRow[];
|
||||||
@@ -139,7 +139,7 @@ export function getSessionEvents(
|
|||||||
|
|
||||||
const placeholders = eventTypes.map(() => '?').join(', ');
|
const placeholders = eventTypes.map(() => '?').join(', ');
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
SELECT event_type AS eventType, CAST(ts_ms AS INTEGER) AS tsMs, payload_json AS payload
|
||||||
FROM imm_session_events
|
FROM imm_session_events
|
||||||
WHERE session_id = ? AND event_type IN (${placeholders})
|
WHERE session_id = ? AND event_type IN (${placeholders})
|
||||||
ORDER BY ts_ms ASC
|
ORDER BY ts_ms ASC
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
|||||||
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COUNT(DISTINCT v.video_id) AS episodeCount,
|
COUNT(DISTINCT v.video_id) AS episodeCount,
|
||||||
a.episodes_total AS episodesTotal,
|
a.episodes_total AS episodesTotal,
|
||||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs
|
CAST(COALESCE(lm.last_watched_ms, 0) AS INTEGER) AS lastWatchedMs
|
||||||
FROM imm_anime a
|
FROM imm_anime a
|
||||||
JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
|
JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
|
||||||
JOIN imm_videos v ON v.anime_id = a.anime_id
|
JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||||
@@ -65,7 +65,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
|
|||||||
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||||
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
||||||
COUNT(DISTINCT v.video_id) AS episodeCount,
|
COUNT(DISTINCT v.video_id) AS episodeCount,
|
||||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs
|
CAST(COALESCE(lm.last_watched_ms, 0) AS INTEGER) AS lastWatchedMs
|
||||||
FROM imm_anime a
|
FROM imm_anime a
|
||||||
JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
|
JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
|
||||||
JOIN imm_videos v ON v.anime_id = a.anime_id
|
JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||||
@@ -110,7 +110,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
|||||||
v.parsed_season AS season,
|
v.parsed_season AS season,
|
||||||
v.parsed_episode AS episode,
|
v.parsed_episode AS episode,
|
||||||
v.duration_ms AS durationMs,
|
v.duration_ms AS durationMs,
|
||||||
(
|
CAST((
|
||||||
SELECT COALESCE(
|
SELECT COALESCE(
|
||||||
NULLIF(s_recent.ended_media_ms, 0),
|
NULLIF(s_recent.ended_media_ms, 0),
|
||||||
(
|
(
|
||||||
@@ -147,14 +147,14 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
|||||||
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
|
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
|
||||||
s_recent.session_id DESC
|
s_recent.session_id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS endedMediaMs,
|
) AS INTEGER) AS endedMediaMs,
|
||||||
v.watched AS watched,
|
v.watched AS watched,
|
||||||
COUNT(DISTINCT s.session_id) AS totalSessions,
|
COUNT(DISTINCT s.session_id) AS totalSessions,
|
||||||
COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0) AS totalActiveMs,
|
COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0) AS totalActiveMs,
|
||||||
COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0) AS totalCards,
|
COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0) AS totalCards,
|
||||||
COALESCE(SUM(COALESCE(asm.tokensSeen, s.tokens_seen, 0)), 0) AS totalTokensSeen,
|
COALESCE(SUM(COALESCE(asm.tokensSeen, s.tokens_seen, 0)), 0) AS totalTokensSeen,
|
||||||
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
||||||
MAX(s.started_at_ms) AS lastWatchedMs
|
CAST(MAX(s.started_at_ms) AS INTEGER) AS lastWatchedMs
|
||||||
FROM imm_videos v
|
FROM imm_videos v
|
||||||
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
@@ -182,7 +182,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
|||||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||||
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
|
CAST(COALESCE(lm.last_watched_ms, 0) AS INTEGER) AS lastWatchedMs,
|
||||||
yv.youtube_video_id AS youtubeVideoId,
|
yv.youtube_video_id AS youtubeVideoId,
|
||||||
yv.video_url AS videoUrl,
|
yv.video_url AS videoUrl,
|
||||||
yv.video_title AS videoTitle,
|
yv.video_title AS videoTitle,
|
||||||
@@ -261,8 +261,8 @@ export function getMediaSessions(
|
|||||||
s.session_id AS sessionId,
|
s.session_id AS sessionId,
|
||||||
s.video_id AS videoId,
|
s.video_id AS videoId,
|
||||||
v.canonical_title AS canonicalTitle,
|
v.canonical_title AS canonicalTitle,
|
||||||
s.started_at_ms AS startedAtMs,
|
CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
|
||||||
s.ended_at_ms AS endedAtMs,
|
CAST(s.ended_at_ms AS INTEGER) AS endedAtMs,
|
||||||
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
||||||
@@ -517,7 +517,7 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
|
|||||||
SELECT
|
SELECT
|
||||||
s.session_id AS sessionId, s.video_id AS videoId,
|
s.session_id AS sessionId, s.video_id AS videoId,
|
||||||
v.canonical_title AS canonicalTitle,
|
v.canonical_title AS canonicalTitle,
|
||||||
s.started_at_ms AS startedAtMs, s.ended_at_ms AS endedAtMs,
|
CAST(s.started_at_ms AS INTEGER) AS startedAtMs, CAST(s.ended_at_ms AS INTEGER) AS endedAtMs,
|
||||||
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
||||||
@@ -541,7 +541,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
|
|||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT e.event_id AS eventId, e.session_id AS sessionId,
|
SELECT e.event_id AS eventId, e.session_id AS sessionId,
|
||||||
e.ts_ms AS tsMs, e.cards_delta AS cardsDelta,
|
CAST(e.ts_ms AS INTEGER) AS tsMs, e.cards_delta AS cardsDelta,
|
||||||
e.payload_json AS payloadJson
|
e.payload_json AS payloadJson
|
||||||
FROM imm_session_events e
|
FROM imm_session_events e
|
||||||
JOIN imm_sessions s ON s.session_id = e.session_id
|
JOIN imm_sessions s ON s.session_id = e.session_id
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import type {
|
|||||||
SessionSummaryQueryRow,
|
SessionSummaryQueryRow,
|
||||||
SessionTimelineRow,
|
SessionTimelineRow,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { ACTIVE_SESSION_METRICS_CTE } from './query-shared';
|
import { ACTIVE_SESSION_METRICS_CTE, subtractDbMs, toDbMs, toDbSeconds } from './query-shared';
|
||||||
|
|
||||||
|
const THIRTY_DAYS_MS = '2592000000';
|
||||||
|
|
||||||
|
function localMidnightSecondsExpr(): string {
|
||||||
|
return `(CAST(strftime('%s', 'now', 'localtime') AS INTEGER) - CAST(strftime('%H', 'now', 'localtime') AS INTEGER) * 3600 - CAST(strftime('%M', 'now', 'localtime') AS INTEGER) * 60 - CAST(strftime('%S', 'now', 'localtime') AS INTEGER))`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
|
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
|
||||||
const prepared = db.prepare(`
|
const prepared = db.prepare(`
|
||||||
@@ -16,8 +22,8 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
|||||||
v.canonical_title AS canonicalTitle,
|
v.canonical_title AS canonicalTitle,
|
||||||
v.anime_id AS animeId,
|
v.anime_id AS animeId,
|
||||||
a.canonical_title AS animeTitle,
|
a.canonical_title AS animeTitle,
|
||||||
s.started_at_ms AS startedAtMs,
|
CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
|
||||||
s.ended_at_ms AS endedAtMs,
|
CAST(s.ended_at_ms AS INTEGER) AS endedAtMs,
|
||||||
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
||||||
@@ -43,7 +49,7 @@ export function getSessionTimeline(
|
|||||||
): SessionTimelineRow[] {
|
): SessionTimelineRow[] {
|
||||||
const select = `
|
const select = `
|
||||||
SELECT
|
SELECT
|
||||||
sample_ms AS sampleMs,
|
CAST(sample_ms AS INTEGER) AS sampleMs,
|
||||||
total_watched_ms AS totalWatchedMs,
|
total_watched_ms AS totalWatchedMs,
|
||||||
active_watched_ms AS activeWatchedMs,
|
active_watched_ms AS activeWatchedMs,
|
||||||
lines_seen AS linesSeen,
|
lines_seen AS linesSeen,
|
||||||
@@ -129,18 +135,13 @@ export function getSessionWordsByLine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
|
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
|
||||||
const now = new Date();
|
|
||||||
const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
|
||||||
const weekAgoSec =
|
|
||||||
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000;
|
|
||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
WITH headword_first_seen AS (
|
WITH headword_first_seen AS (
|
||||||
SELECT
|
SELECT
|
||||||
headword,
|
headword,
|
||||||
MIN(first_seen) AS first_seen
|
CAST(MIN(first_seen) AS INTEGER) AS first_seen
|
||||||
FROM imm_words
|
FROM imm_words
|
||||||
WHERE first_seen IS NOT NULL
|
WHERE first_seen IS NOT NULL
|
||||||
AND headword IS NOT NULL
|
AND headword IS NOT NULL
|
||||||
@@ -148,13 +149,12 @@ function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsTh
|
|||||||
GROUP BY headword
|
GROUP BY headword
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today,
|
COALESCE(SUM(CASE WHEN first_seen >= (${localMidnightSecondsExpr()}) THEN 1 ELSE 0 END), 0) AS today,
|
||||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week
|
COALESCE(SUM(CASE WHEN first_seen >= (${localMidnightSecondsExpr()} - 7 * 86400) THEN 1 ELSE 0 END), 0) AS week
|
||||||
FROM headword_first_seen
|
FROM headword_first_seen
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(todayStartSec, weekAgoSec) as { today: number; week: number } | null;
|
.get() as { today: number; week: number } | null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newWordsToday: Number(row?.today ?? 0),
|
newWordsToday: Number(row?.today ?? 0),
|
||||||
newWordsThisWeek: Number(row?.week ?? 0),
|
newWordsThisWeek: Number(row?.week ?? 0),
|
||||||
@@ -203,10 +203,7 @@ export function getQueryHints(db: DatabaseSync): {
|
|||||||
animeCompleted: number;
|
animeCompleted: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
const now = new Date();
|
const nowSeconds = (BigInt(toDbMs(nowMs())) / 1000n).toString();
|
||||||
const todayLocal = Math.floor(
|
|
||||||
(now.getTime() / 1000 - now.getTimezoneOffset() * 60) / 86_400,
|
|
||||||
);
|
|
||||||
|
|
||||||
const episodesToday =
|
const episodesToday =
|
||||||
(
|
(
|
||||||
@@ -215,13 +212,13 @@ export function getQueryHints(db: DatabaseSync): {
|
|||||||
`
|
`
|
||||||
SELECT COUNT(DISTINCT s.video_id) AS count
|
SELECT COUNT(DISTINCT s.video_id) AS count
|
||||||
FROM imm_sessions s
|
FROM imm_sessions s
|
||||||
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
|
WHERE date(s.started_at_ms / 1000, 'unixepoch', 'localtime') = date(?,'unixepoch','localtime')
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(todayLocal) as { count: number }
|
.get(nowSeconds) as { count: number }
|
||||||
)?.count ?? 0;
|
)?.count ?? 0;
|
||||||
|
|
||||||
const thirtyDaysAgoMs = nowMs() - 30 * 86400000;
|
const activeAnimeCutoffMs = subtractDbMs(toDbMs(nowMs()), `${THIRTY_DAYS_MS}`);
|
||||||
const activeAnimeCount =
|
const activeAnimeCount =
|
||||||
(
|
(
|
||||||
db
|
db
|
||||||
@@ -234,7 +231,7 @@ export function getQueryHints(db: DatabaseSync): {
|
|||||||
AND s.started_at_ms >= ?
|
AND s.started_at_ms >= ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(thirtyDaysAgoMs) as { count: number }
|
.get(activeAnimeCutoffMs) as { count: number }
|
||||||
)?.count ?? 0;
|
)?.count ?? 0;
|
||||||
|
|
||||||
const totalEpisodesWatched = Number(lifetime?.episodesCompleted ?? 0);
|
const totalEpisodesWatched = Number(lifetime?.episodesCompleted ?? 0);
|
||||||
|
|||||||
@@ -276,14 +276,23 @@ export function toDbMs(ms: number | bigint | string): string {
|
|||||||
return ms.toString();
|
return ms.toString();
|
||||||
}
|
}
|
||||||
if (typeof ms === 'string') {
|
if (typeof ms === 'string') {
|
||||||
const parsedMs = Number(ms);
|
const text = ms.trim().replace(/\.0+$/, '');
|
||||||
if (!Number.isFinite(parsedMs)) {
|
return /^-?\d+$/.test(text) ? text : '0';
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
return String(Math.trunc(parsedMs));
|
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(ms)) {
|
if (!Number.isFinite(ms)) {
|
||||||
return '0';
|
return '0';
|
||||||
}
|
}
|
||||||
return String(Math.trunc(ms));
|
return ms.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDbSeconds(ms: number | bigint | string): string {
|
||||||
|
const dbMs = toDbMs(ms);
|
||||||
|
if (dbMs === '0') {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
return (BigInt(dbMs) / 1000n).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtractDbMs(timestampMs: number | bigint | string, deltaMs: number | string): string {
|
||||||
|
return (BigInt(toDbMs(timestampMs)) - BigInt(`${deltaMs}`)).toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ interface TrendPerAnimePoint {
|
|||||||
|
|
||||||
interface TrendSessionMetricRow {
|
interface TrendSessionMetricRow {
|
||||||
startedAtMs: number;
|
startedAtMs: number;
|
||||||
|
localEpochDay: number;
|
||||||
|
localMonthKey: number;
|
||||||
|
localDayOfWeek: number;
|
||||||
|
localHour: number;
|
||||||
videoId: number | null;
|
videoId: number | null;
|
||||||
canonicalTitle: string | null;
|
canonicalTitle: string | null;
|
||||||
animeTitle: string | null;
|
animeTitle: string | null;
|
||||||
@@ -74,63 +78,60 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
function getTrendDayLimit(range: TrendRange): number {
|
function getTrendDayLimit(range: TrendRange): number {
|
||||||
return range === 'all' ? 365 : TREND_DAY_LIMITS[range];
|
return range === 'all' ? 365 : TREND_DAY_LIMITS[range];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrendMonthlyLimit(range: TrendRange): number {
|
function getTrendMonthlyLimit(range: TrendRange): number {
|
||||||
if (range === 'all') {
|
switch (range) {
|
||||||
return 120;
|
case 'all':
|
||||||
|
return 120;
|
||||||
|
case '7d':
|
||||||
|
return 1;
|
||||||
|
case '30d':
|
||||||
|
return 2;
|
||||||
|
case '90d':
|
||||||
|
return 4;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
}
|
||||||
const cutoff = new Date(
|
|
||||||
now.getFullYear(),
|
function epochDayToCivil(epochDay: number): { year: number; month: number; day: number } {
|
||||||
now.getMonth(),
|
const z = epochDay + 719468;
|
||||||
now.getDate() - (TREND_DAY_LIMITS[range] - 1),
|
const era = Math.floor(z / 146097);
|
||||||
|
const doe = z - era * 146097;
|
||||||
|
const yoe = Math.floor(
|
||||||
|
(doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365,
|
||||||
);
|
);
|
||||||
return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1);
|
let year = yoe + era * 400;
|
||||||
}
|
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
|
||||||
|
const mp = Math.floor((5 * doy + 2) / 153);
|
||||||
function getTrendCutoffMs(range: TrendRange): number | null {
|
const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
|
||||||
if (range === 'all') {
|
const month = mp < 10 ? mp + 3 : mp - 9;
|
||||||
return null;
|
if (month <= 2) {
|
||||||
|
year += 1;
|
||||||
}
|
}
|
||||||
const dayLimit = getTrendDayLimit(range);
|
return { year, month, day };
|
||||||
const now = new Date();
|
|
||||||
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
||||||
return localMidnight - (dayLimit - 1) * 86_400_000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTrendLabel(value: number): string {
|
function formatEpochDayLabel(epochDay: number): string {
|
||||||
if (value > 100_000) {
|
const { month, day } = epochDayToCivil(epochDay);
|
||||||
const year = Math.floor(value / 100);
|
return `${MONTH_NAMES[month - 1]} ${day}`;
|
||||||
const month = value % 100;
|
|
||||||
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(value * 86_400_000).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalEpochDay(timestampMs: number): number {
|
function formatMonthKeyLabel(monthKey: number): string {
|
||||||
const date = new Date(timestampMs);
|
const year = Math.floor(monthKey / 100);
|
||||||
return Math.floor((timestampMs - date.getTimezoneOffset() * 60_000) / 86_400_000);
|
const month = monthKey % 100;
|
||||||
|
return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalDateForEpochDay(epochDay: number): Date {
|
function formatTrendLabel(value: number): string {
|
||||||
const utcDate = new Date(epochDay * 86_400_000);
|
return value > 100_000 ? formatMonthKeyLabel(value) : formatEpochDayLabel(value);
|
||||||
return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalMonthKey(timestampMs: number): number {
|
function localMidnightSecondsExpr(): string {
|
||||||
const date = new Date(timestampMs);
|
return `(CAST(strftime('%s', 'now', 'localtime') AS INTEGER) - CAST(strftime('%H', 'now', 'localtime') AS INTEGER) * 3600 - CAST(strftime('%M', 'now', 'localtime') AS INTEGER) * 60 - CAST(strftime('%S', 'now', 'localtime') AS INTEGER))`;
|
||||||
return date.getFullYear() * 100 + date.getMonth() + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
|
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
|
||||||
@@ -178,7 +179,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
|||||||
return Array.from(byKey.entries())
|
return Array.from(byKey.entries())
|
||||||
.sort(([left], [right]) => left - right)
|
.sort(([left], [right]) => left - right)
|
||||||
.map(([key, value]) => ({
|
.map(([key, value]) => ({
|
||||||
label: makeTrendLabel(key),
|
label: formatTrendLabel(key),
|
||||||
activeMin: value.activeMin,
|
activeMin: value.activeMin,
|
||||||
cards: value.cards,
|
cards: value.cards,
|
||||||
words: value.words,
|
words: value.words,
|
||||||
@@ -189,7 +190,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
|||||||
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||||
const totals = new Array(7).fill(0);
|
const totals = new Array(7).fill(0);
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
totals[new Date(session.startedAtMs).getDay()] += session.activeWatchedMs;
|
totals[session.localDayOfWeek] += session.activeWatchedMs;
|
||||||
}
|
}
|
||||||
return DAY_NAMES.map((name, index) => ({
|
return DAY_NAMES.map((name, index) => ({
|
||||||
label: name,
|
label: name,
|
||||||
@@ -200,7 +201,7 @@ function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChar
|
|||||||
function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||||
const totals = new Array(24).fill(0);
|
const totals = new Array(24).fill(0);
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
totals[new Date(session.startedAtMs).getHours()] += session.activeWatchedMs;
|
totals[session.localHour] += session.activeWatchedMs;
|
||||||
}
|
}
|
||||||
return totals.map((ms, index) => ({
|
return totals.map((ms, index) => ({
|
||||||
label: `${String(index).padStart(2, '0')}:00`,
|
label: `${String(index).padStart(2, '0')}:00`,
|
||||||
@@ -208,25 +209,18 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayLabel(epochDay: number): string {
|
|
||||||
return getLocalDateForEpochDay(epochDay).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessionSeriesByDay(
|
function buildSessionSeriesByDay(
|
||||||
sessions: TrendSessionMetricRow[],
|
sessions: TrendSessionMetricRow[],
|
||||||
getValue: (session: TrendSessionMetricRow) => number,
|
getValue: (session: TrendSessionMetricRow) => number,
|
||||||
): TrendChartPoint[] {
|
): TrendChartPoint[] {
|
||||||
const byDay = new Map<number, number>();
|
const byDay = new Map<number, number>();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const epochDay = getLocalEpochDay(session.startedAtMs);
|
const epochDay = session.localEpochDay;
|
||||||
byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session));
|
byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session));
|
||||||
}
|
}
|
||||||
return Array.from(byDay.entries())
|
return Array.from(byDay.entries())
|
||||||
.sort(([left], [right]) => left - right)
|
.sort(([left], [right]) => left - right)
|
||||||
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
|
.map(([epochDay, value]) => ({ label: formatEpochDayLabel(epochDay), value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionSeriesByMonth(
|
function buildSessionSeriesByMonth(
|
||||||
@@ -235,12 +229,12 @@ function buildSessionSeriesByMonth(
|
|||||||
): TrendChartPoint[] {
|
): TrendChartPoint[] {
|
||||||
const byMonth = new Map<number, number>();
|
const byMonth = new Map<number, number>();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const monthKey = getLocalMonthKey(session.startedAtMs);
|
const monthKey = session.localMonthKey;
|
||||||
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
|
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
|
||||||
}
|
}
|
||||||
return Array.from(byMonth.entries())
|
return Array.from(byMonth.entries())
|
||||||
.sort(([left], [right]) => left - right)
|
.sort(([left], [right]) => left - right)
|
||||||
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
|
.map(([monthKey, value]) => ({ label: formatMonthKeyLabel(monthKey), value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||||
@@ -248,7 +242,7 @@ function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendCh
|
|||||||
const wordsByDay = new Map<number, number>();
|
const wordsByDay = new Map<number, number>();
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const epochDay = getLocalEpochDay(session.startedAtMs);
|
const epochDay = session.localEpochDay;
|
||||||
lookupsByDay.set(epochDay, (lookupsByDay.get(epochDay) ?? 0) + session.yomitanLookupCount);
|
lookupsByDay.set(epochDay, (lookupsByDay.get(epochDay) ?? 0) + session.yomitanLookupCount);
|
||||||
wordsByDay.set(epochDay, (wordsByDay.get(epochDay) ?? 0) + getTrendSessionWordCount(session));
|
wordsByDay.set(epochDay, (wordsByDay.get(epochDay) ?? 0) + getTrendSessionWordCount(session));
|
||||||
}
|
}
|
||||||
@@ -258,7 +252,7 @@ function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendCh
|
|||||||
.map(([epochDay, lookups]) => {
|
.map(([epochDay, lookups]) => {
|
||||||
const words = wordsByDay.get(epochDay) ?? 0;
|
const words = wordsByDay.get(epochDay) ?? 0;
|
||||||
return {
|
return {
|
||||||
label: dayLabel(epochDay),
|
label: formatEpochDayLabel(epochDay),
|
||||||
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
|
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -272,7 +266,7 @@ function buildPerAnimeFromSessions(
|
|||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const animeTitle = resolveTrendAnimeTitle(session);
|
const animeTitle = resolveTrendAnimeTitle(session);
|
||||||
const epochDay = getLocalEpochDay(session.startedAtMs);
|
const epochDay = session.localEpochDay;
|
||||||
const dayMap = byAnime.get(animeTitle) ?? new Map();
|
const dayMap = byAnime.get(animeTitle) ?? new Map();
|
||||||
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
|
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
|
||||||
byAnime.set(animeTitle, dayMap);
|
byAnime.set(animeTitle, dayMap);
|
||||||
@@ -293,7 +287,7 @@ function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): Tren
|
|||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const animeTitle = resolveTrendAnimeTitle(session);
|
const animeTitle = resolveTrendAnimeTitle(session);
|
||||||
const epochDay = getLocalEpochDay(session.startedAtMs);
|
const epochDay = session.localEpochDay;
|
||||||
|
|
||||||
const lookupMap = lookups.get(animeTitle) ?? new Map();
|
const lookupMap = lookups.get(animeTitle) ?? new Map();
|
||||||
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
|
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
|
||||||
@@ -461,7 +455,7 @@ function buildEpisodesPerDayFromDailyRollups(
|
|||||||
return Array.from(byDay.entries())
|
return Array.from(byDay.entries())
|
||||||
.sort(([left], [right]) => left - right)
|
.sort(([left], [right]) => left - right)
|
||||||
.map(([epochDay, videoIds]) => ({
|
.map(([epochDay, videoIds]) => ({
|
||||||
label: dayLabel(epochDay),
|
label: formatEpochDayLabel(epochDay),
|
||||||
value: videoIds.size,
|
value: videoIds.size,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -481,20 +475,25 @@ function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]):
|
|||||||
return Array.from(byMonth.entries())
|
return Array.from(byMonth.entries())
|
||||||
.sort(([left], [right]) => left - right)
|
.sort(([left], [right]) => left - right)
|
||||||
.map(([monthKey, videoIds]) => ({
|
.map(([monthKey, videoIds]) => ({
|
||||||
label: makeTrendLabel(monthKey),
|
label: formatTrendLabel(monthKey),
|
||||||
value: videoIds.size,
|
value: videoIds.size,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrendSessionMetrics(
|
function getTrendSessionMetrics(db: DatabaseSync, range: TrendRange): TrendSessionMetricRow[] {
|
||||||
db: DatabaseSync,
|
const dayLimit = getTrendDayLimit(range);
|
||||||
cutoffMs: number | null,
|
const cutoffClause =
|
||||||
): TrendSessionMetricRow[] {
|
range === 'all'
|
||||||
const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?';
|
? ''
|
||||||
|
: `WHERE CAST(s.started_at_ms AS INTEGER) >= (${localMidnightSecondsExpr()} - ${(dayLimit - 1) * 86400}) * 1000`;
|
||||||
const prepared = db.prepare(`
|
const prepared = db.prepare(`
|
||||||
${ACTIVE_SESSION_METRICS_CTE}
|
${ACTIVE_SESSION_METRICS_CTE}
|
||||||
SELECT
|
SELECT
|
||||||
s.started_at_ms AS startedAtMs,
|
CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
|
||||||
|
CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS localEpochDay,
|
||||||
|
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS localMonthKey,
|
||||||
|
CAST(strftime('%w', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS localDayOfWeek,
|
||||||
|
CAST(strftime('%H', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS localHour,
|
||||||
s.video_id AS videoId,
|
s.video_id AS videoId,
|
||||||
v.canonical_title AS canonicalTitle,
|
v.canonical_title AS canonicalTitle,
|
||||||
a.canonical_title AS animeTitle,
|
a.canonical_title AS animeTitle,
|
||||||
@@ -506,61 +505,79 @@ function getTrendSessionMetrics(
|
|||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
LEFT JOIN imm_videos v ON v.video_id = s.video_id
|
LEFT JOIN imm_videos v ON v.video_id = s.video_id
|
||||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||||
${whereClause}
|
${cutoffClause}
|
||||||
ORDER BY s.started_at_ms ASC
|
ORDER BY CAST(s.started_at_ms AS INTEGER) ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return (cutoffMs === null ? prepared.all() : prepared.all(cutoffMs)) as TrendSessionMetricRow[];
|
const rows = prepared.all() as Array<{
|
||||||
|
startedAtMs: number | string;
|
||||||
|
localEpochDay: number | string;
|
||||||
|
localMonthKey: number | string;
|
||||||
|
localDayOfWeek: number | string;
|
||||||
|
localHour: number | string;
|
||||||
|
videoId: number | null;
|
||||||
|
canonicalTitle: string | null;
|
||||||
|
animeTitle: string | null;
|
||||||
|
activeWatchedMs: number | string;
|
||||||
|
tokensSeen: number | string;
|
||||||
|
cardsMined: number | string;
|
||||||
|
yomitanLookupCount: number | string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
startedAtMs: Number.parseInt(String(row.startedAtMs), 10),
|
||||||
|
localEpochDay: Number.parseInt(String(row.localEpochDay), 10),
|
||||||
|
localMonthKey: Number.parseInt(String(row.localMonthKey), 10),
|
||||||
|
localDayOfWeek: Number.parseInt(String(row.localDayOfWeek), 10),
|
||||||
|
localHour: Number.parseInt(String(row.localHour), 10),
|
||||||
|
videoId: row.videoId,
|
||||||
|
canonicalTitle: row.canonicalTitle,
|
||||||
|
animeTitle: row.animeTitle,
|
||||||
|
activeWatchedMs: Number(row.activeWatchedMs),
|
||||||
|
tokensSeen: Number(row.tokensSeen),
|
||||||
|
cardsMined: Number(row.cardsMined),
|
||||||
|
yomitanLookupCount: Number(row.yomitanLookupCount),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
|
function buildNewWordsPerDay(db: DatabaseSync, dayLimit: number | null): TrendChartPoint[] {
|
||||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
const cutoffExpr =
|
||||||
|
dayLimit === null ? '' : `AND CAST(first_seen AS INTEGER) >= (${localMidnightSecondsExpr()} - ${(dayLimit - 1) * 86400})`;
|
||||||
const prepared = db.prepare(`
|
const prepared = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
CAST(julianday(first_seen, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay,
|
CAST(julianday(first_seen, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay,
|
||||||
COUNT(*) AS wordCount
|
COUNT(*) AS wordCount
|
||||||
FROM imm_words
|
FROM imm_words
|
||||||
WHERE first_seen IS NOT NULL
|
WHERE first_seen IS NOT NULL
|
||||||
${whereClause}
|
${cutoffExpr}
|
||||||
GROUP BY epochDay
|
GROUP BY epochDay
|
||||||
ORDER BY epochDay ASC
|
ORDER BY epochDay ASC
|
||||||
`);
|
`);
|
||||||
|
const rows = prepared.all() as Array<{ epochDay: number; wordCount: number }>;
|
||||||
const rows = (
|
|
||||||
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
|
|
||||||
) as Array<{
|
|
||||||
epochDay: number;
|
|
||||||
wordCount: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
label: dayLabel(row.epochDay),
|
label: formatEpochDayLabel(row.epochDay),
|
||||||
value: row.wordCount,
|
value: row.wordCount,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
|
function buildNewWordsPerMonth(db: DatabaseSync, dayLimit: number | null): TrendChartPoint[] {
|
||||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
const cutoffExpr =
|
||||||
|
dayLimit === null ? '' : `AND CAST(first_seen AS INTEGER) >= (${localMidnightSecondsExpr()} - ${(dayLimit - 1) * 86400})`;
|
||||||
const prepared = db.prepare(`
|
const prepared = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey,
|
CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey,
|
||||||
COUNT(*) AS wordCount
|
COUNT(*) AS wordCount
|
||||||
FROM imm_words
|
FROM imm_words
|
||||||
WHERE first_seen IS NOT NULL
|
WHERE first_seen IS NOT NULL
|
||||||
${whereClause}
|
${cutoffExpr}
|
||||||
GROUP BY monthKey
|
GROUP BY monthKey
|
||||||
ORDER BY monthKey ASC
|
ORDER BY monthKey ASC
|
||||||
`);
|
`);
|
||||||
|
const rows = prepared.all() as Array<{ monthKey: number; wordCount: number }>;
|
||||||
const rows = (
|
|
||||||
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
|
|
||||||
) as Array<{
|
|
||||||
monthKey: number;
|
|
||||||
wordCount: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
label: makeTrendLabel(row.monthKey),
|
label: formatMonthKeyLabel(row.monthKey),
|
||||||
value: row.wordCount,
|
value: row.wordCount,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -572,13 +589,12 @@ export function getTrendsDashboard(
|
|||||||
): TrendsDashboardQueryResult {
|
): TrendsDashboardQueryResult {
|
||||||
const dayLimit = getTrendDayLimit(range);
|
const dayLimit = getTrendDayLimit(range);
|
||||||
const monthlyLimit = getTrendMonthlyLimit(range);
|
const monthlyLimit = getTrendMonthlyLimit(range);
|
||||||
const cutoffMs = getTrendCutoffMs(range);
|
|
||||||
const useMonthlyBuckets = groupBy === 'month';
|
const useMonthlyBuckets = groupBy === 'month';
|
||||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||||
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
||||||
|
|
||||||
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
||||||
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
const sessions = getTrendSessionMetrics(db, range);
|
||||||
const titlesByVideoId = getVideoAnimeTitleMap(
|
const titlesByVideoId = getVideoAnimeTitleMap(
|
||||||
db,
|
db,
|
||||||
dailyRollups.map((rollup) => rollup.videoId),
|
dailyRollups.map((rollup) => rollup.videoId),
|
||||||
@@ -618,7 +634,7 @@ export function getTrendsDashboard(
|
|||||||
sessions: accumulatePoints(activity.sessions),
|
sessions: accumulatePoints(activity.sessions),
|
||||||
words: accumulatePoints(activity.words),
|
words: accumulatePoints(activity.words),
|
||||||
newWords: accumulatePoints(
|
newWords: accumulatePoints(
|
||||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
useMonthlyBuckets ? buildNewWordsPerMonth(db, range === 'all' ? null : dayLimit) : buildNewWordsPerDay(db, range === 'all' ? null : dayLimit),
|
||||||
),
|
),
|
||||||
cards: accumulatePoints(activity.cards),
|
cards: accumulatePoints(activity.cards),
|
||||||
episodes: accumulatePoints(
|
episodes: accumulatePoints(
|
||||||
|
|||||||
@@ -143,10 +143,10 @@ test('ensureSchema creates immersion core tables', () => {
|
|||||||
const rollupStateRow = db
|
const rollupStateRow = db
|
||||||
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
||||||
.get('last_rollup_sample_ms') as {
|
.get('last_rollup_sample_ms') as {
|
||||||
state_value: number;
|
state_value: string;
|
||||||
} | null;
|
} | null;
|
||||||
assert.ok(rollupStateRow);
|
assert.ok(rollupStateRow);
|
||||||
assert.equal(rollupStateRow?.state_value, 0);
|
assert.equal(rollupStateRow?.state_value, '0');
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
@@ -965,12 +965,12 @@ test('start/finalize session updates ended_at and status', () => {
|
|||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
|
.prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
|
||||||
.get(sessionId) as {
|
.get(sessionId) as {
|
||||||
ended_at_ms: number | null;
|
ended_at_ms: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
assert.ok(row);
|
assert.ok(row);
|
||||||
assert.equal(row?.ended_at_ms, endedAtMs);
|
assert.equal(row?.ended_at_ms, String(endedAtMs));
|
||||||
assert.equal(row?.status, SESSION_STATUS_ENDED);
|
assert.equal(row?.status, SESSION_STATUS_ENDED);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -1421,7 +1421,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
|||||||
) {
|
) {
|
||||||
throw new Error('Incomplete telemetry write');
|
throw new Error('Incomplete telemetry write');
|
||||||
}
|
}
|
||||||
const telemetrySampleMs = toDbMs(write.sampleMs ?? Number(currentMs));
|
const telemetrySampleMs = write.sampleMs === undefined ? currentMs : toDbMs(write.sampleMs);
|
||||||
stmts.telemetryInsertStmt.run(
|
stmts.telemetryInsertStmt.run(
|
||||||
write.sessionId,
|
write.sessionId,
|
||||||
telemetrySampleMs,
|
telemetrySampleMs,
|
||||||
@@ -1496,7 +1496,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
|||||||
|
|
||||||
stmts.eventInsertStmt.run(
|
stmts.eventInsertStmt.run(
|
||||||
write.sessionId,
|
write.sessionId,
|
||||||
toDbMs(write.sampleMs ?? Number(currentMs)),
|
write.sampleMs === undefined ? currentMs : toDbMs(write.sampleMs),
|
||||||
write.eventType ?? 0,
|
write.eventType ?? 0,
|
||||||
write.lineIndex ?? null,
|
write.lineIndex ?? null,
|
||||||
write.segmentStartMs ?? null,
|
write.segmentStartMs ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user