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)
callback()
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
local details_button = include_details and {
@@ -1841,6 +1849,10 @@ function BookOrbitCatalog:showBookActionSheet(detail, opts)
end
end
if download_options_button then
addRow(download_options_button)
end
addRow({
text = _("Set read status"),
callback = function()
@@ -2917,11 +2929,7 @@ function BookOrbitCatalog:buildDetailHeader(detail, width, height)
enabled = #supported_files > 0,
text_font_size = 16,
callback = function()
if #supported_files == 1 then
self:downloadDefaultFile(detail, supported_files[1])
else
self:showFileChoices(detail)
end
self:showDownloadOptions(detail)
end,
}
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';
const dialect = new PgDialect();
function makeChain(result: unknown, fields?: Record<string, unknown>) {
const chain: Record<string, unknown> = {};
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 }]);
});
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 () => {
const db = makeDb([
[
@@ -171,11 +171,13 @@ export class UserStatisticsRepository {
isSuperuser: boolean,
filterLibraryIds?: number[],
days = 365,
timeZone = 'UTC',
): Promise<{ hour: number; format: string; source: ReadingSessionSource | null; readingSeconds: number; eventsCount: number }[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
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'))`;
return this.db
@@ -102,7 +102,7 @@ describe('UserStatisticsService', () => {
const service = new UserStatisticsService(repo as any);
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[8]).toEqual(
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 () => {
const repo = {
getFavoriteReadingDays: vi.fn().mockResolvedValue([
@@ -218,9 +218,10 @@ export class UserStatisticsService {
async getPeakReadingHours(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserPeakHourStat[]> {
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 () => {
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<
number,