mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
e2af7ba3b4
* feat(stats): count KOReader sync reading toward streaks and statistics - Estimate a reading session from the interval between two advancing kosync pushes by the same device, so a plain KOReader install with no BookOrbit plugin still reaches the reading log, the streak and every daily statistic - Bound each estimate with a one minute floor and a thirty minute ceiling, so a page turn is not a sitting and an overnight silence is not recorded as a long read - Derive the estimate id from device, book file and interval start, so two pushes racing for the same file resolve to one row instead of counting the same reading twice - Measure both the interval and the distance read against the same device's own previous row, so a second device syncing the book in between cannot lend it either one - Skip estimating for a device that has swept in the last thirty days, since its plugin uploads real page timings for the same reading, and let the exclusion lapse so a removed or broken plugin does not retire the device from every statistic - Retire estimates once a sweep measures the same reading, matched by an id prefix so the lookup stays bounded and indexed - Rebuild a reader's daily stats when their timezone is submitted, so a corrected day boundary applies to history rather than only to the next couple of days - Backfill daily stats once on upgrade for every reader with history, marked done only after a clean pass so a partial repair retries - Keep a failed estimate or rebuild from taking down the progress push or the settings save that triggered it Refs #261 * fix(stats): correct estimate logging, lock order, and settings latency - Read the clock before handing the backfill pass to catch, so an outright failure reports its real elapsed time rather than durationMs=0 - Sort the hourly recompute's groups by user then library, so it and a concurrent per-user rebuild take the shared advisory locks in one order instead of deadlocking a reader who holds two libraries - Detach the timezone rebuild from the settings write, which never returns its result and would otherwise hold the response open for a full-history walk - Add durationMs to the koreader.sync_session end and fail logs - Add durationMs to the sweep cleanup failure log
202 lines
7.0 KiB
TypeScript
202 lines
7.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
aggregateReadingSessionDailyStats,
|
|
getDayRangeForDateKeys,
|
|
getReadingSessionDayKeys,
|
|
mergeReadingDailyStatsSegments,
|
|
sortReadingDailyStatsSegments,
|
|
splitReadingSessionByDay,
|
|
type ReadingDailyStatsSegment,
|
|
} from './reading-daily-stats.utils';
|
|
|
|
describe('reading daily stats utils', () => {
|
|
it('keeps a same-day session on one local day', () => {
|
|
const result = splitReadingSessionByDay(
|
|
{
|
|
startedAt: new Date('2026-04-15T10:00:00.000Z'),
|
|
endedAt: new Date('2026-04-15T10:30:00.000Z'),
|
|
durationSeconds: 1800,
|
|
progressDelta: 4,
|
|
},
|
|
'UTC',
|
|
);
|
|
|
|
expect(result).toEqual([{ day: '2026-04-15', readingSeconds: 1800, progressDelta: 4, sessionsCount: 1 }]);
|
|
});
|
|
|
|
it('splits a continuous session across local midnight', () => {
|
|
const result = splitReadingSessionByDay(
|
|
{
|
|
startedAt: new Date('2026-04-16T03:00:00.000Z'),
|
|
endedAt: new Date('2026-04-16T04:30:00.000Z'),
|
|
durationSeconds: 5400,
|
|
progressDelta: 9,
|
|
},
|
|
'America/New_York',
|
|
);
|
|
|
|
expect(result).toEqual([
|
|
{ day: '2026-04-15', readingSeconds: 3600, progressDelta: 6, sessionsCount: 1 },
|
|
{ day: '2026-04-16', readingSeconds: 1800, progressDelta: 3, sessionsCount: 1 },
|
|
]);
|
|
});
|
|
|
|
it('preserves total seconds when proportional pieces need rounding', () => {
|
|
const result = splitReadingSessionByDay(
|
|
{
|
|
startedAt: new Date('2026-04-15T23:59:58.000Z'),
|
|
endedAt: new Date('2026-04-16T00:00:01.000Z'),
|
|
durationSeconds: 10,
|
|
progressDelta: 1,
|
|
},
|
|
'UTC',
|
|
);
|
|
|
|
expect(result.reduce((sum, row) => sum + row.readingSeconds, 0)).toBe(10);
|
|
expect(result.map((row) => row.day)).toEqual(['2026-04-15', '2026-04-16']);
|
|
});
|
|
|
|
it('does not truncate sessions that span more than 400 local days', () => {
|
|
const result = splitReadingSessionByDay(
|
|
{
|
|
startedAt: new Date('2024-01-01T00:00:00.000Z'),
|
|
endedAt: new Date('2025-06-01T00:00:00.000Z'),
|
|
durationSeconds: 86400,
|
|
progressDelta: 10,
|
|
},
|
|
'UTC',
|
|
);
|
|
|
|
expect(result.at(-1)?.day).toBe('2025-05-31');
|
|
expect(result.reduce((sum, row) => sum + row.readingSeconds, 0)).toBe(86400);
|
|
expect(result.reduce((sum, row) => sum + row.progressDelta, 0)).toBeCloseTo(10);
|
|
});
|
|
|
|
it('returns local day keys for every day touched by a session', () => {
|
|
const result = getReadingSessionDayKeys(
|
|
{
|
|
startedAt: new Date('2026-04-16T03:00:00.000Z'),
|
|
endedAt: new Date('2026-04-16T04:30:00.000Z'),
|
|
durationSeconds: 5400,
|
|
progressDelta: 9,
|
|
},
|
|
'America/New_York',
|
|
);
|
|
|
|
expect(result).toEqual(['2026-04-15', '2026-04-16']);
|
|
});
|
|
|
|
it('handles non-positive, invalid, and clockless sessions defensively', () => {
|
|
expect(
|
|
splitReadingSessionByDay(
|
|
{
|
|
startedAt: new Date('2026-04-15T10:00:00.000Z'),
|
|
endedAt: new Date('2026-04-15T10:00:00.000Z'),
|
|
durationSeconds: 0,
|
|
progressDelta: null,
|
|
},
|
|
'UTC',
|
|
),
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
splitReadingSessionByDay(
|
|
{
|
|
startedAt: new Date(Number.NaN),
|
|
endedAt: new Date('2026-04-15T10:00:00.000Z'),
|
|
durationSeconds: 60,
|
|
progressDelta: null,
|
|
},
|
|
'UTC',
|
|
),
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
splitReadingSessionByDay(
|
|
{
|
|
startedAt: new Date('2026-04-15T10:00:00.000Z'),
|
|
endedAt: new Date('2026-04-15T09:59:00.000Z'),
|
|
durationSeconds: 60,
|
|
progressDelta: null,
|
|
},
|
|
'UTC',
|
|
),
|
|
).toEqual([{ day: '2026-04-15', readingSeconds: 60, progressDelta: 0, sessionsCount: 1 }]);
|
|
});
|
|
|
|
it('aggregates only requested affected days', () => {
|
|
const result = aggregateReadingSessionDailyStats(
|
|
[
|
|
{
|
|
startedAt: new Date('2026-04-15T23:00:00.000Z'),
|
|
endedAt: new Date('2026-04-16T01:00:00.000Z'),
|
|
durationSeconds: 7200,
|
|
progressDelta: null,
|
|
},
|
|
{
|
|
startedAt: new Date('2026-04-16T10:00:00.000Z'),
|
|
endedAt: new Date('2026-04-16T10:30:00.000Z'),
|
|
durationSeconds: 1800,
|
|
progressDelta: 2,
|
|
},
|
|
],
|
|
'UTC',
|
|
new Set(['2026-04-16']),
|
|
);
|
|
|
|
expect(result).toEqual([{ day: '2026-04-16', readingSeconds: 5400, progressDelta: 2, sessionsCount: 2 }]);
|
|
});
|
|
|
|
it('accumulates batched segments into the same totals as one pass over every session', () => {
|
|
const sessions = [
|
|
{ startedAt: new Date('2026-04-15T08:00:00.000Z'), endedAt: new Date('2026-04-15T09:00:00.000Z'), durationSeconds: 3600, progressDelta: 3 },
|
|
{ startedAt: new Date('2026-04-15T20:00:00.000Z'), endedAt: new Date('2026-04-15T20:30:00.000Z'), durationSeconds: 1800, progressDelta: 1 },
|
|
{ startedAt: new Date('2026-04-16T07:00:00.000Z'), endedAt: new Date('2026-04-16T07:15:00.000Z'), durationSeconds: 900, progressDelta: null },
|
|
];
|
|
|
|
const byDay = new Map<string, ReadingDailyStatsSegment>();
|
|
for (const session of sessions) {
|
|
mergeReadingDailyStatsSegments(byDay, aggregateReadingSessionDailyStats([session], 'UTC'));
|
|
}
|
|
|
|
expect(sortReadingDailyStatsSegments(byDay)).toEqual(aggregateReadingSessionDailyStats(sessions, 'UTC'));
|
|
});
|
|
|
|
it('merges without mutating the segments it was handed', () => {
|
|
const incoming: ReadingDailyStatsSegment[] = [{ day: '2026-04-15', readingSeconds: 60, progressDelta: 1, sessionsCount: 1 }];
|
|
const byDay = new Map<string, ReadingDailyStatsSegment>();
|
|
|
|
mergeReadingDailyStatsSegments(byDay, incoming);
|
|
mergeReadingDailyStatsSegments(byDay, [{ day: '2026-04-15', readingSeconds: 40, progressDelta: 2, sessionsCount: 1 }]);
|
|
|
|
expect(byDay.get('2026-04-15')).toEqual({ day: '2026-04-15', readingSeconds: 100, progressDelta: 3, sessionsCount: 2 });
|
|
expect(incoming[0]).toEqual({ day: '2026-04-15', readingSeconds: 60, progressDelta: 1, sessionsCount: 1 });
|
|
});
|
|
|
|
it('drops merged days outside the requested set and sorts what is left by day', () => {
|
|
const byDay = mergeReadingDailyStatsSegments(
|
|
new Map<string, ReadingDailyStatsSegment>(),
|
|
[
|
|
{ day: '2026-04-16', readingSeconds: 30, progressDelta: 0, sessionsCount: 1 },
|
|
{ day: '2026-04-15', readingSeconds: 60, progressDelta: 0, sessionsCount: 1 },
|
|
{ day: '2026-04-14', readingSeconds: 90, progressDelta: 0, sessionsCount: 1 },
|
|
],
|
|
new Set(['2026-04-15', '2026-04-16']),
|
|
);
|
|
|
|
expect(sortReadingDailyStatsSegments(byDay).map((segment) => segment.day)).toEqual(['2026-04-15', '2026-04-16']);
|
|
});
|
|
|
|
it('builds an exclusive UTC range for timezone-local affected days', () => {
|
|
const range = getDayRangeForDateKeys(['2026-04-15', '2026-04-16'], 'America/New_York');
|
|
|
|
expect(range?.start.toISOString()).toBe('2026-04-15T04:00:00.000Z');
|
|
expect(range?.end.toISOString()).toBe('2026-04-17T04:00:00.000Z');
|
|
});
|
|
|
|
it('returns null for an empty affected-day range', () => {
|
|
expect(getDayRangeForDateKeys([], 'UTC')).toBeNull();
|
|
});
|
|
});
|