mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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([
|
||||
[
|
||||
|
||||
@@ -1085,14 +1085,17 @@ export class UserStatisticsRepository {
|
||||
isSuperuser: boolean,
|
||||
filterLibraryIds?: number[],
|
||||
days = 365,
|
||||
timeZone = 'UTC',
|
||||
): Promise<UserSessionArchetypePoint[]> {
|
||||
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<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 dowExpr = sql<number>`extract(dow from ${readingSessions.startedAt})::int`;
|
||||
const dowExpr = sql<number>`extract(dow from ${localStartedAt})::int`;
|
||||
|
||||
const rows = await this.db
|
||||
.select({ hour: hourExpr, durationMinutes: durationExpr, dayOfWeek: dowExpr })
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1203,9 +1203,12 @@ export class UserStatisticsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async getSessionArchetypes(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserSessionArchetypePoint[]> {
|
||||
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<UserGenreReadingTimeItem[]> {
|
||||
|
||||
Reference in New Issue
Block a user