forked from akai/readest
135 lines
5.1 KiB
TypeScript
135 lines
5.1 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { useBookProgress } from '@/store/readerProgressStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { StatisticsDb } from '@/services/statistics/statisticsDb';
|
|
import { TrackerCore, type FlushedEvent } from '@/services/statistics/trackerCore';
|
|
import { DEFAULT_STATS_TRACKING_CONFIG } from '@/types/statistics';
|
|
import { SyncClient } from '@/libs/sync';
|
|
import { pushStats, pullStats } from '@/services/statistics/statsSync';
|
|
import { isSyncCategoryEnabled } from '@/services/sync/syncCategories';
|
|
|
|
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
|
|
// Statistics are best-effort telemetry: a failed write or sync (e.g. the
|
|
// statistics DB torn down mid-flight on app teardown -> "database ... not
|
|
// loaded", Sentry READEST-6) must never surface as an unhandled rejection.
|
|
const runBestEffort = (work: Promise<unknown>): void => {
|
|
void work.catch((err) => console.warn('[stats] background operation failed:', err));
|
|
};
|
|
|
|
export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
|
|
const { appService } = useEnv();
|
|
// Progress lives in readerProgressStore, not readerStore.viewStates.
|
|
const progress = useBookProgress(bookKey);
|
|
// booksData is keyed by book id = bookKey.split('-')[0].
|
|
const getBookData = useBookDataStore((s) => s.getBookData);
|
|
const { user } = useAuth();
|
|
const coreRef = useRef(new TrackerCore(DEFAULT_STATS_TRACKING_CONFIG));
|
|
const dbRef = useRef<StatisticsDb | null>(null);
|
|
const idleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const bookData = getBookData(bookKey);
|
|
const book = bookData?.book;
|
|
// Book.hash is the partialMD5 used as KOReader's md5.
|
|
const bookMd5 = book?.hash;
|
|
const title = book?.title ?? '';
|
|
// Book.author is the single-string author field; upsertBook takes authors: string.
|
|
const authors = book?.author ?? '';
|
|
|
|
const syncEnabled = () => !!user && isSyncCategoryEnabled('stats');
|
|
|
|
const schedulePush = () => {
|
|
if (!syncEnabled()) return;
|
|
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
|
pushTimerRef.current = setTimeout(() => {
|
|
const db = dbRef.current;
|
|
if (db) runBestEffort(pushStats(db, new SyncClient()));
|
|
}, 10_000);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!appService) return;
|
|
let cancelled = false;
|
|
StatisticsDb.open(appService).then((db) => {
|
|
if (cancelled) return;
|
|
dbRef.current = db;
|
|
if (syncEnabled()) runBestEffort(pullStats(db, new SyncClient()));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [appService]);
|
|
|
|
// Persist flushed events into the statistics DB.
|
|
const persist = async (events: FlushedEvent[]): Promise<void> => {
|
|
const db = dbRef.current;
|
|
if (!db || !bookMd5 || events.length === 0) return;
|
|
try {
|
|
const idBook = await db.upsertBook({ bookMd5, title, authors });
|
|
for (const e of events) await db.insertPageEvent(idBook, e);
|
|
await db.recomputeBookTotals(idBook);
|
|
schedulePush();
|
|
} catch (err) {
|
|
// The statistics DB can be closed mid-write on app/tab teardown
|
|
// ("database ... not loaded", Sentry READEST-6). Best-effort: log and
|
|
// never reject, so the fire-and-forget dispatch sites stay safe.
|
|
console.warn('[stats] failed to persist reading events:', err);
|
|
}
|
|
};
|
|
|
|
const armIdle = () => {
|
|
if (idleRef.current) clearTimeout(idleRef.current);
|
|
idleRef.current = setTimeout(
|
|
() => void persist(coreRef.current.onIdle(nowSec())),
|
|
DEFAULT_STATS_TRACKING_CONFIG.idleTimeoutSeconds * 1000,
|
|
);
|
|
};
|
|
|
|
// Page changes drive the tracker.
|
|
useEffect(() => {
|
|
const info = progress?.pageinfo;
|
|
if (!info) return;
|
|
const page = (info.current ?? 0) + 1;
|
|
const total = info.total || 1;
|
|
void persist(coreRef.current.onPage(page, total, nowSec()));
|
|
armIdle();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [progress?.pageinfo]);
|
|
|
|
// Tab/window visibility.
|
|
useEffect(() => {
|
|
const onVis = () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
if (idleRef.current) clearTimeout(idleRef.current);
|
|
void persist(coreRef.current.onHide(nowSec()));
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', onVis);
|
|
return () => document.removeEventListener('visibilitychange', onVis);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [bookMd5]);
|
|
|
|
// Book close (unmount).
|
|
useEffect(() => {
|
|
return () => {
|
|
if (idleRef.current) clearTimeout(idleRef.current);
|
|
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
|
runBestEffort(
|
|
persist(coreRef.current.onClose(nowSec())).then(() => {
|
|
if (syncEnabled() && dbRef.current) return pushStats(dbRef.current, new SyncClient());
|
|
return undefined;
|
|
}),
|
|
);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [bookMd5]);
|
|
|
|
return null;
|
|
}
|