fix(stats): use local timezone for peak reading hours

Pass the user's timezone settings to the database query to ensure the
Reading Clock widget groups peak reading hours correctly, instead of
defaulting to UTC.

Also includes a minor UI update to the KOReader plugin to expose a
download options button when multiple formats are available.

Closes #513
This commit is contained in:
neon
2026-07-03 05:11:14 +00:00
parent 6fdd2569e1
commit 68134a9f85
5 changed files with 54 additions and 9 deletions
@@ -1781,6 +1781,14 @@ function BookOrbitCatalog:showBookActionSheet(detail, opts)
UIManager:close(dialog) UIManager:close(dialog)
callback() callback()
end end
local download_options_button = #supported_files > 0 and {
text = _("Download options"),
callback = function()
closeThen(function()
self:showDownloadOptions(detail)
end)
end,
} or nil
if include_details or allow_select then if include_details or allow_select then
local details_button = include_details and { local details_button = include_details and {
@@ -1841,6 +1849,10 @@ function BookOrbitCatalog:showBookActionSheet(detail, opts)
end end
end end
if download_options_button then
addRow(download_options_button)
end
addRow({ addRow({
text = _("Set read status"), text = _("Set read status"),
callback = function() callback = function()
@@ -2917,11 +2929,7 @@ function BookOrbitCatalog:buildDetailHeader(detail, width, height)
enabled = #supported_files > 0, enabled = #supported_files > 0,
text_font_size = 16, text_font_size = 16,
callback = function() callback = function()
if #supported_files == 1 then self:showDownloadOptions(detail)
self:downloadDefaultFile(detail, supported_files[1])
else
self:showFileChoices(detail)
end
end, end,
} }
table.insert(buttons_row, self.detail_download_button) table.insert(buttons_row, self.detail_download_button)
@@ -1,5 +1,10 @@
import type { SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { UserStatisticsRepository } from './user-statistics.repository'; import { UserStatisticsRepository } from './user-statistics.repository';
const dialect = new PgDialect();
function makeChain(result: unknown, fields?: Record<string, unknown>) { function makeChain(result: unknown, fields?: Record<string, unknown>) {
const chain: Record<string, unknown> = {}; const chain: Record<string, unknown> = {};
for (const key of Object.keys(fields ?? {})) { for (const key of Object.keys(fields ?? {})) {
@@ -107,6 +112,21 @@ describe('UserStatisticsRepository', () => {
await expect(repo.getMonthlyCompletions(5, false, [2], 365)).resolves.toEqual([{ year: 2026, month: 4, count: 2 }]); await expect(repo.getMonthlyCompletions(5, false, [2], 365)).resolves.toEqual([{ year: 2026, month: 4, count: 2 }]);
}); });
it('builds peak reading hour buckets in the provided timezone', async () => {
const db = makeDb([[{ hour: 23, format: 'EPUB', source: 'kobo', readingSeconds: 1800, eventsCount: 1 }]]);
const repo = new UserStatisticsRepository(db as never);
vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([2]);
await expect(repo.getPeakReadingHours(5, false, [2], 30, 'Australia/Brisbane')).resolves.toEqual([
{ hour: 23, format: 'EPUB', source: 'kobo', readingSeconds: 1800, eventsCount: 1 },
]);
const fields = db.select.mock.calls[0]?.[0] as { hour?: SQL } | undefined;
const hourQuery = dialect.sqlToQuery(fields?.hour as SQL);
expect(hourQuery.sql).toContain('"reading_sessions"."started_at" AT TIME ZONE $1');
expect(hourQuery.params).toEqual(['Australia/Brisbane']);
});
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([
[ [
@@ -171,11 +171,13 @@ export class UserStatisticsRepository {
isSuperuser: boolean, isSuperuser: boolean,
filterLibraryIds?: number[], filterLibraryIds?: number[],
days = 365, days = 365,
timeZone = 'UTC',
): Promise<{ hour: number; format: string; source: ReadingSessionSource | null; readingSeconds: number; eventsCount: number }[]> { ): Promise<{ hour: number; format: string; source: ReadingSessionSource | null; readingSeconds: number; eventsCount: number }[]> {
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 hourExpr = sql<number>`extract(hour from ${readingSessions.startedAt})::int`; const resolvedTimeZone = resolveTimeZone(timeZone, 'UTC');
const hourExpr = sql<number>`extract(hour from (${readingSessions.startedAt} AT TIME ZONE ${resolvedTimeZone}))::int`;
const formatExpr = sql<string>`upper(coalesce(${bookFiles.format}, 'UNKNOWN'))`; const formatExpr = sql<string>`upper(coalesce(${bookFiles.format}, 'UNKNOWN'))`;
return this.db return this.db
@@ -102,7 +102,7 @@ describe('UserStatisticsService', () => {
const service = new UserStatisticsService(repo as any); const service = new UserStatisticsService(repo as any);
const result = await service.getPeakReadingHours({ id: 123, isSuperuser: false } as any, { libraryIds: [] }); const result = await service.getPeakReadingHours({ id: 123, isSuperuser: false } as any, { libraryIds: [] });
expect(repo.getPeakReadingHours).toHaveBeenCalledWith(123, false, [], 365); expect(repo.getPeakReadingHours).toHaveBeenCalledWith(123, false, [], 365, 'UTC');
expect(result).toHaveLength(24); expect(result).toHaveLength(24);
expect(result[8]).toEqual( expect(result[8]).toEqual(
expect.objectContaining({ hour: 8, readingSeconds: 600, eventsCount: 3, bySource: { bookorbit: 400, koreader: 0, kobo: 200 } }), expect.objectContaining({ hour: 8, readingSeconds: 600, eventsCount: 3, bySource: { bookorbit: 400, koreader: 0, kobo: 200 } }),
@@ -115,6 +115,20 @@ describe('UserStatisticsService', () => {
); );
}); });
it('passes the resolved user timezone to peak reading hours', async () => {
const repo = {
getPeakReadingHours: vi.fn().mockResolvedValue([]),
};
const service = new UserStatisticsService(repo as any);
await service.getPeakReadingHours({ id: 123, isSuperuser: false, settings: { timezone: 'Australia/Brisbane' } } as any, {
days: 30,
libraryIds: [2],
});
expect(repo.getPeakReadingHours).toHaveBeenCalledWith(123, false, [2], 30, 'Australia/Brisbane');
});
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([
@@ -218,9 +218,10 @@ export class UserStatisticsService {
async getPeakReadingHours(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserPeakHourStat[]> { async getPeakReadingHours(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserPeakHourStat[]> {
const days = query.days ?? BEHAVIOR_DEFAULT_DAYS; const days = query.days ?? BEHAVIOR_DEFAULT_DAYS;
const key = this.buildUserCacheKey('peak-hours', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days }); const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
const key = this.buildUserCacheKey('peak-hours', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days, timeZone });
return this.cache.get(String(user.id), key, async () => { return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getPeakReadingHours(user.id, user.isSuperuser, query.libraryIds, days); const rows = await this.repo.getPeakReadingHours(user.id, user.isSuperuser, query.libraryIds, days, timeZone);
const byHour = new Map< const byHour = new Map<
number, number,