Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ Server-side aggregate across all of the user's devices:
}
```

#### GET /api/v1/stats/overview?tz=&days=&language=&genre=&author=&group_by=
#### GET /api/v1/stats/overview?tz=&days=&language=&genre=&author=&group_by=&week_start=

A small digest for readers that want to show the account's totals on the device. It has the same numbers
the Stats page shows, from every source: syncs, chosen start and finish dates, and device stats.
Expand All @@ -608,7 +608,8 @@ the Stats page shows, from every source: syncs, chosen start and finish dates, a
the response stays a few hundred bytes.
- All the counts cover the whole history, whatever `days` is: `reading_days`, the streaks, the finished counts and the pages.
The time fields come from device stats: `seconds`, `sessions`, `today_seconds`, and `week_seconds` for the
last 7 days including today.
last 7 days including today. With `week_start=0` (Sunday) or `week_start=1` (Monday), `week_seconds` covers the
calendar week so far instead, in every group too. Any other value is refused.

Filters narrow the digest to matching books: `language` (main subtag, so `en` also matches `en-GB`),
`genre` (one of the book's genres) and `author` (part of the name). All ignore case and can be combined.
Expand Down
12 changes: 10 additions & 2 deletions src/routes/v1/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,13 +417,21 @@ export function statsRoutes(db: DB, http?: HttpTransport): Hono<AppEnv> {
const days = Number.isInteger(daysRaw) && daysRaw >= 1 ? Math.min(daysRaw, MAX_OVERVIEW_DAYS) : DEFAULT_OVERVIEW_DAYS;
const filter = bookFilter(c.req.query('language'), c.req.query('genre'), c.req.query('author'));
const groupBy = c.req.query('group_by');
if (filter === null || (groupBy !== undefined && groupBy !== 'language' && groupBy !== 'genre')) {
const weekStart = c.req.query('week_start');
if (
filter === null ||
(groupBy !== undefined && groupBy !== 'language' && groupBy !== 'genre') ||
(weekStart !== undefined && weekStart !== '0' && weekStart !== '1')
) {
return kosyncError(c, 403, 2003, 'Invalid request');
}
const activity = await activityFor(user.id, tz);
const day = (t: number) => Math.floor(t / 86400000) - EPOCH_DAY; // days since 2000-01-01
const today = day(Date.now() - tz * 60000);
const year = new Date(Date.now() - tz * 60000).getUTCFullYear();
// week_seconds: the last 7 days, or the days since Sunday (week_start=0) or Monday (1).
// 2000-01-01 was a Saturday, so (today + 6) % 7 is the weekday with Sunday as 0.
const weekDays = weekStart === undefined ? 7 : ((today + 6 - Number(weekStart)) % 7) + 1;

const digest = (read: Set<number>, books: typeof activity.books, pages: number, time: OverviewTime) => {
const past = [...read].filter((d) => d <= today).sort((a, b) => a - b);
Expand Down Expand Up @@ -452,7 +460,7 @@ export function statsRoutes(db: DB, http?: HttpTransport): Hono<AppEnv> {
seconds: time.seconds,
sessions: time.sessions,
today_seconds: time.perDay.get(today) ?? 0,
week_seconds: [0, 1, 2, 3, 4, 5, 6].reduce((sum, n) => sum + (time.perDay.get(today - n) ?? 0), 0),
week_seconds: Array.from({ length: weekDays }, (_, n) => time.perDay.get(today - n) ?? 0).reduce((a, b) => a + b, 0),
};
};

Expand Down
30 changes: 30 additions & 0 deletions test/stats-overview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,36 @@ describe('GET /api/v1/stats/overview', () => {
expect((await (await app.request('/api/v1/stats/overview?days=99999', { headers })).json()).days).toBe(3650);
});

it('counts week_seconds for the calendar week with week_start', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-21T18:00:00Z')); // a Wednesday
const { app, db } = makeTestApp();
const { username, headers } = await registerUser(app);
const { id } = db.prepare('SELECT id FROM users WHERE username = ?').get(username) as { id: number };
const ja = 'a'.repeat(32);
db.prepare('INSERT INTO documents (user_id, document, language, updated_at) VALUES (?, ?, ?, 0)').run(id, ja, 'ja');
// Thursday last week, then Sunday, Monday and today.
const reading: [string, number][] = [['2026-10-15', 100], ['2026-10-18', 600], ['2026-10-19', 300], ['2026-10-21', 540]];
const global = {
device_id: 'x4', device: 'CrossPoint', v: 5, sessions: 0, seconds: 1540, pages: 0, completed: 0,
tod: [0, 0, 0, 0], dow: [0, 0, 0, 0, 0, 0, 0], anchor_day: 0, history_b64: '', streak: 0,
daily: reading.map(([date, seconds]) => ({ date, seconds })),
};
expect((await app.request('/api/v1/stats/global', { method: 'PUT', headers, body: JSON.stringify(global) })).status).toBe(200);
const items = reading.map(([date, seconds], n) => ({
session_id: `s${n}`, document: ja, started_at: at(date), ended_at: at(date) + seconds, seconds, utc_offset_minutes: 0,
}));
expect((await app.request('/api/v1/stats/sessions', { method: 'PUT', headers, body: JSON.stringify({ device_id: 'x4', items }) })).status).toBe(200);

const get = async (q: string) => (await app.request(`/api/v1/stats/overview?days=10&group_by=language${q}`, { headers })).json();
for (const [q, seconds] of [['', 1540], ['&week_start=0', 1440], ['&week_start=1', 840]] as const) {
const overview = await get(q);
expect(overview.week_seconds).toBe(seconds);
expect(overview.groups.ja.week_seconds).toBe(seconds);
}
expect((await app.request('/api/v1/stats/overview?week_start=2', { headers })).status).toBe(403);
});

it('narrows to books by language, genre or author, and groups by language', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-24T18:00:00Z'));
Expand Down
Loading