From a643e8671789d9f87637db2a26930f0368d13ce5 Mon Sep 17 00:00:00 2001 From: Eszter Date: Mon, 26 Oct 2026 13:59:04 +0100 Subject: [PATCH 1/2] feat: week_start=1 counts week_seconds from Monday in the overview --- docs/API.md | 5 +++-- src/routes/v1/stats.ts | 11 +++++++++-- test/stats-overview.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/API.md b/docs/API.md index 37b17acf..b2665ac5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. @@ -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=1`, `week_seconds` covers the days since Monday instead, for a + reader that shows the calendar week. 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. diff --git a/src/routes/v1/stats.ts b/src/routes/v1/stats.ts index 876b052e..9897f6a1 100644 --- a/src/routes/v1/stats.ts +++ b/src/routes/v1/stats.ts @@ -417,13 +417,20 @@ export function statsRoutes(db: DB, http?: HttpTransport): Hono { 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 !== '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 with week_start=1 the days since Monday (2000-01-01 was a Saturday). + const weekDays = weekStart === '1' ? ((today + 5) % 7) + 1 : 7; const digest = (read: Set, books: typeof activity.books, pages: number, time: OverviewTime) => { const past = [...read].filter((d) => d <= today).sort((a, b) => a - b); @@ -452,7 +459,7 @@ export function statsRoutes(db: DB, http?: HttpTransport): Hono { 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), }; }; diff --git a/test/stats-overview.test.ts b/test/stats-overview.test.ts index efc05c4f..62844d04 100644 --- a/test/stats-overview.test.ts +++ b/test/stats-overview.test.ts @@ -48,6 +48,29 @@ 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 from Monday with week_start=1', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-24T18:00:00Z')); // a Saturday + const { app } = makeTestApp(); + const { headers } = await registerUser(app); + const global = { + device_id: 'x4', device: 'CrossPoint', v: 5, sessions: 0, seconds: 1440, 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: [ + { date: '2026-10-18', seconds: 600 }, // Sunday: last week + { date: '2026-10-19', seconds: 300 }, // Monday + { date: '2026-10-24', seconds: 540 }, + ], + }; + expect((await app.request('/api/v1/stats/global', { method: 'PUT', headers, body: JSON.stringify(global) })).status).toBe(200); + + const rolling = await (await app.request('/api/v1/stats/overview?days=10', { headers })).json(); + expect(rolling.week_seconds).toBe(1440); + const fromMonday = await (await app.request('/api/v1/stats/overview?days=10&week_start=1', { headers })).json(); + expect(fromMonday.week_seconds).toBe(840); + expect((await app.request('/api/v1/stats/overview?week_start=0', { 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')); From c3df46a810e8d48a6e3e89d6b93ad34c5c5d4cd4 Mon Sep 17 00:00:00 2001 From: Eszter Date: Mon, 26 Oct 2026 14:06:59 +0100 Subject: [PATCH 2/2] feat: week_start=0 for a week from Sunday --- docs/API.md | 4 ++-- src/routes/v1/stats.ts | 7 ++++--- test/stats-overview.test.ts | 37 ++++++++++++++++++++++--------------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/API.md b/docs/API.md index b2665ac5..f672408a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -608,8 +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. With `week_start=1`, `week_seconds` covers the days since Monday instead, for a - reader that shows the calendar week. Any other value is refused. + 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. diff --git a/src/routes/v1/stats.ts b/src/routes/v1/stats.ts index 9897f6a1..7e70b94f 100644 --- a/src/routes/v1/stats.ts +++ b/src/routes/v1/stats.ts @@ -421,7 +421,7 @@ export function statsRoutes(db: DB, http?: HttpTransport): Hono { if ( filter === null || (groupBy !== undefined && groupBy !== 'language' && groupBy !== 'genre') || - (weekStart !== undefined && weekStart !== '1') + (weekStart !== undefined && weekStart !== '0' && weekStart !== '1') ) { return kosyncError(c, 403, 2003, 'Invalid request'); } @@ -429,8 +429,9 @@ export function statsRoutes(db: DB, http?: HttpTransport): Hono { 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 with week_start=1 the days since Monday (2000-01-01 was a Saturday). - const weekDays = weekStart === '1' ? ((today + 5) % 7) + 1 : 7; + // 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, books: typeof activity.books, pages: number, time: OverviewTime) => { const past = [...read].filter((d) => d <= today).sort((a, b) => a - b); diff --git a/test/stats-overview.test.ts b/test/stats-overview.test.ts index 62844d04..5c027eb0 100644 --- a/test/stats-overview.test.ts +++ b/test/stats-overview.test.ts @@ -48,27 +48,34 @@ 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 from Monday with week_start=1', async () => { + it('counts week_seconds for the calendar week with week_start', async () => { vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(new Date('2026-10-24T18:00:00Z')); // a Saturday - const { app } = makeTestApp(); - const { headers } = await registerUser(app); + 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: 1440, pages: 0, completed: 0, + 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: [ - { date: '2026-10-18', seconds: 600 }, // Sunday: last week - { date: '2026-10-19', seconds: 300 }, // Monday - { date: '2026-10-24', seconds: 540 }, - ], + 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 rolling = await (await app.request('/api/v1/stats/overview?days=10', { headers })).json(); - expect(rolling.week_seconds).toBe(1440); - const fromMonday = await (await app.request('/api/v1/stats/overview?days=10&week_start=1', { headers })).json(); - expect(fromMonday.week_seconds).toBe(840); - expect((await app.request('/api/v1/stats/overview?week_start=0', { headers })).status).toBe(403); + 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 () => {