mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user