From 2be79af880034c4d13a26d93bd0fe135d9e4b9fd Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:05:01 +0000 Subject: [PATCH] fix(stats): read session archetype hours in the user's time zone Convert started_at to the user's configured time zone before taking the hour, minute and weekday, and include the zone in the cache key so a changed setting is picked up. Closes #716 --- .../user-statistics.repository.test.ts | 27 +++++++++++++++++++ .../user-statistics.repository.ts | 7 +++-- .../user-statistics.service.test.ts | 19 +++++++++++++ .../user-statistics.service.ts | 7 +++-- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/server/src/modules/user-statistics/user-statistics.repository.test.ts b/server/src/modules/user-statistics/user-statistics.repository.test.ts index 9325b6ce7..459111da8 100644 --- a/server/src/modules/user-statistics/user-statistics.repository.test.ts +++ b/server/src/modules/user-statistics/user-statistics.repository.test.ts @@ -236,6 +236,33 @@ describe('UserStatisticsRepository', () => { expect(text.match(/AT TIME ZONE/g)).toHaveLength(1); }); + it('derives session archetype hour and weekday in the provided timezone', async () => { + const calls: Array<{ text: string; params: unknown[] }> = []; + const fakeClient = { + query: vi.fn().mockImplementation((cfg: { text: string }, params: unknown[]) => { + calls.push({ text: cfg.text, params }); + return Promise.resolve({ rows: [] }); + }), + }; + const db = drizzle({ client: fakeClient as never, schema }); + const repo = new UserStatisticsRepository(db as never); + + await repo.getSessionArchetypePoints(5, true, [2], 30, 'Australia/Brisbane'); + await repo.getSessionArchetypePoints(5, true, [2], 30, 'Not/AZone'); + await repo.getSessionArchetypePoints(5, true, [2], 30); + + expect(calls).toHaveLength(3); + const [brisbane, invalid, fallback] = calls; + expect(brisbane!.text).toMatch(/extract\(hour from \("reading_sessions"\."started_at" AT TIME ZONE \$\d+\)\)/); + expect(brisbane!.text).toMatch(/extract\(minute from \("reading_sessions"\."started_at" AT TIME ZONE \$\d+\)\)/); + expect(brisbane!.text).toMatch(/extract\(dow from \("reading_sessions"\."started_at" AT TIME ZONE \$\d+\)\)/); + expect(brisbane!.text).not.toMatch(/extract\(\w+ from "reading_sessions"\."started_at"\)/); + expect(brisbane!.params.filter((param) => param === 'Australia/Brisbane')).toHaveLength(3); + expect(invalid!.params).not.toContain('Not/AZone'); + expect(invalid!.params.filter((param) => param === 'UTC')).toHaveLength(3); + expect(fallback!.params.filter((param) => param === 'UTC')).toHaveLength(3); + }); + it('returns per-source daily reading seconds for the heatmap tooltip', async () => { const db = makeDb([ [ diff --git a/server/src/modules/user-statistics/user-statistics.repository.ts b/server/src/modules/user-statistics/user-statistics.repository.ts index ee22d9467..2c7b82f56 100644 --- a/server/src/modules/user-statistics/user-statistics.repository.ts +++ b/server/src/modules/user-statistics/user-statistics.repository.ts @@ -1085,14 +1085,17 @@ export class UserStatisticsRepository { isSuperuser: boolean, filterLibraryIds?: number[], days = 365, + timeZone = 'UTC', ): Promise { const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); const since = this.sinceDateForDays(days); + const resolvedTimeZone = resolveTimeZone(timeZone, 'UTC'); - const hourExpr = sql`extract(hour from ${readingSessions.startedAt}) + extract(minute from ${readingSessions.startedAt}) / 60.0`; + const localStartedAt = sql`(${readingSessions.startedAt} AT TIME ZONE ${resolvedTimeZone})`; + const hourExpr = sql`extract(hour from ${localStartedAt}) + extract(minute from ${localStartedAt}) / 60.0`; const durationExpr = sql`${readingSessions.durationSeconds} / 60.0`; - const dowExpr = sql`extract(dow from ${readingSessions.startedAt})::int`; + const dowExpr = sql`extract(dow from ${localStartedAt})::int`; const rows = await this.db .select({ hour: hourExpr, durationMinutes: durationExpr, dayOfWeek: dowExpr }) diff --git a/server/src/modules/user-statistics/user-statistics.service.test.ts b/server/src/modules/user-statistics/user-statistics.service.test.ts index 3aadbd6d7..202c42305 100644 --- a/server/src/modules/user-statistics/user-statistics.service.test.ts +++ b/server/src/modules/user-statistics/user-statistics.service.test.ts @@ -456,6 +456,25 @@ describe('UserStatisticsService', () => { expect(repo.getPeakReadingHours).toHaveBeenCalledWith(123, false, [2], 30, 'Australia/Brisbane'); }); + it('passes the resolved user timezone to session archetypes and caches per timezone', async () => { + const repo = { + getSessionArchetypePoints: vi.fn().mockResolvedValue([]), + }; + const service = new UserStatisticsService(repo as any, new ReadingAttemptEventsService()); + + await service.getSessionArchetypes({ id: 123, isSuperuser: false, settings: { timezone: 'Australia/Brisbane' } } as any, { libraryIds: [2] }); + await service.getSessionArchetypes({ id: 123, isSuperuser: false, settings: { timezone: 'Australia/Brisbane' } } as any, { libraryIds: [2] }); + await service.getSessionArchetypes({ id: 123, isSuperuser: false, settings: { timezone: 'America/New_York' } } as any, { libraryIds: [2] }); + await service.getSessionArchetypes({ id: 123, isSuperuser: false, settings: { timezone: 'Not/AZone' } } as any, { libraryIds: [2] }); + await service.getSessionArchetypes({ id: 123, isSuperuser: false } as any, { libraryIds: [2] }); + + expect(repo.getSessionArchetypePoints.mock.calls).toEqual([ + [123, false, [2], 365, 'Australia/Brisbane'], + [123, false, [2], 365, 'America/New_York'], + [123, false, [2], 365, 'UTC'], + ]); + }); + it('returns all weekday buckets for favorite reading days', async () => { const repo = { getFavoriteReadingDays: vi.fn().mockResolvedValue([ diff --git a/server/src/modules/user-statistics/user-statistics.service.ts b/server/src/modules/user-statistics/user-statistics.service.ts index f42d63108..f2a9c74cc 100644 --- a/server/src/modules/user-statistics/user-statistics.service.ts +++ b/server/src/modules/user-statistics/user-statistics.service.ts @@ -1203,9 +1203,12 @@ export class UserStatisticsService implements OnModuleInit, OnModuleDestroy { async getSessionArchetypes(user: RequestUser, query: UserDailyReadingQueryDto): Promise { const days = query.days ?? SESSION_ARCHETYPES_DEFAULT_DAYS; - const key = this.buildUserCacheKey('session-archetypes', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days }); + const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC'); + const key = this.buildUserCacheKey('session-archetypes', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days, timeZone }); this.readingAttemptEvents.flushPendingChanges(user.id); - return this.cache.get(String(user.id), key, () => this.repo.getSessionArchetypePoints(user.id, user.isSuperuser, query.libraryIds, days)); + return this.cache.get(String(user.id), key, () => + this.repo.getSessionArchetypePoints(user.id, user.isSuperuser, query.libraryIds, days, timeZone), + ); } async getGenreReadingTime(user: RequestUser, query: UserDailyReadingQueryDto): Promise {