mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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)
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user