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
This commit is contained in:
neon
2026-10-20 17:05:01 +00:00
parent cc6bac696b
commit 2be79af880
4 changed files with 56 additions and 4 deletions
@@ -236,6 +236,33 @@ describe('UserStatisticsRepository', () => {
expect(text.match(/AT TIME ZONE/g)).toHaveLength(1); 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 () => { it('returns per-source daily reading seconds for the heatmap tooltip', async () => {
const db = makeDb([ const db = makeDb([
[ [
@@ -1085,14 +1085,17 @@ export class UserStatisticsRepository {
isSuperuser: boolean, isSuperuser: boolean,
filterLibraryIds?: number[], filterLibraryIds?: number[],
days = 365, days = 365,
timeZone = 'UTC',
): Promise<UserSessionArchetypePoint[]> { ): Promise<UserSessionArchetypePoint[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const since = this.sinceDateForDays(days); const since = this.sinceDateForDays(days);
const resolvedTimeZone = resolveTimeZone(timeZone, 'UTC');
const hourExpr = sql<number>`extract(hour from ${readingSessions.startedAt}) + extract(minute from ${readingSessions.startedAt}) / 60.0`; const localStartedAt = sql`(${readingSessions.startedAt} AT TIME ZONE ${resolvedTimeZone})`;
const hourExpr = sql<number>`extract(hour from ${localStartedAt}) + extract(minute from ${localStartedAt}) / 60.0`;
const durationExpr = sql<number>`${readingSessions.durationSeconds} / 60.0`; const durationExpr = sql<number>`${readingSessions.durationSeconds} / 60.0`;
const dowExpr = sql<number>`extract(dow from ${readingSessions.startedAt})::int`; const dowExpr = sql<number>`extract(dow from ${localStartedAt})::int`;
const rows = await this.db const rows = await this.db
.select({ hour: hourExpr, durationMinutes: durationExpr, dayOfWeek: dowExpr }) .select({ hour: hourExpr, durationMinutes: durationExpr, dayOfWeek: dowExpr })
@@ -456,6 +456,25 @@ describe('UserStatisticsService', () => {
expect(repo.getPeakReadingHours).toHaveBeenCalledWith(123, false, [2], 30, 'Australia/Brisbane'); 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 () => { it('returns all weekday buckets for favorite reading days', async () => {
const repo = { const repo = {
getFavoriteReadingDays: vi.fn().mockResolvedValue([ getFavoriteReadingDays: vi.fn().mockResolvedValue([
@@ -1203,9 +1203,12 @@ export class UserStatisticsService implements OnModuleInit, OnModuleDestroy {
async getSessionArchetypes(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserSessionArchetypePoint[]> { async getSessionArchetypes(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserSessionArchetypePoint[]> {
const days = query.days ?? SESSION_ARCHETYPES_DEFAULT_DAYS; 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); 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<UserGenreReadingTimeItem[]> { async getGenreReadingTime(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserGenreReadingTimeItem[]> {