From acf056095a3d520a5c8b1423a5325a0b618a22f0 Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Mon, 15 Jun 2026 16:08:38 -0400 Subject: [PATCH 01/14] Catching and reseting IsLoading if cover search fails --- .../components/book-page-cover-selector.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/book-page/components/book-page-cover-selector.tsx b/apps/web/src/pages/book-page/components/book-page-cover-selector.tsx index 34642933..d7dca1ba 100644 --- a/apps/web/src/pages/book-page/components/book-page-cover-selector.tsx +++ b/apps/web/src/pages/book-page/components/book-page-cover-selector.tsx @@ -42,8 +42,18 @@ export function BookPageCoverSelector({ loadedCovers: [], isSavingCovers: false, })); - const coverIds = await listCovers(state.query || book.title); - setState((prev) => ({ ...prev, isLoading: false, data: coverIds })); + try { + const coverIds = await listCovers(state.query || book.title); + setState((prev) => ({ ...prev, isLoading: false, data: coverIds })); + } catch (error) { + setState((prev) => ({ ...prev, isLoading: false, data: [] })); + notifications.show({ + title: 'Error fetching covers', + message: `Unable to fetch covers for ${book.title}.`, + color: 'red', + position: 'top-center', + }); + } }; const onSave = async (coverId: string) => { From b885215762dd5219adde6182305d3a0e4b82ecda Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Tue, 16 Jun 2026 09:12:15 -0400 Subject: [PATCH 02/14] Added Daily reading streaks --- apps/server/src/stats/stats-router.test.ts | 2 + apps/server/src/stats/stats-router.ts | 4 ++ apps/server/src/stats/stats-service.test.ts | 66 +++++++++++++++++++- apps/server/src/stats/stats-service.ts | 48 +++++++++++++- apps/web/src/api/use-page-stats.ts | 2 + apps/web/src/pages/stats-page/stats-page.tsx | 16 ++++- packages/common/types/stats-api.ts | 2 + 7 files changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/server/src/stats/stats-router.test.ts b/apps/server/src/stats/stats-router.test.ts index a059be01..272300c7 100644 --- a/apps/server/src/stats/stats-router.test.ts +++ b/apps/server/src/stats/stats-router.test.ts @@ -38,5 +38,7 @@ describe('GET /stats', () => { expect(body).toHaveProperty('perMonth'); expect(body.longestDay).toBe(20); expect(body.totalPagesRead).toBe(4); + expect(body).toHaveProperty('currentDailyReadingStreak'); + expect(body).toHaveProperty('longestDailyReadingStreak'); }); }); diff --git a/apps/server/src/stats/stats-router.ts b/apps/server/src/stats/stats-router.ts index ff8285e7..cd3acd78 100644 --- a/apps/server/src/stats/stats-router.ts +++ b/apps/server/src/stats/stats-router.ts @@ -20,6 +20,8 @@ router.get('/', async (_: Request, res: Response) => { const totalReadingTime = StatsService.totalReadingTime(stats); const longestDay = StatsService.longestDay(stats); const last7DaysReadTime = StatsService.last7DaysReadTime(stats); + const currentDailyReadingStreak = StatsService.currentDailyReadingStreak(stats); + const longestDailyReadingStreak = StatsService.longestDailyReadingStreak(stats); const response: GetAllStatsResponse = { stats, @@ -29,6 +31,8 @@ router.get('/', async (_: Request, res: Response) => { totalReadingTime, longestDay, last7DaysReadTime, + currentDailyReadingStreak, + longestDailyReadingStreak, totalPagesRead, }; diff --git a/apps/server/src/stats/stats-service.test.ts b/apps/server/src/stats/stats-service.test.ts index 5e605dac..4f5cdf1a 100644 --- a/apps/server/src/stats/stats-service.test.ts +++ b/apps/server/src/stats/stats-service.test.ts @@ -6,7 +6,7 @@ import { createDevice } from '../db/factories/device-factory'; import { createPageStat } from '../db/factories/page-stat-factory'; import { db } from '../knex'; import { StatsService } from './stats-service'; -import { subDays } from 'date-fns'; +import { startOfDay, subDays } from 'date-fns'; describe(StatsService, () => { let device: Device; @@ -195,6 +195,70 @@ describe(StatsService, () => { }); }); + describe(StatsService.currentDailyReadingStreak, () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns streak ending today', () => { + const today = startOfDay(new Date('2025-04-10T15:00:00.000Z')).getTime(); + vi.useFakeTimers(); + vi.setSystemTime(today); + + const stats = [0, 1, 2, 5].map((daysAgo, index) => ({ + page: index, + start_time: subDays(today, daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + + const result = StatsService.currentDailyReadingStreak(stats); + expect(result).toEqual(3); + }); + + it('returns 0 when today has no reading activity', () => { + const today = startOfDay(new Date('2025-04-10T15:00:00.000Z')).getTime(); + vi.useFakeTimers(); + vi.setSystemTime(today); + + const stats = [1, 2, 3].map((daysAgo, index) => ({ + page: index, + start_time: subDays(today, daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + + const result = StatsService.currentDailyReadingStreak(stats); + expect(result).toEqual(0); + }); + }); + + describe(StatsService.longestDailyReadingStreak, () => { + it('returns the longest consecutive daily streak', () => { + const anchor = startOfDay(new Date('2025-04-10T15:00:00.000Z')).getTime(); + const stats = [0, 1, 2, 4, 6, 7, 8, 9].map((daysAgo, index) => ({ + page: index, + start_time: subDays(anchor, daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + + const result = StatsService.longestDailyReadingStreak(stats); + expect(result).toEqual(4); + }); + + it('returns 0 with no stats', () => { + const result = StatsService.longestDailyReadingStreak([]); + expect(result).toEqual(0); + }); + }); + describe(StatsService.perDayOfTheWeek, () => { // FIXME: Flaky - Depends on locale it.skip('returns the correct per day of the week', async () => { diff --git a/apps/server/src/stats/stats-service.ts b/apps/server/src/stats/stats-service.ts index 50fd6815..e777efe3 100644 --- a/apps/server/src/stats/stats-service.ts +++ b/apps/server/src/stats/stats-service.ts @@ -5,7 +5,7 @@ import { PerDayOfTheWeek, PerMonthReadingTime, } from '@koinsight/common/types'; -import { format, startOfDay, subDays } from 'date-fns'; +import { differenceInCalendarDays, format, startOfDay, subDays } from 'date-fns'; import { groupBy, sum } from 'ramda'; export class StatsService { @@ -72,10 +72,56 @@ export class StatsService { return sum(lastSevenDays.map((s) => s.duration)); } + static currentDailyReadingStreak(stats: PageStat[]) { + if (!stats?.length) { + return 0; + } + + const uniqueDays = new Set(this.getUniqueReadingDays(stats)); + + let streak = 0; + let currentDay = startOfDay(new Date()).getTime(); + + while (uniqueDays.has(currentDay)) { + streak += 1; + currentDay = startOfDay(subDays(currentDay, 1)).getTime(); + } + + return streak; + } + + static longestDailyReadingStreak(stats: PageStat[]) { + const uniqueDays = this.getUniqueReadingDays(stats); + + if (!uniqueDays.length) { + return 0; + } + + let longestStreak = 1; + let currentStreak = 1; + + for (let i = 1; i < uniqueDays.length; i += 1) { + if (differenceInCalendarDays(uniqueDays[i], uniqueDays[i - 1]) === 1) { + currentStreak += 1; + } else { + longestStreak = Math.max(longestStreak, currentStreak); + currentStreak = 1; + } + } + + return Math.max(longestStreak, currentStreak); + } + static totalPagesRead(books: BookWithData[]) { return books.reduce((acc, book) => acc + book.total_read_pages, 0); } + private static getUniqueReadingDays(stats: PageStat[]) { + return Array.from(new Set(stats.map((stat) => startOfDay(stat.start_time).getTime()))).sort( + (a, b) => a - b + ); + } + private static getPagesPerDay(stats: PageStat[], books: Book[]) { const booksByMd5 = books?.reduce( (acc, book) => { diff --git a/apps/web/src/api/use-page-stats.ts b/apps/web/src/api/use-page-stats.ts index 797b3aa6..8c2c1d80 100644 --- a/apps/web/src/api/use-page-stats.ts +++ b/apps/web/src/api/use-page-stats.ts @@ -13,6 +13,8 @@ export function usePageStats() { totalReadingTime: 0, longestDay: 0, last7DaysReadTime: 0, + currentDailyReadingStreak: 0, + longestDailyReadingStreak: 0, totalPagesRead: 0, }, }); diff --git a/apps/web/src/pages/stats-page/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index 104bf2fb..1e08a1b7 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -9,7 +9,7 @@ import { useComputedColorScheme, useMantineTheme, } from '@mantine/core'; -import { IconClock, IconMaximize, IconPageBreak } from '@tabler/icons-react'; +import { IconClock, IconFlame, IconMaximize, IconPageBreak, IconTrophy } from '@tabler/icons-react'; import { JSX, useMemo } from 'react'; import { BarProps } from 'recharts'; import { useBooks } from '../../api/books'; @@ -34,6 +34,8 @@ export function StatsPage(): JSX.Element { totalReadingTime, longestDay, last7DaysReadTime, + currentDailyReadingStreak, + longestDailyReadingStreak, totalPagesRead, }, isLoading: statsLoading, @@ -49,6 +51,8 @@ export function StatsPage(): JSX.Element { ); }, [books]); + const formatStreakDays = (value: number) => `${value} day${value === 1 ? '' : 's'}`; + if (booksLoading || statsLoading) { return ( @@ -101,6 +105,16 @@ export function StatsPage(): JSX.Element { value: mostPagesInADay ?? 'N/A', icon: IconMaximize, }, + { + label: 'Current Daily Reading Streak', + value: formatStreakDays(currentDailyReadingStreak), + icon: IconFlame, + }, + { + label: 'Longest Daily Reading Streak', + value: formatStreakDays(longestDailyReadingStreak), + icon: IconTrophy, + }, ]} /> diff --git a/packages/common/types/stats-api.ts b/packages/common/types/stats-api.ts index 5942a85b..44172dcd 100644 --- a/packages/common/types/stats-api.ts +++ b/packages/common/types/stats-api.ts @@ -21,5 +21,7 @@ export type GetAllStatsResponse = { totalReadingTime: number; longestDay: number; last7DaysReadTime: number; + currentDailyReadingStreak: number; + longestDailyReadingStreak: number; totalPagesRead: number; }; From 81fd213c929ede4ffc0e92aae8d4ab91df23ee9d Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Tue, 16 Jun 2026 14:03:45 -0400 Subject: [PATCH 03/14] Update stat cards with details and icons --- .../statistics/statistic.module.css | 8 ++ .../src/components/statistics/statistic.tsx | 18 +-- .../src/components/statistics/statistics.tsx | 8 +- apps/web/src/pages/stats-page/stats-page.tsx | 108 +++++++++++++++++- 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/statistics/statistic.module.css b/apps/web/src/components/statistics/statistic.module.css index 85604ccd..53257a01 100644 --- a/apps/web/src/components/statistics/statistic.module.css +++ b/apps/web/src/components/statistics/statistic.module.css @@ -31,3 +31,11 @@ font-weight: 900; text-transform: uppercase; } + +.Detail { + min-height: 20px; +} + +.DetailEmpty { + visibility: hidden; +} diff --git a/apps/web/src/components/statistics/statistic.tsx b/apps/web/src/components/statistics/statistic.tsx index 2d0c1589..8f002623 100644 --- a/apps/web/src/components/statistics/statistic.tsx +++ b/apps/web/src/components/statistics/statistic.tsx @@ -7,10 +7,11 @@ import style from './statistic.module.css'; export type StatisticProps = { label: string; value: string | number; + detail?: string; icon: ComponentType<{ size: number; stroke: number; className: string }>; }; -export function Statistic({ label, value, icon: Icon }: StatisticProps): JSX.Element { +export function Statistic({ label, value, detail, icon: Icon }: StatisticProps): JSX.Element { return ( @@ -22,15 +23,16 @@ export function Statistic({ label, value, icon: Icon }: StatisticProps): JSX.Ele

{value}

- {/* 0 ? 'teal' : 'red'} fz="sm" fw={500} className={style.diff}> - {stat.diff}% - - */}
- {/* - Compared to previous month - */} + + {detail ?? 'No detail'} +
); } diff --git a/apps/web/src/components/statistics/statistics.tsx b/apps/web/src/components/statistics/statistics.tsx index 0be80467..913f8766 100644 --- a/apps/web/src/components/statistics/statistics.tsx +++ b/apps/web/src/components/statistics/statistics.tsx @@ -12,7 +12,13 @@ export function Statistics({ data }: StatisticsProps): JSX.Element { return ( {data.map((stat) => ( - + ))} ); diff --git a/apps/web/src/pages/stats-page/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index 1e08a1b7..99c5019c 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -9,7 +9,15 @@ import { useComputedColorScheme, useMantineTheme, } from '@mantine/core'; -import { IconClock, IconFlame, IconMaximize, IconPageBreak, IconTrophy } from '@tabler/icons-react'; +import { + IconFiles, + IconFileStar, + IconClock, + IconClockStar, + IconFlame, + IconTrophy, +} from '@tabler/icons-react'; +import { format, startOfDay } from 'date-fns'; import { JSX, useMemo } from 'react'; import { BarProps } from 'recharts'; import { useBooks } from '../../api/books'; @@ -52,6 +60,88 @@ export function StatsPage(): JSX.Element { }, [books]); const formatStreakDays = (value: number) => `${value} day${value === 1 ? '' : 's'}`; + const formatLocalizedNumber = (value: number) => new Intl.NumberFormat().format(value); + + const formatStatDate = (dayTimestamp?: number) => + dayTimestamp ? format(dayTimestamp, 'MMM d, yyyy') : 'No reading data yet'; + + const { longestDayTimestamp, mostPagesDayTimestamp, longestStreakRange } = useMemo(() => { + if (!stats?.length) { + return { + longestDayTimestamp: undefined, + mostPagesDayTimestamp: undefined, + longestStreakRange: undefined, + }; + } + + const durationPerDay = new Map(); + const pagesPerDay = new Map(); + + for (const stat of stats) { + const dayStart = startOfDay(stat.start_time).getTime(); + durationPerDay.set(dayStart, (durationPerDay.get(dayStart) ?? 0) + stat.duration); + + const referencePages = booksByMd5?.[stat.book_md5]?.reference_pages; + const pagesForStat = stat.total_pages && referencePages ? (1 / stat.total_pages) * referencePages : 1; + pagesPerDay.set(dayStart, (pagesPerDay.get(dayStart) ?? 0) + pagesForStat); + } + + let longestDayEntry: [number, number] | undefined; + for (const entry of durationPerDay.entries()) { + if (!longestDayEntry || entry[1] > longestDayEntry[1]) { + longestDayEntry = entry; + } + } + + let mostPagesEntry: [number, number] | undefined; + for (const entry of pagesPerDay.entries()) { + if (!mostPagesEntry || entry[1] > mostPagesEntry[1]) { + mostPagesEntry = entry; + } + } + + const uniqueDays = Array.from(durationPerDay.keys()).sort((a, b) => a - b); + + let bestStart = uniqueDays[0]; + let bestEnd = uniqueDays[0]; + let bestLength = 1; + + let currentStart = uniqueDays[0]; + let currentEnd = uniqueDays[0]; + let currentLength = 1; + + for (let i = 1; i < uniqueDays.length; i += 1) { + const currentDay = uniqueDays[i]; + const previousDay = uniqueDays[i - 1]; + const isConsecutive = currentDay - previousDay === 24 * 60 * 60 * 1000; + + if (isConsecutive) { + currentEnd = currentDay; + currentLength += 1; + } else { + if (currentLength > bestLength) { + bestStart = currentStart; + bestEnd = currentEnd; + bestLength = currentLength; + } + + currentStart = currentDay; + currentEnd = currentDay; + currentLength = 1; + } + } + + if (currentLength > bestLength) { + bestStart = currentStart; + bestEnd = currentEnd; + } + + return { + longestDayTimestamp: longestDayEntry?.[0], + mostPagesDayTimestamp: mostPagesEntry?.[0], + longestStreakRange: `${format(bestStart, 'MMM d, yyyy')} - ${format(bestEnd, 'MMM d, yyyy')}`, + }; + }, [stats, booksByMd5]); if (booksLoading || statsLoading) { return ( @@ -92,18 +182,23 @@ export function StatsPage(): JSX.Element { }, { label: 'Total pages read', - value: totalPagesRead, - icon: IconPageBreak, + value: formatLocalizedNumber(totalPagesRead), + icon: IconFiles, }, { label: 'Longest time reading in a day', value: formatSecondsToHumanReadable(longestDay), - icon: IconMaximize, + detail: formatStatDate(longestDayTimestamp), + icon: IconClockStar, }, { label: 'Most pages in a day', - value: mostPagesInADay ?? 'N/A', - icon: IconMaximize, + value: + mostPagesInADay !== null && mostPagesInADay !== undefined + ? formatLocalizedNumber(mostPagesInADay) + : 'N/A', + detail: formatStatDate(mostPagesDayTimestamp), + icon: IconFileStar, }, { label: 'Current Daily Reading Streak', @@ -113,6 +208,7 @@ export function StatsPage(): JSX.Element { { label: 'Longest Daily Reading Streak', value: formatStreakDays(longestDailyReadingStreak), + detail: longestStreakRange ?? 'N/A', icon: IconTrophy, }, ]} From 7b4d655f5b7c063a5ab3ad4e6a60d45cef2dafa6 Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 12:15:47 -0400 Subject: [PATCH 04/14] chore: prepare local fork deployment --- .github/workflows/ci.yaml | 2 +- Dockerfile | 4 +++- apps/server/src/knex.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 274692ce..da489f9a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install dependencies run: npm ci diff --git a/Dockerfile b/Dockerfile index a0d56815..4376a9e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,12 @@ FROM node:22-alpine AS builder WORKDIR /app COPY package.json . +COPY package-lock.json . COPY apps/server/package.json ./apps/server/package.json COPY apps/web/package.json ./apps/web/package.json COPY packages/common/package.json ./packages/common/package.json -RUN npm install +RUN npm ci COPY turbo.json . COPY apps ./apps @@ -28,6 +29,7 @@ COPY --from=builder /app/apps/web/dist /app/apps/web/dist COPY plugins ./plugins ENV NODE_ENV="production" +ENV HOSTNAME="0.0.0.0" ENV DATA_PATH="/app/data" ENV MAX_FILE_SIZE_MB="100" diff --git a/apps/server/src/knex.ts b/apps/server/src/knex.ts index ee3683be..2c72c062 100644 --- a/apps/server/src/knex.ts +++ b/apps/server/src/knex.ts @@ -3,4 +3,4 @@ import { appConfig } from './config'; import config from './knexfile'; const environment = appConfig.env || 'development'; -export const db: Knex = knex(config[environment]); +export const db: Knex = knex(config[environment] ?? config.development); From f13f33f85fe2e91d94ec28b462beac153a4997ef Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 13:25:01 -0400 Subject: [PATCH 05/14] Local deploy example --- .gitignore | 1 + deploy.local.example.sh | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 deploy.local.example.sh diff --git a/.gitignore b/.gitignore index 82f9cacb..1dbf7658 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ node_modules .env.development .env.production +deploy.sh *.tsbuildinfo diff --git a/deploy.local.example.sh b/deploy.local.example.sh new file mode 100644 index 00000000..ec6524aa --- /dev/null +++ b/deploy.local.example.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Copy this file to deploy.sh on the deployment host and adjust these paths. +# Keep deploy.sh uncommitted if it contains host-specific directories. +APP_DIR="/opt/koinsight" +SRC_DIR="/opt/koinsight-src" +APP_URL="http://127.0.0.1:3000" + +BACKUP_DIR="$APP_DIR/backups/data-$(date +%Y%m%d-%H%M%S)" + +cd "$APP_DIR" + +echo "Stopping KoInsight..." +docker compose down + +echo "Backing up data..." +mkdir -p "$APP_DIR/backups" +cp -a "$APP_DIR/data" "$BACKUP_DIR" + +echo "Updating source..." +cd "$SRC_DIR" +git pull --ff-only + +echo "Building and starting..." +cd "$APP_DIR" +# The compose file in APP_DIR should set build.context to SRC_DIR. +docker compose up -d --build + +echo "Smoke testing..." +sleep 5 +curl -fsS "$APP_URL/" >/dev/null +curl -fsS "$APP_URL/api/plugin/download" >/dev/null + +docker compose ps From 3effcc2fdd42bfa4f00b142141feec8ebd921e0a Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 15:21:50 -0400 Subject: [PATCH 06/14] If you haven't read yet today it won't break your streak --- apps/server/src/stats/stats-service.test.ts | 21 ++++++++++++++++++++- apps/server/src/stats/stats-service.ts | 4 +++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/server/src/stats/stats-service.test.ts b/apps/server/src/stats/stats-service.test.ts index 4f5cdf1a..424a4427 100644 --- a/apps/server/src/stats/stats-service.test.ts +++ b/apps/server/src/stats/stats-service.test.ts @@ -218,7 +218,7 @@ describe(StatsService, () => { expect(result).toEqual(3); }); - it('returns 0 when today has no reading activity', () => { + it('returns streak ending yesterday when today has no reading activity', () => { const today = startOfDay(new Date('2025-04-10T15:00:00.000Z')).getTime(); vi.useFakeTimers(); vi.setSystemTime(today); @@ -232,6 +232,24 @@ describe(StatsService, () => { book_md5: book.md5, })); + const result = StatsService.currentDailyReadingStreak(stats); + expect(result).toEqual(3); + }); + + it('returns 0 when neither today nor yesterday has reading activity', () => { + const today = startOfDay(new Date('2025-04-10T15:00:00.000Z')).getTime(); + vi.useFakeTimers(); + vi.setSystemTime(today); + + const stats = [2, 3, 4].map((daysAgo, index) => ({ + page: index, + start_time: subDays(today, daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + const result = StatsService.currentDailyReadingStreak(stats); expect(result).toEqual(0); }); @@ -290,3 +308,4 @@ describe(StatsService, () => { }); }); }); + diff --git a/apps/server/src/stats/stats-service.ts b/apps/server/src/stats/stats-service.ts index e777efe3..94f86b2c 100644 --- a/apps/server/src/stats/stats-service.ts +++ b/apps/server/src/stats/stats-service.ts @@ -80,7 +80,8 @@ export class StatsService { const uniqueDays = new Set(this.getUniqueReadingDays(stats)); let streak = 0; - let currentDay = startOfDay(new Date()).getTime(); + const today = startOfDay(new Date()).getTime(); + let currentDay = uniqueDays.has(today) ? today : startOfDay(subDays(today, 1)).getTime(); while (uniqueDays.has(currentDay)) { streak += 1; @@ -149,3 +150,4 @@ export class StatsService { return pagesPerDay; } } + From fe76bca8f167a49012ef91124d06b33ed6c60f8f Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 15:41:20 -0400 Subject: [PATCH 07/14] Fixed current reading stats --- apps/server/src/stats/stats-service.test.ts | 18 ++++++++++++++++++ apps/server/src/stats/stats-service.ts | 14 ++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/server/src/stats/stats-service.test.ts b/apps/server/src/stats/stats-service.test.ts index 424a4427..b3d50a93 100644 --- a/apps/server/src/stats/stats-service.test.ts +++ b/apps/server/src/stats/stats-service.test.ts @@ -253,6 +253,24 @@ describe(StatsService, () => { const result = StatsService.currentDailyReadingStreak(stats); expect(result).toEqual(0); }); + + it('returns current streak when a historical streak is longer', () => { + const today = startOfDay(new Date('2025-04-10T15:00:00.000Z')).getTime(); + vi.useFakeTimers(); + vi.setSystemTime(today); + + const stats = [1, 2, 5, 6, 7, 8, 9].map((daysAgo, index) => ({ + page: index, + start_time: subDays(today, daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + + expect(StatsService.currentDailyReadingStreak(stats)).toEqual(2); + expect(StatsService.longestDailyReadingStreak(stats)).toEqual(5); + }); }); describe(StatsService.longestDailyReadingStreak, () => { diff --git a/apps/server/src/stats/stats-service.ts b/apps/server/src/stats/stats-service.ts index 94f86b2c..3bc3e3f4 100644 --- a/apps/server/src/stats/stats-service.ts +++ b/apps/server/src/stats/stats-service.ts @@ -77,13 +77,19 @@ export class StatsService { return 0; } - const uniqueDays = new Set(this.getUniqueReadingDays(stats)); + const today = startOfDay(new Date()).getTime(); + const uniqueDays = this.getUniqueReadingDays(stats).filter((day) => day <= today); + const latestReadingDay = uniqueDays[uniqueDays.length - 1]; + if (latestReadingDay === undefined || differenceInCalendarDays(today, latestReadingDay) > 1) { + return 0; + } + + const readingDays = new Set(uniqueDays); let streak = 0; - const today = startOfDay(new Date()).getTime(); - let currentDay = uniqueDays.has(today) ? today : startOfDay(subDays(today, 1)).getTime(); + let currentDay = latestReadingDay; - while (uniqueDays.has(currentDay)) { + while (readingDays.has(currentDay)) { streak += 1; currentDay = startOfDay(subDays(currentDay, 1)).getTime(); } From aa1469b8199b44d3bb405dd8618532144475e0a4 Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 16:33:54 -0400 Subject: [PATCH 08/14] Changed how we calculate date diff --- apps/web/src/pages/stats-page/stats-page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/stats-page/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index 99c5019c..e0514e5a 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -17,7 +17,7 @@ import { IconFlame, IconTrophy, } from '@tabler/icons-react'; -import { format, startOfDay } from 'date-fns'; +import { differenceInCalendarDays, format, startOfDay } from 'date-fns'; import { JSX, useMemo } from 'react'; import { BarProps } from 'recharts'; import { useBooks } from '../../api/books'; @@ -113,7 +113,7 @@ export function StatsPage(): JSX.Element { for (let i = 1; i < uniqueDays.length; i += 1) { const currentDay = uniqueDays[i]; const previousDay = uniqueDays[i - 1]; - const isConsecutive = currentDay - previousDay === 24 * 60 * 60 * 1000; + const isConsecutive = differenceInCalendarDays(currentDay, previousDay) === 1; if (isConsecutive) { currentEnd = currentDay; @@ -134,6 +134,7 @@ export function StatsPage(): JSX.Element { if (currentLength > bestLength) { bestStart = currentStart; bestEnd = currentEnd; + bestLength = currentLength; } return { From 4c650f3799bcf83001d4bfe2b6a4e548472a5b92 Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 19:07:42 -0400 Subject: [PATCH 09/14] Rework of the stats service --- apps/server/src/stats/stats-router.test.ts | 5 +- apps/server/src/stats/stats-service.test.ts | 30 ++-- apps/server/src/stats/stats-service.ts | 141 ++++++++++++++----- apps/web/src/api/use-page-stats.ts | 6 +- apps/web/src/pages/stats-page/stats-page.tsx | 98 ++----------- packages/common/types/stats-api.ts | 22 ++- 6 files changed, 162 insertions(+), 140 deletions(-) diff --git a/apps/server/src/stats/stats-router.test.ts b/apps/server/src/stats/stats-router.test.ts index 272300c7..b288e6c8 100644 --- a/apps/server/src/stats/stats-router.test.ts +++ b/apps/server/src/stats/stats-router.test.ts @@ -36,9 +36,10 @@ describe('GET /stats', () => { // TODO: Do we need a more detailed test here provided everything is from the StatsService? expect(body).toHaveProperty('perMonth'); - expect(body.longestDay).toBe(20); + expect(body.longestDay.duration).toBe(20); + expect(body.mostPagesInADay).toHaveProperty('pages'); expect(body.totalPagesRead).toBe(4); expect(body).toHaveProperty('currentDailyReadingStreak'); - expect(body).toHaveProperty('longestDailyReadingStreak'); + expect(body.longestDailyReadingStreak).toHaveProperty('days'); }); }); diff --git a/apps/server/src/stats/stats-service.test.ts b/apps/server/src/stats/stats-service.test.ts index b3d50a93..f739a221 100644 --- a/apps/server/src/stats/stats-service.test.ts +++ b/apps/server/src/stats/stats-service.test.ts @@ -102,18 +102,23 @@ describe(StatsService, () => { stats.push( await createPageStat(db, book, bookDevice, device, { + page: 10, start_time: new Date(2025, 1, 1).getTime(), }) ); stats.push( await createPageStat(db, book, bookDevice, device, { + page: 11, start_time: new Date(2025, 1, 1).getTime(), }) ); const result = await StatsService.mostPagesInADay([book], stats); - expect(result).toEqual(3); + expect(result).toEqual({ + pages: 3, + timestamp: startOfDay(new Date(2025, 1, 1)).getTime(), + }); }); it('works with reference pages', async () => { @@ -131,23 +136,25 @@ describe(StatsService, () => { stats.push( await createPageStat(db, book, bookDevice, device, { + page: 10, start_time: new Date(2025, 1, 1).getTime(), }) ); stats.push( await createPageStat(db, book, bookDevice, device, { + page: 11, start_time: new Date(2025, 1, 1).getTime(), }) ); const result = await StatsService.mostPagesInADay([book], stats); - expect(result).toEqual(2); // result is rounded + expect(result.pages).toEqual(2); // result is rounded }); it('returns 0 with no stats', async () => { const result = await StatsService.mostPagesInADay([book], []); - expect(result).toEqual(0); + expect(result).toEqual({ pages: 0 }); }); }); @@ -164,12 +171,15 @@ describe(StatsService, () => { ); const result = await StatsService.longestDay(stats); - expect(result).toEqual(40); + expect(result).toEqual({ + duration: 40, + timestamp: startOfDay(new Date(2025, 1, 5)).getTime(), + }); }); it('returns 0 with no stats', async () => { const result = await StatsService.longestDay([]); - expect(result).toEqual(0); + expect(result).toEqual({ duration: 0 }); }); }); @@ -269,7 +279,7 @@ describe(StatsService, () => { })); expect(StatsService.currentDailyReadingStreak(stats)).toEqual(2); - expect(StatsService.longestDailyReadingStreak(stats)).toEqual(5); + expect(StatsService.longestDailyReadingStreak(stats).days).toEqual(5); }); }); @@ -286,12 +296,16 @@ describe(StatsService, () => { })); const result = StatsService.longestDailyReadingStreak(stats); - expect(result).toEqual(4); + expect(result).toEqual({ + days: 4, + start: startOfDay(subDays(anchor, 9)).getTime(), + end: startOfDay(subDays(anchor, 6)).getTime(), + }); }); it('returns 0 with no stats', () => { const result = StatsService.longestDailyReadingStreak([]); - expect(result).toEqual(0); + expect(result).toEqual({ days: 0 }); }); }); diff --git a/apps/server/src/stats/stats-service.ts b/apps/server/src/stats/stats-service.ts index 3bc3e3f4..9e5ead1c 100644 --- a/apps/server/src/stats/stats-service.ts +++ b/apps/server/src/stats/stats-service.ts @@ -1,6 +1,9 @@ import { Book, BookWithData, + DailyReadingStreak, + ReadingDayStat, + ReadingPageStat, PageStat, PerDayOfTheWeek, PerMonthReadingTime, @@ -46,24 +49,51 @@ export class StatsService { .sort((a, b) => a.day - b.day); } - static mostPagesInADay(books: Book[], stats: PageStat[]) { - const max = Math.round(Math.max(...this.getPagesPerDay(stats, books))); - return Math.max(0, max); + static mostPagesInADay(books: Book[], stats: PageStat[]): ReadingPageStat { + const pagesPerDay = this.getPagesPerDayEntries(stats, books); + const maxPagesDay = pagesPerDay.reduce<[number, number] | undefined>((max, entry) => { + if (!max || entry[1] > max[1]) { + return entry; + } + + return max; + }, undefined); + + if (!maxPagesDay) { + return { pages: 0 }; + } + + return { + pages: Math.max(0, Math.round(maxPagesDay[1])), + timestamp: maxPagesDay[0], + }; } static totalReadingTime(stats: PageStat[]) { return sum((stats ?? []).map((s) => s.duration)); } - static longestDay(stats: PageStat[]) { - const timePerDay = stats.reduce>((acc, stat) => { - const day = startOfDay(stat.start_time).getTime(); - acc[day] = (acc[day] || 0) + stat.duration; - return acc; - }, {}); + static longestDay(stats: PageStat[]): ReadingDayStat { + const timePerDay = this.getTimePerDay(stats); + const longestDayEntry = Object.entries(timePerDay).reduce<[number, number] | undefined>( + (max, [day, duration]) => { + if (!max || duration > max[1]) { + return [Number(day), duration]; + } - const maxTime = Math.max(...Object.values(timePerDay ?? [])); - return Math.max(0, maxTime); + return max; + }, + undefined + ); + + if (!longestDayEntry) { + return { duration: 0 }; + } + + return { + duration: Math.max(0, longestDayEntry[1]), + timestamp: longestDayEntry[0], + }; } static last7DaysReadTime(stats: PageStat[]) { @@ -97,39 +127,73 @@ export class StatsService { return streak; } - static longestDailyReadingStreak(stats: PageStat[]) { + static longestDailyReadingStreak(stats: PageStat[]): DailyReadingStreak { const uniqueDays = this.getUniqueReadingDays(stats); + const longestStreak = this.getLongestDailyReadingStreak(uniqueDays); + + if (!longestStreak) { + return { days: 0 }; + } + + return longestStreak; + } + + static totalPagesRead(books: BookWithData[]) { + return books.reduce((acc, book) => acc + book.total_read_pages, 0); + } + private static getUniqueReadingDays(stats: PageStat[]) { + return Array.from(new Set(stats.map((stat) => startOfDay(stat.start_time).getTime()))).sort( + (a, b) => a - b + ); + } + + private static getLongestDailyReadingStreak(uniqueDays: number[]) { if (!uniqueDays.length) { - return 0; + return undefined; } - let longestStreak = 1; - let currentStreak = 1; + let longestStreak = { + start: uniqueDays[0], + end: uniqueDays[0], + days: 1, + }; + + let currentStreak = { ...longestStreak }; for (let i = 1; i < uniqueDays.length; i += 1) { if (differenceInCalendarDays(uniqueDays[i], uniqueDays[i - 1]) === 1) { - currentStreak += 1; + currentStreak.end = uniqueDays[i]; + currentStreak.days += 1; } else { - longestStreak = Math.max(longestStreak, currentStreak); - currentStreak = 1; + if (currentStreak.days > longestStreak.days) { + longestStreak = { ...currentStreak }; + } + + currentStreak = { + start: uniqueDays[i], + end: uniqueDays[i], + days: 1, + }; } } - return Math.max(longestStreak, currentStreak); - } + if (currentStreak.days > longestStreak.days) { + longestStreak = { ...currentStreak }; + } - static totalPagesRead(books: BookWithData[]) { - return books.reduce((acc, book) => acc + book.total_read_pages, 0); + return longestStreak; } - private static getUniqueReadingDays(stats: PageStat[]) { - return Array.from(new Set(stats.map((stat) => startOfDay(stat.start_time).getTime()))).sort( - (a, b) => a - b - ); + private static getTimePerDay(stats: PageStat[]) { + return stats.reduce>((acc, stat) => { + const day = startOfDay(stat.start_time).getTime(); + acc[day] = (acc[day] || 0) + stat.duration; + return acc; + }, {}); } - private static getPagesPerDay(stats: PageStat[], books: Book[]) { + private static getPagesPerDayEntries(stats: PageStat[], books: Book[]) { const booksByMd5 = books?.reduce( (acc, book) => { acc[book.md5] = book; @@ -142,18 +206,19 @@ export class StatsService { startOfDay(stat.start_time).getTime().toString() )(stats); - const pagesPerDay = Object.values(statsPerDay).map( - (dayStats) => - dayStats?.reduce((acc, stat) => { - if (stat.total_pages && booksByMd5[stat.book_md5]?.reference_pages) { - return acc + (1 / stat.total_pages) * booksByMd5[stat.book_md5].reference_pages!; - } else { - return acc + 1; - } - }, 0) ?? 0 + return Object.entries(statsPerDay).map( + ([day, dayStats]) => + [ + Number(day), + dayStats?.reduce((acc, stat) => { + if (stat.total_pages && booksByMd5[stat.book_md5]?.reference_pages) { + return acc + (1 / stat.total_pages) * booksByMd5[stat.book_md5].reference_pages!; + } else { + return acc + 1; + } + }, 0) ?? 0, + ] as [number, number] ); - - return pagesPerDay; } } diff --git a/apps/web/src/api/use-page-stats.ts b/apps/web/src/api/use-page-stats.ts index 8c2c1d80..085f13eb 100644 --- a/apps/web/src/api/use-page-stats.ts +++ b/apps/web/src/api/use-page-stats.ts @@ -9,12 +9,12 @@ export function usePageStats() { stats: [], perMonth: [], perDayOfTheWeek: [], - mostPagesInADay: 0, + mostPagesInADay: { pages: 0 }, totalReadingTime: 0, - longestDay: 0, + longestDay: { duration: 0 }, last7DaysReadTime: 0, currentDailyReadingStreak: 0, - longestDailyReadingStreak: 0, + longestDailyReadingStreak: { days: 0 }, totalPagesRead: 0, }, }); diff --git a/apps/web/src/pages/stats-page/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index e0514e5a..ccacad65 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -17,7 +17,7 @@ import { IconFlame, IconTrophy, } from '@tabler/icons-react'; -import { differenceInCalendarDays, format, startOfDay } from 'date-fns'; +import { format } from 'date-fns'; import { JSX, useMemo } from 'react'; import { BarProps } from 'recharts'; import { useBooks } from '../../api/books'; @@ -65,84 +65,10 @@ export function StatsPage(): JSX.Element { const formatStatDate = (dayTimestamp?: number) => dayTimestamp ? format(dayTimestamp, 'MMM d, yyyy') : 'No reading data yet'; - const { longestDayTimestamp, mostPagesDayTimestamp, longestStreakRange } = useMemo(() => { - if (!stats?.length) { - return { - longestDayTimestamp: undefined, - mostPagesDayTimestamp: undefined, - longestStreakRange: undefined, - }; - } - - const durationPerDay = new Map(); - const pagesPerDay = new Map(); - - for (const stat of stats) { - const dayStart = startOfDay(stat.start_time).getTime(); - durationPerDay.set(dayStart, (durationPerDay.get(dayStart) ?? 0) + stat.duration); - - const referencePages = booksByMd5?.[stat.book_md5]?.reference_pages; - const pagesForStat = stat.total_pages && referencePages ? (1 / stat.total_pages) * referencePages : 1; - pagesPerDay.set(dayStart, (pagesPerDay.get(dayStart) ?? 0) + pagesForStat); - } - - let longestDayEntry: [number, number] | undefined; - for (const entry of durationPerDay.entries()) { - if (!longestDayEntry || entry[1] > longestDayEntry[1]) { - longestDayEntry = entry; - } - } - - let mostPagesEntry: [number, number] | undefined; - for (const entry of pagesPerDay.entries()) { - if (!mostPagesEntry || entry[1] > mostPagesEntry[1]) { - mostPagesEntry = entry; - } - } - - const uniqueDays = Array.from(durationPerDay.keys()).sort((a, b) => a - b); - - let bestStart = uniqueDays[0]; - let bestEnd = uniqueDays[0]; - let bestLength = 1; - - let currentStart = uniqueDays[0]; - let currentEnd = uniqueDays[0]; - let currentLength = 1; - - for (let i = 1; i < uniqueDays.length; i += 1) { - const currentDay = uniqueDays[i]; - const previousDay = uniqueDays[i - 1]; - const isConsecutive = differenceInCalendarDays(currentDay, previousDay) === 1; - - if (isConsecutive) { - currentEnd = currentDay; - currentLength += 1; - } else { - if (currentLength > bestLength) { - bestStart = currentStart; - bestEnd = currentEnd; - bestLength = currentLength; - } - - currentStart = currentDay; - currentEnd = currentDay; - currentLength = 1; - } - } - - if (currentLength > bestLength) { - bestStart = currentStart; - bestEnd = currentEnd; - bestLength = currentLength; - } - - return { - longestDayTimestamp: longestDayEntry?.[0], - mostPagesDayTimestamp: mostPagesEntry?.[0], - longestStreakRange: `${format(bestStart, 'MMM d, yyyy')} - ${format(bestEnd, 'MMM d, yyyy')}`, - }; - }, [stats, booksByMd5]); + const formatDateRange = (dateRange?: { start?: number; end?: number }) => + dateRange?.start && dateRange.end + ? `${format(dateRange.start, 'MMM d, yyyy')} - ${format(dateRange.end, 'MMM d, yyyy')}` + : 'N/A'; if (booksLoading || statsLoading) { return ( @@ -188,17 +114,17 @@ export function StatsPage(): JSX.Element { }, { label: 'Longest time reading in a day', - value: formatSecondsToHumanReadable(longestDay), - detail: formatStatDate(longestDayTimestamp), + value: formatSecondsToHumanReadable(longestDay.duration), + detail: formatStatDate(longestDay.timestamp), icon: IconClockStar, }, { label: 'Most pages in a day', value: - mostPagesInADay !== null && mostPagesInADay !== undefined - ? formatLocalizedNumber(mostPagesInADay) + mostPagesInADay.pages !== null && mostPagesInADay.pages !== undefined + ? formatLocalizedNumber(mostPagesInADay.pages) : 'N/A', - detail: formatStatDate(mostPagesDayTimestamp), + detail: formatStatDate(mostPagesInADay.timestamp), icon: IconFileStar, }, { @@ -208,8 +134,8 @@ export function StatsPage(): JSX.Element { }, { label: 'Longest Daily Reading Streak', - value: formatStreakDays(longestDailyReadingStreak), - detail: longestStreakRange ?? 'N/A', + value: formatStreakDays(longestDailyReadingStreak.days), + detail: formatDateRange(longestDailyReadingStreak), icon: IconTrophy, }, ]} diff --git a/packages/common/types/stats-api.ts b/packages/common/types/stats-api.ts index 44172dcd..555e1ec1 100644 --- a/packages/common/types/stats-api.ts +++ b/packages/common/types/stats-api.ts @@ -13,15 +13,31 @@ export type PerDayOfTheWeek = { day: number; }; +export type ReadingPageStat = { + pages: number; + timestamp?: number; +}; + +export type ReadingDayStat = { + duration: number; + timestamp?: number; +}; + +export type DailyReadingStreak = { + days: number; + start?: number; + end?: number; +}; + export type GetAllStatsResponse = { stats: PageStat[]; perMonth: PerMonthReadingTime[]; perDayOfTheWeek: PerDayOfTheWeek[]; - mostPagesInADay: number; + mostPagesInADay: ReadingPageStat; totalReadingTime: number; - longestDay: number; + longestDay: ReadingDayStat; last7DaysReadTime: number; currentDailyReadingStreak: number; - longestDailyReadingStreak: number; + longestDailyReadingStreak: DailyReadingStreak; totalPagesRead: number; }; From 0f5a14a0afeec0330958b19886a8e35e4be81016 Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 19:31:13 -0400 Subject: [PATCH 10/14] Rework of the stats service --- apps/server/src/stats/stats-router.ts | 12 ++- apps/server/src/stats/stats-service.test.ts | 31 +++++- apps/server/src/stats/stats-service.ts | 105 +++++++++++++------ apps/web/src/api/use-page-stats.ts | 6 +- apps/web/src/pages/stats-page/stats-page.tsx | 12 +-- packages/common/types/stats-api.ts | 10 +- 6 files changed, 119 insertions(+), 57 deletions(-) diff --git a/apps/server/src/stats/stats-router.ts b/apps/server/src/stats/stats-router.ts index cd3acd78..a63fe5b9 100644 --- a/apps/server/src/stats/stats-router.ts +++ b/apps/server/src/stats/stats-router.ts @@ -9,19 +9,21 @@ const router = Router(); /** * Get all stats */ -router.get('/', async (_: Request, res: Response) => { +router.get('/', async (req: Request, res: Response) => { const books = await BooksRepository.getAllWithData(); const totalPagesRead = StatsService.totalPagesRead(books); const stats = await StatsRepository.getAll(); + const requestedTimeZone = typeof req.query.time_zone === 'string' ? req.query.time_zone : 'UTC'; + const timeZone = StatsService.isValidTimeZone(requestedTimeZone) ? requestedTimeZone : 'UTC'; const perMonth = StatsService.getPerMonthReadingTime(stats); const perDayOfTheWeek = StatsService.perDayOfTheWeek(stats); - const mostPagesInADay = StatsService.mostPagesInADay(books, stats); + const mostPagesInADay = StatsService.mostPagesInADay(books, stats, timeZone); const totalReadingTime = StatsService.totalReadingTime(stats); - const longestDay = StatsService.longestDay(stats); + const longestDay = StatsService.longestDay(stats, timeZone); const last7DaysReadTime = StatsService.last7DaysReadTime(stats); - const currentDailyReadingStreak = StatsService.currentDailyReadingStreak(stats); - const longestDailyReadingStreak = StatsService.longestDailyReadingStreak(stats); + const currentDailyReadingStreak = StatsService.currentDailyReadingStreak(stats, timeZone); + const longestDailyReadingStreak = StatsService.longestDailyReadingStreak(stats, timeZone); const response: GetAllStatsResponse = { stats, diff --git a/apps/server/src/stats/stats-service.test.ts b/apps/server/src/stats/stats-service.test.ts index f739a221..f8d9b632 100644 --- a/apps/server/src/stats/stats-service.test.ts +++ b/apps/server/src/stats/stats-service.test.ts @@ -117,7 +117,7 @@ describe(StatsService, () => { const result = await StatsService.mostPagesInADay([book], stats); expect(result).toEqual({ pages: 3, - timestamp: startOfDay(new Date(2025, 1, 1)).getTime(), + date: '2025-02-01', }); }); @@ -173,7 +173,7 @@ describe(StatsService, () => { const result = await StatsService.longestDay(stats); expect(result).toEqual({ duration: 40, - timestamp: startOfDay(new Date(2025, 1, 5)).getTime(), + date: '2025-02-05', }); }); @@ -281,6 +281,28 @@ describe(StatsService, () => { expect(StatsService.currentDailyReadingStreak(stats)).toEqual(2); expect(StatsService.longestDailyReadingStreak(stats).days).toEqual(5); }); + + it('uses the requested timezone to decide whether a streak is current', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-30T15:00:00.000Z')); + + const stats = [0, 1, 2].map((daysAgo, index) => ({ + page: index, + start_time: subDays(new Date('2026-07-29T02:00:00.000Z'), daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + + expect(StatsService.longestDailyReadingStreak(stats, 'America/Toronto')).toEqual({ + days: 3, + start: '2026-07-26', + end: '2026-07-28', + }); + expect(StatsService.currentDailyReadingStreak(stats, 'America/Toronto')).toEqual(0); + expect(StatsService.currentDailyReadingStreak(stats, 'UTC')).toEqual(3); + }); }); describe(StatsService.longestDailyReadingStreak, () => { @@ -298,8 +320,8 @@ describe(StatsService, () => { const result = StatsService.longestDailyReadingStreak(stats); expect(result).toEqual({ days: 4, - start: startOfDay(subDays(anchor, 9)).getTime(), - end: startOfDay(subDays(anchor, 6)).getTime(), + start: '2025-04-01', + end: '2025-04-04', }); }); @@ -340,4 +362,3 @@ describe(StatsService, () => { }); }); }); - diff --git a/apps/server/src/stats/stats-service.ts b/apps/server/src/stats/stats-service.ts index 9e5ead1c..7d4367e7 100644 --- a/apps/server/src/stats/stats-service.ts +++ b/apps/server/src/stats/stats-service.ts @@ -2,16 +2,24 @@ import { Book, BookWithData, DailyReadingStreak, - ReadingDayStat, - ReadingPageStat, PageStat, PerDayOfTheWeek, PerMonthReadingTime, + ReadingDayStat, + ReadingPageStat, } from '@koinsight/common/types'; import { differenceInCalendarDays, format, startOfDay, subDays } from 'date-fns'; import { groupBy, sum } from 'ramda'; export class StatsService { + static isValidTimeZone(timeZone: string) { + try { + Intl.DateTimeFormat(undefined, { timeZone }); + return true; + } catch { + return false; + } + } static getPerMonthReadingTime(stats: PageStat[]): PerMonthReadingTime[] { const perMonth = (stats ?? []) .reduce((acc, stat) => { @@ -49,9 +57,9 @@ export class StatsService { .sort((a, b) => a.day - b.day); } - static mostPagesInADay(books: Book[], stats: PageStat[]): ReadingPageStat { - const pagesPerDay = this.getPagesPerDayEntries(stats, books); - const maxPagesDay = pagesPerDay.reduce<[number, number] | undefined>((max, entry) => { + static mostPagesInADay(books: Book[], stats: PageStat[], timeZone = 'UTC'): ReadingPageStat { + const pagesPerDay = this.getPagesPerDayEntries(stats, books, timeZone); + const maxPagesDay = pagesPerDay.reduce<[string, number] | undefined>((max, entry) => { if (!max || entry[1] > max[1]) { return entry; } @@ -65,7 +73,7 @@ export class StatsService { return { pages: Math.max(0, Math.round(maxPagesDay[1])), - timestamp: maxPagesDay[0], + date: maxPagesDay[0], }; } @@ -73,12 +81,12 @@ export class StatsService { return sum((stats ?? []).map((s) => s.duration)); } - static longestDay(stats: PageStat[]): ReadingDayStat { - const timePerDay = this.getTimePerDay(stats); - const longestDayEntry = Object.entries(timePerDay).reduce<[number, number] | undefined>( + static longestDay(stats: PageStat[], timeZone = 'UTC'): ReadingDayStat { + const timePerDay = this.getTimePerDay(stats, timeZone); + const longestDayEntry = Object.entries(timePerDay).reduce<[string, number] | undefined>( (max, [day, duration]) => { if (!max || duration > max[1]) { - return [Number(day), duration]; + return [day, duration]; } return max; @@ -92,7 +100,7 @@ export class StatsService { return { duration: Math.max(0, longestDayEntry[1]), - timestamp: longestDayEntry[0], + date: longestDayEntry[0], }; } @@ -102,16 +110,19 @@ export class StatsService { return sum(lastSevenDays.map((s) => s.duration)); } - static currentDailyReadingStreak(stats: PageStat[]) { + static currentDailyReadingStreak(stats: PageStat[], timeZone = 'UTC') { if (!stats?.length) { return 0; } - const today = startOfDay(new Date()).getTime(); - const uniqueDays = this.getUniqueReadingDays(stats).filter((day) => day <= today); + const today = this.getDateKey(Date.now(), timeZone); + const uniqueDays = this.getUniqueReadingDays(stats, timeZone).filter((day) => day <= today); const latestReadingDay = uniqueDays[uniqueDays.length - 1]; - if (latestReadingDay === undefined || differenceInCalendarDays(today, latestReadingDay) > 1) { + if ( + latestReadingDay === undefined || + differenceInCalendarDays(this.dateKeyToDate(today), this.dateKeyToDate(latestReadingDay)) > 1 + ) { return 0; } @@ -121,14 +132,14 @@ export class StatsService { while (readingDays.has(currentDay)) { streak += 1; - currentDay = startOfDay(subDays(currentDay, 1)).getTime(); + currentDay = this.previousDateKey(currentDay); } return streak; } - static longestDailyReadingStreak(stats: PageStat[]): DailyReadingStreak { - const uniqueDays = this.getUniqueReadingDays(stats); + static longestDailyReadingStreak(stats: PageStat[], timeZone = 'UTC'): DailyReadingStreak { + const uniqueDays = this.getUniqueReadingDays(stats, timeZone); const longestStreak = this.getLongestDailyReadingStreak(uniqueDays); if (!longestStreak) { @@ -142,13 +153,11 @@ export class StatsService { return books.reduce((acc, book) => acc + book.total_read_pages, 0); } - private static getUniqueReadingDays(stats: PageStat[]) { - return Array.from(new Set(stats.map((stat) => startOfDay(stat.start_time).getTime()))).sort( - (a, b) => a - b - ); + private static getUniqueReadingDays(stats: PageStat[], timeZone = 'UTC') { + return Array.from(new Set(stats.map((stat) => this.getDateKey(stat.start_time, timeZone)))).sort(); } - private static getLongestDailyReadingStreak(uniqueDays: number[]) { + private static getLongestDailyReadingStreak(uniqueDays: string[]) { if (!uniqueDays.length) { return undefined; } @@ -162,7 +171,12 @@ export class StatsService { let currentStreak = { ...longestStreak }; for (let i = 1; i < uniqueDays.length; i += 1) { - if (differenceInCalendarDays(uniqueDays[i], uniqueDays[i - 1]) === 1) { + if ( + differenceInCalendarDays( + this.dateKeyToDate(uniqueDays[i]), + this.dateKeyToDate(uniqueDays[i - 1]) + ) === 1 + ) { currentStreak.end = uniqueDays[i]; currentStreak.days += 1; } else { @@ -185,15 +199,15 @@ export class StatsService { return longestStreak; } - private static getTimePerDay(stats: PageStat[]) { - return stats.reduce>((acc, stat) => { - const day = startOfDay(stat.start_time).getTime(); + private static getTimePerDay(stats: PageStat[], timeZone = 'UTC') { + return stats.reduce>((acc, stat) => { + const day = this.getDateKey(stat.start_time, timeZone); acc[day] = (acc[day] || 0) + stat.duration; return acc; }, {}); } - private static getPagesPerDayEntries(stats: PageStat[], books: Book[]) { + private static getPagesPerDayEntries(stats: PageStat[], books: Book[], timeZone = 'UTC') { const booksByMd5 = books?.reduce( (acc, book) => { acc[book.md5] = book; @@ -202,14 +216,14 @@ export class StatsService { {} as Record ); - const statsPerDay = groupBy((stat: PageStat) => - startOfDay(stat.start_time).getTime().toString() - )(stats); + const statsPerDay = groupBy((stat: PageStat) => this.getDateKey(stat.start_time, timeZone))( + stats + ); return Object.entries(statsPerDay).map( ([day, dayStats]) => [ - Number(day), + day, dayStats?.reduce((acc, stat) => { if (stat.total_pages && booksByMd5[stat.book_md5]?.reference_pages) { return acc + (1 / stat.total_pages) * booksByMd5[stat.book_md5].reference_pages!; @@ -217,8 +231,31 @@ export class StatsService { return acc + 1; } }, 0) ?? 0, - ] as [number, number] + ] as [string, number] ); } -} + private static getDateKey(timestamp: number, timeZone: string) { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(timestamp)); + + const year = parts.find((part) => part.type === 'year')?.value; + const month = parts.find((part) => part.type === 'month')?.value; + const day = parts.find((part) => part.type === 'day')?.value; + + return `${year}-${month}-${day}`; + } + + private static dateKeyToDate(dateKey: string) { + const [year, month, day] = dateKey.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day, 12)); + } + + private static previousDateKey(dateKey: string) { + return subDays(this.dateKeyToDate(dateKey), 1).toISOString().slice(0, 10); + } +} diff --git a/apps/web/src/api/use-page-stats.ts b/apps/web/src/api/use-page-stats.ts index 085f13eb..066364c3 100644 --- a/apps/web/src/api/use-page-stats.ts +++ b/apps/web/src/api/use-page-stats.ts @@ -4,7 +4,9 @@ import { fetchFromAPI } from './api'; import { GetAllStatsResponse } from '@koinsight/common/types'; export function usePageStats() { - return useSWR('stats', () => fetchFromAPI('stats'), { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return useSWR(['stats', timeZone], () => fetchFromAPI('stats', 'GET', { time_zone: timeZone }), { fallbackData: { stats: [], perMonth: [], @@ -22,4 +24,4 @@ export function usePageStats() { export function useBookStats(bookMd5: string) { return useSWR(`stats/${bookMd5}`, () => fetchFromAPI(`stats/${bookMd5}`)); -} +} \ No newline at end of file diff --git a/apps/web/src/pages/stats-page/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index ccacad65..4ba5e108 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -62,12 +62,12 @@ export function StatsPage(): JSX.Element { const formatStreakDays = (value: number) => `${value} day${value === 1 ? '' : 's'}`; const formatLocalizedNumber = (value: number) => new Intl.NumberFormat().format(value); - const formatStatDate = (dayTimestamp?: number) => - dayTimestamp ? format(dayTimestamp, 'MMM d, yyyy') : 'No reading data yet'; + const formatDateKey = (dateKey?: string) => + dateKey ? format(new Date(`${dateKey}T12:00:00`), 'MMM d, yyyy') : 'No reading data yet'; - const formatDateRange = (dateRange?: { start?: number; end?: number }) => + const formatDateRange = (dateRange?: { start?: string; end?: string }) => dateRange?.start && dateRange.end - ? `${format(dateRange.start, 'MMM d, yyyy')} - ${format(dateRange.end, 'MMM d, yyyy')}` + ? `${formatDateKey(dateRange.start)} - ${formatDateKey(dateRange.end)}` : 'N/A'; if (booksLoading || statsLoading) { @@ -115,7 +115,7 @@ export function StatsPage(): JSX.Element { { label: 'Longest time reading in a day', value: formatSecondsToHumanReadable(longestDay.duration), - detail: formatStatDate(longestDay.timestamp), + detail: formatDateKey(longestDay.date), icon: IconClockStar, }, { @@ -124,7 +124,7 @@ export function StatsPage(): JSX.Element { mostPagesInADay.pages !== null && mostPagesInADay.pages !== undefined ? formatLocalizedNumber(mostPagesInADay.pages) : 'N/A', - detail: formatStatDate(mostPagesInADay.timestamp), + detail: formatDateKey(mostPagesInADay.date), icon: IconFileStar, }, { diff --git a/packages/common/types/stats-api.ts b/packages/common/types/stats-api.ts index 555e1ec1..6ee837a5 100644 --- a/packages/common/types/stats-api.ts +++ b/packages/common/types/stats-api.ts @@ -15,18 +15,18 @@ export type PerDayOfTheWeek = { export type ReadingPageStat = { pages: number; - timestamp?: number; + date?: string; }; export type ReadingDayStat = { duration: number; - timestamp?: number; + date?: string; }; export type DailyReadingStreak = { days: number; - start?: number; - end?: number; + start?: string; + end?: string; }; export type GetAllStatsResponse = { @@ -40,4 +40,4 @@ export type GetAllStatsResponse = { currentDailyReadingStreak: number; longestDailyReadingStreak: DailyReadingStreak; totalPagesRead: number; -}; +}; \ No newline at end of file From f2e1911dff4f5a8ba54489289593629daf32e595 Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 20:14:54 -0400 Subject: [PATCH 11/14] Better timezone fix --- apps/server/src/stats/stats-router.ts | 13 +- apps/server/src/stats/stats-service.ts | 281 +++++++++++++++++-------- 2 files changed, 196 insertions(+), 98 deletions(-) diff --git a/apps/server/src/stats/stats-router.ts b/apps/server/src/stats/stats-router.ts index a63fe5b9..5867400b 100644 --- a/apps/server/src/stats/stats-router.ts +++ b/apps/server/src/stats/stats-router.ts @@ -18,23 +18,20 @@ router.get('/', async (req: Request, res: Response) => { const timeZone = StatsService.isValidTimeZone(requestedTimeZone) ? requestedTimeZone : 'UTC'; const perMonth = StatsService.getPerMonthReadingTime(stats); const perDayOfTheWeek = StatsService.perDayOfTheWeek(stats); - const mostPagesInADay = StatsService.mostPagesInADay(books, stats, timeZone); + const dailyReadingStats = StatsService.dailyReadingStats(books, stats, timeZone); const totalReadingTime = StatsService.totalReadingTime(stats); - const longestDay = StatsService.longestDay(stats, timeZone); const last7DaysReadTime = StatsService.last7DaysReadTime(stats); - const currentDailyReadingStreak = StatsService.currentDailyReadingStreak(stats, timeZone); - const longestDailyReadingStreak = StatsService.longestDailyReadingStreak(stats, timeZone); const response: GetAllStatsResponse = { stats, perMonth, perDayOfTheWeek, - mostPagesInADay, + mostPagesInADay: dailyReadingStats.mostPagesInADay, totalReadingTime, - longestDay, + longestDay: dailyReadingStats.longestDay, last7DaysReadTime, - currentDailyReadingStreak, - longestDailyReadingStreak, + currentDailyReadingStreak: dailyReadingStats.currentDailyReadingStreak, + longestDailyReadingStreak: dailyReadingStats.longestDailyReadingStreak, totalPagesRead, }; diff --git a/apps/server/src/stats/stats-service.ts b/apps/server/src/stats/stats-service.ts index 7d4367e7..bcb33c65 100644 --- a/apps/server/src/stats/stats-service.ts +++ b/apps/server/src/stats/stats-service.ts @@ -8,13 +8,33 @@ import { ReadingDayStat, ReadingPageStat, } from '@koinsight/common/types'; -import { differenceInCalendarDays, format, startOfDay, subDays } from 'date-fns'; -import { groupBy, sum } from 'ramda'; +import { differenceInCalendarDays, format, subDays } from 'date-fns'; +import { sum } from 'ramda'; + +type DateKeyContext = { + formatter: Intl.DateTimeFormat; + offsetsByUtcHour: Map; +}; + +type DailyStatsSummary = { + mostPagesInADay: ReadingPageStat; + longestDay: ReadingDayStat; + currentDailyReadingStreak: number; + longestDailyReadingStreak: DailyReadingStreak; +}; + +type ReadingDayTotals = { + duration: number; + pages: number; +}; export class StatsService { + private static dateKeyFormatters = new Map(); + private static dateKeyOffsetsByTimeZone = new Map>(); + static isValidTimeZone(timeZone: string) { try { - Intl.DateTimeFormat(undefined, { timeZone }); + this.getDateKeyFormatter(timeZone); return true; } catch { return false; @@ -57,51 +77,33 @@ export class StatsService { .sort((a, b) => a.day - b.day); } - static mostPagesInADay(books: Book[], stats: PageStat[], timeZone = 'UTC'): ReadingPageStat { - const pagesPerDay = this.getPagesPerDayEntries(stats, books, timeZone); - const maxPagesDay = pagesPerDay.reduce<[string, number] | undefined>((max, entry) => { - if (!max || entry[1] > max[1]) { - return entry; - } - - return max; - }, undefined); - - if (!maxPagesDay) { - return { pages: 0 }; - } + static dailyReadingStats(books: Book[], stats: PageStat[], timeZone = 'UTC'): DailyStatsSummary { + const context = this.getDateKeyContext(timeZone); + const totalsByDay = this.getReadingDayTotals(stats, books, context); + const uniqueDays = Array.from(totalsByDay.keys()).sort(); + const longestDailyReadingStreak = this.getLongestDailyReadingStreak(uniqueDays) ?? { days: 0 }; + const today = this.getDateKey(Date.now(), context); return { - pages: Math.max(0, Math.round(maxPagesDay[1])), - date: maxPagesDay[0], + mostPagesInADay: this.getMostPagesInADay(totalsByDay), + longestDay: this.getLongestDay(totalsByDay), + currentDailyReadingStreak: this.getCurrentDailyReadingStreak(uniqueDays, today), + longestDailyReadingStreak, }; } + static mostPagesInADay(books: Book[], stats: PageStat[], timeZone = 'UTC'): ReadingPageStat { + const context = this.getDateKeyContext(timeZone); + return this.getMostPagesInADay(this.getReadingDayTotals(stats, books, context)); + } + static totalReadingTime(stats: PageStat[]) { return sum((stats ?? []).map((s) => s.duration)); } static longestDay(stats: PageStat[], timeZone = 'UTC'): ReadingDayStat { - const timePerDay = this.getTimePerDay(stats, timeZone); - const longestDayEntry = Object.entries(timePerDay).reduce<[string, number] | undefined>( - (max, [day, duration]) => { - if (!max || duration > max[1]) { - return [day, duration]; - } - - return max; - }, - undefined - ); - - if (!longestDayEntry) { - return { duration: 0 }; - } - - return { - duration: Math.max(0, longestDayEntry[1]), - date: longestDayEntry[0], - }; + const context = this.getDateKeyContext(timeZone); + return this.getLongestDay(this.getReadingDayTotals(stats, [], context)); } static last7DaysReadTime(stats: PageStat[]) { @@ -115,31 +117,16 @@ export class StatsService { return 0; } - const today = this.getDateKey(Date.now(), timeZone); - const uniqueDays = this.getUniqueReadingDays(stats, timeZone).filter((day) => day <= today); - const latestReadingDay = uniqueDays[uniqueDays.length - 1]; - - if ( - latestReadingDay === undefined || - differenceInCalendarDays(this.dateKeyToDate(today), this.dateKeyToDate(latestReadingDay)) > 1 - ) { - return 0; - } - - const readingDays = new Set(uniqueDays); - let streak = 0; - let currentDay = latestReadingDay; + const context = this.getDateKeyContext(timeZone); + const today = this.getDateKey(Date.now(), context); + const uniqueDays = this.getUniqueReadingDays(stats, context); - while (readingDays.has(currentDay)) { - streak += 1; - currentDay = this.previousDateKey(currentDay); - } - - return streak; + return this.getCurrentDailyReadingStreak(uniqueDays, today); } static longestDailyReadingStreak(stats: PageStat[], timeZone = 'UTC'): DailyReadingStreak { - const uniqueDays = this.getUniqueReadingDays(stats, timeZone); + const context = this.getDateKeyContext(timeZone); + const uniqueDays = this.getUniqueReadingDays(stats, context); const longestStreak = this.getLongestDailyReadingStreak(uniqueDays); if (!longestStreak) { @@ -153,8 +140,8 @@ export class StatsService { return books.reduce((acc, book) => acc + book.total_read_pages, 0); } - private static getUniqueReadingDays(stats: PageStat[], timeZone = 'UTC') { - return Array.from(new Set(stats.map((stat) => this.getDateKey(stat.start_time, timeZone)))).sort(); + private static getUniqueReadingDays(stats: PageStat[], context: DateKeyContext) { + return Array.from(new Set(stats.map((stat) => this.getDateKey(stat.start_time, context)))).sort(); } private static getLongestDailyReadingStreak(uniqueDays: string[]) { @@ -199,15 +186,34 @@ export class StatsService { return longestStreak; } - private static getTimePerDay(stats: PageStat[], timeZone = 'UTC') { - return stats.reduce>((acc, stat) => { - const day = this.getDateKey(stat.start_time, timeZone); - acc[day] = (acc[day] || 0) + stat.duration; - return acc; - }, {}); + private static getCurrentDailyReadingStreak(uniqueDays: string[], today: string) { + const currentUniqueDays = uniqueDays.filter((day) => day <= today); + const latestReadingDay = currentUniqueDays[currentUniqueDays.length - 1]; + + if ( + latestReadingDay === undefined || + differenceInCalendarDays(this.dateKeyToDate(today), this.dateKeyToDate(latestReadingDay)) > 1 + ) { + return 0; + } + + const readingDays = new Set(currentUniqueDays); + let streak = 0; + let currentDay = latestReadingDay; + + while (readingDays.has(currentDay)) { + streak += 1; + currentDay = this.previousDateKey(currentDay); + } + + return streak; } - private static getPagesPerDayEntries(stats: PageStat[], books: Book[], timeZone = 'UTC') { + private static getReadingDayTotals( + stats: PageStat[], + books: Book[], + context: DateKeyContext + ): Map { const booksByMd5 = books?.reduce( (acc, book) => { acc[book.md5] = book; @@ -215,41 +221,136 @@ export class StatsService { }, {} as Record ); + const totalsByDay = new Map(); - const statsPerDay = groupBy((stat: PageStat) => this.getDateKey(stat.start_time, timeZone))( - stats - ); + stats.forEach((stat) => { + const day = this.getDateKey(stat.start_time, context); + const dayTotals = totalsByDay.get(day) ?? { duration: 0, pages: 0 }; + dayTotals.duration += stat.duration; + dayTotals.pages += this.getStatPageCount(stat, booksByMd5); + totalsByDay.set(day, dayTotals); + }); - return Object.entries(statsPerDay).map( - ([day, dayStats]) => - [ - day, - dayStats?.reduce((acc, stat) => { - if (stat.total_pages && booksByMd5[stat.book_md5]?.reference_pages) { - return acc + (1 / stat.total_pages) * booksByMd5[stat.book_md5].reference_pages!; - } else { - return acc + 1; - } - }, 0) ?? 0, - ] as [string, number] - ); + return totalsByDay; } - private static getDateKey(timestamp: number, timeZone: string) { - const parts = new Intl.DateTimeFormat('en-US', { + private static getMostPagesInADay(totalsByDay: Map): ReadingPageStat { + let maxPagesDay: [string, ReadingDayTotals] | undefined; + + totalsByDay.forEach((totals, day) => { + if (!maxPagesDay || totals.pages > maxPagesDay[1].pages) { + maxPagesDay = [day, totals]; + } + }); + + if (!maxPagesDay) { + return { pages: 0 }; + } + + return { + pages: Math.max(0, Math.round(maxPagesDay[1].pages)), + date: maxPagesDay[0], + }; + } + + private static getLongestDay(totalsByDay: Map): ReadingDayStat { + let longestDay: [string, ReadingDayTotals] | undefined; + + totalsByDay.forEach((totals, day) => { + if (!longestDay || totals.duration > longestDay[1].duration) { + longestDay = [day, totals]; + } + }); + + if (!longestDay) { + return { duration: 0 }; + } + + return { + duration: Math.max(0, longestDay[1].duration), + date: longestDay[0], + }; + } + + private static getStatPageCount(stat: PageStat, booksByMd5: Record) { + const referencePages = booksByMd5[stat.book_md5]?.reference_pages; + if (stat.total_pages && referencePages) { + return (1 / stat.total_pages) * referencePages; + } + + return 1; + } + + private static getDateKeyContext(timeZone: string): DateKeyContext { + let offsetsByUtcHour = this.dateKeyOffsetsByTimeZone.get(timeZone); + if (!offsetsByUtcHour) { + offsetsByUtcHour = new Map(); + this.dateKeyOffsetsByTimeZone.set(timeZone, offsetsByUtcHour); + } + + return { + formatter: this.getDateKeyFormatter(timeZone), + offsetsByUtcHour, + }; + } + + private static getDateKeyFormatter(timeZone: string) { + const existingFormatter = this.dateKeyFormatters.get(timeZone); + if (existingFormatter) { + return existingFormatter; + } + + const formatter = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', - }).formatToParts(new Date(timestamp)); + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }); + this.dateKeyFormatters.set(timeZone, formatter); + + return formatter; + } - const year = parts.find((part) => part.type === 'year')?.value; - const month = parts.find((part) => part.type === 'month')?.value; - const day = parts.find((part) => part.type === 'day')?.value; + private static getDateKey(timestamp: number, context: DateKeyContext) { + const offset = this.getTimeZoneOffset(timestamp, context); + const localDate = new Date(timestamp + offset); + const year = localDate.getUTCFullYear().toString().padStart(4, '0'); + const month = (localDate.getUTCMonth() + 1).toString().padStart(2, '0'); + const day = localDate.getUTCDate().toString().padStart(2, '0'); return `${year}-${month}-${day}`; } + private static getTimeZoneOffset(timestamp: number, context: DateKeyContext) { + const utcHour = Math.floor(timestamp / 3_600_000); + const existingOffset = context.offsetsByUtcHour.get(utcHour); + if (existingOffset !== undefined) { + return existingOffset; + } + + const roundedTimestamp = timestamp - (timestamp % 1000); + const parts = context.formatter.formatToParts(new Date(roundedTimestamp)); + const valueFor = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((part) => part.type === type)?.value); + const localTimestampAsUtc = Date.UTC( + valueFor('year'), + valueFor('month') - 1, + valueFor('day'), + valueFor('hour'), + valueFor('minute'), + valueFor('second') + ); + const offset = localTimestampAsUtc - roundedTimestamp; + + context.offsetsByUtcHour.set(utcHour, offset); + + return offset; + } + private static dateKeyToDate(dateKey: string) { const [year, month, day] = dateKey.split('-').map(Number); return new Date(Date.UTC(year, month - 1, day, 12)); From f3053641459a607997d561914e89678cdc20ef27 Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 20:34:33 -0400 Subject: [PATCH 12/14] split stats into 2 calls --- apps/server/src/stats/stats-router.test.ts | 14 ++++++- apps/server/src/stats/stats-router.ts | 15 +++++-- apps/web/src/api/use-page-stats.ts | 40 +++++++++++-------- .../statistics/reading-calendar.tsx | 4 +- apps/web/src/pages/calendar-page.tsx | 5 +-- apps/web/src/pages/stats-page/stats-page.tsx | 7 ++-- apps/web/src/pages/stats-page/week-stats.tsx | 20 ++++++---- packages/common/types/stats-api.ts | 5 +-- 8 files changed, 66 insertions(+), 44 deletions(-) diff --git a/apps/server/src/stats/stats-router.test.ts b/apps/server/src/stats/stats-router.test.ts index b288e6c8..ce9cbe5a 100644 --- a/apps/server/src/stats/stats-router.test.ts +++ b/apps/server/src/stats/stats-router.test.ts @@ -23,7 +23,7 @@ describe('GET /stats', () => { bookDevice = await createBookDevice(db, book, device, { pages: 100 }); }); - it('returns all stats', async () => { + it('returns stats summary', async () => { await createPageStat(db, book, bookDevice, device, { duration: 10, page: 1 }); await createPageStat(db, book, bookDevice, device, { duration: 20, page: 2 }); await createPageStat(db, book, bookDevice, device, { duration: 10, page: 3 }); @@ -36,10 +36,22 @@ describe('GET /stats', () => { // TODO: Do we need a more detailed test here provided everything is from the StatsService? expect(body).toHaveProperty('perMonth'); + expect(body).not.toHaveProperty('stats'); expect(body.longestDay.duration).toBe(20); expect(body.mostPagesInADay).toHaveProperty('pages'); expect(body.totalPagesRead).toBe(4); expect(body).toHaveProperty('currentDailyReadingStreak'); expect(body.longestDailyReadingStreak).toHaveProperty('days'); }); + + it('returns raw page stats from the page-stats endpoint', async () => { + await createPageStat(db, book, bookDevice, device, { duration: 10, page: 1 }); + await createPageStat(db, book, bookDevice, device, { duration: 20, page: 2 }); + + const response = await request(app).get('/stats/page-stats'); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('start_time'); + }); }); diff --git a/apps/server/src/stats/stats-router.ts b/apps/server/src/stats/stats-router.ts index 5867400b..b77b9ee5 100644 --- a/apps/server/src/stats/stats-router.ts +++ b/apps/server/src/stats/stats-router.ts @@ -1,4 +1,4 @@ -import { GetAllStatsResponse } from '@koinsight/common/types'; +import { GetStatsSummaryResponse } from '@koinsight/common/types'; import { Request, Response, Router } from 'express'; import { BooksRepository } from '../books/books-repository'; import { StatsRepository } from './stats-repository'; @@ -7,7 +7,7 @@ import { StatsService } from './stats-service'; const router = Router(); /** - * Get all stats + * Get stats summary */ router.get('/', async (req: Request, res: Response) => { const books = await BooksRepository.getAllWithData(); @@ -22,8 +22,7 @@ router.get('/', async (req: Request, res: Response) => { const totalReadingTime = StatsService.totalReadingTime(stats); const last7DaysReadTime = StatsService.last7DaysReadTime(stats); - const response: GetAllStatsResponse = { - stats, + const response: GetStatsSummaryResponse = { perMonth, perDayOfTheWeek, mostPagesInADay: dailyReadingStats.mostPagesInADay, @@ -38,6 +37,14 @@ router.get('/', async (req: Request, res: Response) => { res.status(200).json(response); }); +/** + * Get raw page stats + */ +router.get('/page-stats', async (_req: Request, res: Response) => { + const stats = await StatsRepository.getAll(); + res.status(200).json(stats); +}); + /** * Get stats by book md5 */ diff --git a/apps/web/src/api/use-page-stats.ts b/apps/web/src/api/use-page-stats.ts index 066364c3..ff5989ee 100644 --- a/apps/web/src/api/use-page-stats.ts +++ b/apps/web/src/api/use-page-stats.ts @@ -1,24 +1,32 @@ -import { PageStat } from '@koinsight/common/types/page-stat'; +import { GetStatsSummaryResponse, PageStat } from '@koinsight/common/types'; import useSWR from 'swr'; import { fetchFromAPI } from './api'; -import { GetAllStatsResponse } from '@koinsight/common/types'; -export function usePageStats() { +export function useStatsSummary() { const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return useSWR(['stats', timeZone], () => fetchFromAPI('stats', 'GET', { time_zone: timeZone }), { - fallbackData: { - stats: [], - perMonth: [], - perDayOfTheWeek: [], - mostPagesInADay: { pages: 0 }, - totalReadingTime: 0, - longestDay: { duration: 0 }, - last7DaysReadTime: 0, - currentDailyReadingStreak: 0, - longestDailyReadingStreak: { days: 0 }, - totalPagesRead: 0, - }, + return useSWR( + ['stats', timeZone], + () => fetchFromAPI('stats', 'GET', { time_zone: timeZone }), + { + fallbackData: { + perMonth: [], + perDayOfTheWeek: [], + mostPagesInADay: { pages: 0 }, + totalReadingTime: 0, + longestDay: { duration: 0 }, + last7DaysReadTime: 0, + currentDailyReadingStreak: 0, + longestDailyReadingStreak: { days: 0 }, + totalPagesRead: 0, + }, + } + ); +} + +export function usePageStats() { + return useSWR('stats/page-stats', () => fetchFromAPI('stats/page-stats'), { + fallbackData: [], }); } diff --git a/apps/web/src/components/statistics/reading-calendar.tsx b/apps/web/src/components/statistics/reading-calendar.tsx index e783ac18..40bf4604 100644 --- a/apps/web/src/components/statistics/reading-calendar.tsx +++ b/apps/web/src/components/statistics/reading-calendar.tsx @@ -6,9 +6,7 @@ import { formatSecondsToHumanReadable } from '../../utils/dates'; import { DayData, DotTrail } from '../dot-trail/dot-trail'; export function ReadingCalendar(): JSX.Element { - const { - data: { stats }, - } = usePageStats(); + const { data: stats } = usePageStats(); const percentPerDay: Record = useMemo(() => { const timePerDay = stats.reduce>((acc, stat) => { diff --git a/apps/web/src/pages/calendar-page.tsx b/apps/web/src/pages/calendar-page.tsx index 938ba98d..3c0ca8d4 100644 --- a/apps/web/src/pages/calendar-page.tsx +++ b/apps/web/src/pages/calendar-page.tsx @@ -18,10 +18,7 @@ type DayData = { export function CalendarPage(): JSX.Element { const { data: books, isLoading } = useBooks(); - const { - data: { stats: events }, - isLoading: eventsLoading, - } = usePageStats(); + const { data: events, isLoading: eventsLoading } = usePageStats(); const calendarEvents = useMemo>>(() => { if (eventsLoading || !events) { diff --git a/apps/web/src/pages/stats-page/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index 4ba5e108..73c373d0 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -21,7 +21,7 @@ import { format } from 'date-fns'; import { JSX, useMemo } from 'react'; import { BarProps } from 'recharts'; import { useBooks } from '../../api/books'; -import { usePageStats } from '../../api/use-page-stats'; +import { useStatsSummary } from '../../api/use-page-stats'; import { CustomBar } from '../../components/charts/custom-bar'; import { ReadingCalendar } from '../../components/statistics/reading-calendar'; import { Statistics } from '../../components/statistics/statistics'; @@ -35,7 +35,6 @@ export function StatsPage(): JSX.Element { const { data: { - stats, perMonth, perDayOfTheWeek, mostPagesInADay, @@ -47,7 +46,7 @@ export function StatsPage(): JSX.Element { totalPagesRead, }, isLoading: statsLoading, - } = usePageStats(); + } = useStatsSummary(); const booksByMd5 = useMemo(() => { return books?.reduce( @@ -150,7 +149,7 @@ export function StatsPage(): JSX.Element { Weekly stats - + Per day of the week diff --git a/apps/web/src/pages/stats-page/week-stats.tsx b/apps/web/src/pages/stats-page/week-stats.tsx index a63be665..cad8799a 100644 --- a/apps/web/src/pages/stats-page/week-stats.tsx +++ b/apps/web/src/pages/stats-page/week-stats.tsx @@ -1,7 +1,7 @@ import { Book } from '@koinsight/common/types/book'; import { PageStat } from '@koinsight/common/types/page-stat'; import { AreaChart } from '@mantine/charts'; -import { Flex, Popover, Text, useComputedColorScheme, useMantineTheme } from '@mantine/core'; +import { Flex, Loader, Popover, Text, useComputedColorScheme, useMantineTheme } from '@mantine/core'; import { DatePicker } from '@mantine/dates'; import { IconArrowsVertical, @@ -24,16 +24,12 @@ import { } from 'date-fns'; import { groupBy, sum } from 'ramda'; import { useMemo, useState } from 'react'; +import { usePageStats } from '../../api/use-page-stats'; import { Statistics } from '../../components/statistics/statistics'; import { formatSecondsToHumanReadable } from '../../utils/dates'; -export function WeekStats({ - stats, - booksByMd5, -}: { - stats: PageStat[]; - booksByMd5: Record; -}) { +export function WeekStats({ booksByMd5 }: { booksByMd5: Record }) { + const { data: stats, isLoading: statsLoading } = usePageStats(); const colorScheme = useComputedColorScheme(); const { colors } = useMantineTheme(); @@ -108,6 +104,14 @@ export function WeekStats({ return perDayResult; }, [stats, weekStart, weekEnd]); + if (statsLoading) { + return ( + + + + ); + } + return ( <> diff --git a/packages/common/types/stats-api.ts b/packages/common/types/stats-api.ts index 6ee837a5..508437be 100644 --- a/packages/common/types/stats-api.ts +++ b/packages/common/types/stats-api.ts @@ -1,5 +1,3 @@ -import { PageStat } from './page-stat'; - export type PerMonthReadingTime = { month: string; duration: number; @@ -29,8 +27,7 @@ export type DailyReadingStreak = { end?: string; }; -export type GetAllStatsResponse = { - stats: PageStat[]; +export type GetStatsSummaryResponse = { perMonth: PerMonthReadingTime[]; perDayOfTheWeek: PerDayOfTheWeek[]; mostPagesInADay: ReadingPageStat; From da71a5e9df2b42b4de025f246707d3626d93520c Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 20:44:35 -0400 Subject: [PATCH 13/14] Split the stats endpoint into stats and page-stats (dates read) --- apps/server/src/stats/stats-router.test.ts | 19 +- apps/server/src/stats/stats-router.ts | 32 +- apps/server/src/stats/stats-service.test.ts | 83 ++++- apps/server/src/stats/stats-service.ts | 329 +++++++++++++++--- apps/web/src/api/use-page-stats.ts | 42 ++- .../statistics/reading-calendar.tsx | 4 +- apps/web/src/pages/calendar-page.tsx | 5 +- apps/web/src/pages/stats-page/stats-page.tsx | 112 ++---- apps/web/src/pages/stats-page/week-stats.tsx | 20 +- packages/common/types/stats-api.ts | 31 +- 10 files changed, 462 insertions(+), 215 deletions(-) diff --git a/apps/server/src/stats/stats-router.test.ts b/apps/server/src/stats/stats-router.test.ts index 272300c7..ce9cbe5a 100644 --- a/apps/server/src/stats/stats-router.test.ts +++ b/apps/server/src/stats/stats-router.test.ts @@ -23,7 +23,7 @@ describe('GET /stats', () => { bookDevice = await createBookDevice(db, book, device, { pages: 100 }); }); - it('returns all stats', async () => { + it('returns stats summary', async () => { await createPageStat(db, book, bookDevice, device, { duration: 10, page: 1 }); await createPageStat(db, book, bookDevice, device, { duration: 20, page: 2 }); await createPageStat(db, book, bookDevice, device, { duration: 10, page: 3 }); @@ -36,9 +36,22 @@ describe('GET /stats', () => { // TODO: Do we need a more detailed test here provided everything is from the StatsService? expect(body).toHaveProperty('perMonth'); - expect(body.longestDay).toBe(20); + expect(body).not.toHaveProperty('stats'); + expect(body.longestDay.duration).toBe(20); + expect(body.mostPagesInADay).toHaveProperty('pages'); expect(body.totalPagesRead).toBe(4); expect(body).toHaveProperty('currentDailyReadingStreak'); - expect(body).toHaveProperty('longestDailyReadingStreak'); + expect(body.longestDailyReadingStreak).toHaveProperty('days'); + }); + + it('returns raw page stats from the page-stats endpoint', async () => { + await createPageStat(db, book, bookDevice, device, { duration: 10, page: 1 }); + await createPageStat(db, book, bookDevice, device, { duration: 20, page: 2 }); + + const response = await request(app).get('/stats/page-stats'); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('start_time'); }); }); diff --git a/apps/server/src/stats/stats-router.ts b/apps/server/src/stats/stats-router.ts index cd3acd78..b77b9ee5 100644 --- a/apps/server/src/stats/stats-router.ts +++ b/apps/server/src/stats/stats-router.ts @@ -1,4 +1,4 @@ -import { GetAllStatsResponse } from '@koinsight/common/types'; +import { GetStatsSummaryResponse } from '@koinsight/common/types'; import { Request, Response, Router } from 'express'; import { BooksRepository } from '../books/books-repository'; import { StatsRepository } from './stats-repository'; @@ -7,38 +7,44 @@ import { StatsService } from './stats-service'; const router = Router(); /** - * Get all stats + * Get stats summary */ -router.get('/', async (_: Request, res: Response) => { +router.get('/', async (req: Request, res: Response) => { const books = await BooksRepository.getAllWithData(); const totalPagesRead = StatsService.totalPagesRead(books); const stats = await StatsRepository.getAll(); + const requestedTimeZone = typeof req.query.time_zone === 'string' ? req.query.time_zone : 'UTC'; + const timeZone = StatsService.isValidTimeZone(requestedTimeZone) ? requestedTimeZone : 'UTC'; const perMonth = StatsService.getPerMonthReadingTime(stats); const perDayOfTheWeek = StatsService.perDayOfTheWeek(stats); - const mostPagesInADay = StatsService.mostPagesInADay(books, stats); + const dailyReadingStats = StatsService.dailyReadingStats(books, stats, timeZone); const totalReadingTime = StatsService.totalReadingTime(stats); - const longestDay = StatsService.longestDay(stats); const last7DaysReadTime = StatsService.last7DaysReadTime(stats); - const currentDailyReadingStreak = StatsService.currentDailyReadingStreak(stats); - const longestDailyReadingStreak = StatsService.longestDailyReadingStreak(stats); - const response: GetAllStatsResponse = { - stats, + const response: GetStatsSummaryResponse = { perMonth, perDayOfTheWeek, - mostPagesInADay, + mostPagesInADay: dailyReadingStats.mostPagesInADay, totalReadingTime, - longestDay, + longestDay: dailyReadingStats.longestDay, last7DaysReadTime, - currentDailyReadingStreak, - longestDailyReadingStreak, + currentDailyReadingStreak: dailyReadingStats.currentDailyReadingStreak, + longestDailyReadingStreak: dailyReadingStats.longestDailyReadingStreak, totalPagesRead, }; res.status(200).json(response); }); +/** + * Get raw page stats + */ +router.get('/page-stats', async (_req: Request, res: Response) => { + const stats = await StatsRepository.getAll(); + res.status(200).json(stats); +}); + /** * Get stats by book md5 */ diff --git a/apps/server/src/stats/stats-service.test.ts b/apps/server/src/stats/stats-service.test.ts index 424a4427..4746ba10 100644 --- a/apps/server/src/stats/stats-service.test.ts +++ b/apps/server/src/stats/stats-service.test.ts @@ -102,18 +102,23 @@ describe(StatsService, () => { stats.push( await createPageStat(db, book, bookDevice, device, { + page: 10, start_time: new Date(2025, 1, 1).getTime(), }) ); stats.push( await createPageStat(db, book, bookDevice, device, { + page: 11, start_time: new Date(2025, 1, 1).getTime(), }) ); const result = await StatsService.mostPagesInADay([book], stats); - expect(result).toEqual(3); + expect(result).toEqual({ + pages: 3, + date: '2025-02-01', + }); }); it('works with reference pages', async () => { @@ -131,23 +136,25 @@ describe(StatsService, () => { stats.push( await createPageStat(db, book, bookDevice, device, { + page: 10, start_time: new Date(2025, 1, 1).getTime(), }) ); stats.push( await createPageStat(db, book, bookDevice, device, { + page: 11, start_time: new Date(2025, 1, 1).getTime(), }) ); const result = await StatsService.mostPagesInADay([book], stats); - expect(result).toEqual(2); // result is rounded + expect(result.pages).toEqual(2); // result is rounded }); it('returns 0 with no stats', async () => { const result = await StatsService.mostPagesInADay([book], []); - expect(result).toEqual(0); + expect(result).toEqual({ pages: 0 }); }); }); @@ -164,12 +171,15 @@ describe(StatsService, () => { ); const result = await StatsService.longestDay(stats); - expect(result).toEqual(40); + expect(result).toEqual({ + duration: 40, + date: '2025-02-05', + }); }); it('returns 0 with no stats', async () => { const result = await StatsService.longestDay([]); - expect(result).toEqual(0); + expect(result).toEqual({ duration: 0 }); }); }); @@ -215,7 +225,7 @@ describe(StatsService, () => { })); const result = StatsService.currentDailyReadingStreak(stats); - expect(result).toEqual(3); + expect(result).toEqual({ days: 3, start: '2025-04-08', end: '2025-04-10' }); }); it('returns streak ending yesterday when today has no reading activity', () => { @@ -233,7 +243,7 @@ describe(StatsService, () => { })); const result = StatsService.currentDailyReadingStreak(stats); - expect(result).toEqual(3); + expect(result).toEqual({ days: 3, start: '2025-04-07', end: '2025-04-09' }); }); it('returns 0 when neither today nor yesterday has reading activity', () => { @@ -251,7 +261,55 @@ describe(StatsService, () => { })); const result = StatsService.currentDailyReadingStreak(stats); - expect(result).toEqual(0); + expect(result).toEqual({ days: 0 }); + }); + + it('returns current streak when a historical streak is longer', () => { + const today = startOfDay(new Date('2025-04-10T15:00:00.000Z')).getTime(); + vi.useFakeTimers(); + vi.setSystemTime(today); + + const stats = [1, 2, 5, 6, 7, 8, 9].map((daysAgo, index) => ({ + page: index, + start_time: subDays(today, daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + + expect(StatsService.currentDailyReadingStreak(stats)).toEqual({ + days: 2, + start: '2025-04-08', + end: '2025-04-09', + }); + expect(StatsService.longestDailyReadingStreak(stats).days).toEqual(5); + }); + + it('uses the requested timezone to decide whether a streak is current', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-30T15:00:00.000Z')); + + const stats = [0, 1, 2].map((daysAgo, index) => ({ + page: index, + start_time: subDays(new Date('2026-07-29T02:00:00.000Z'), daysAgo).getTime(), + duration: 60, + total_pages: 100, + device_id: device.id, + book_md5: book.md5, + })); + + expect(StatsService.longestDailyReadingStreak(stats, 'America/Toronto')).toEqual({ + days: 3, + start: '2026-07-26', + end: '2026-07-28', + }); + expect(StatsService.currentDailyReadingStreak(stats, 'America/Toronto')).toEqual({ days: 0 }); + expect(StatsService.currentDailyReadingStreak(stats, 'UTC')).toEqual({ + days: 3, + start: '2026-07-27', + end: '2026-07-29', + }); }); }); @@ -268,12 +326,16 @@ describe(StatsService, () => { })); const result = StatsService.longestDailyReadingStreak(stats); - expect(result).toEqual(4); + expect(result).toEqual({ + days: 4, + start: '2025-04-01', + end: '2025-04-04', + }); }); it('returns 0 with no stats', () => { const result = StatsService.longestDailyReadingStreak([]); - expect(result).toEqual(0); + expect(result).toEqual({ days: 0 }); }); }); @@ -308,4 +370,3 @@ describe(StatsService, () => { }); }); }); - diff --git a/apps/server/src/stats/stats-service.ts b/apps/server/src/stats/stats-service.ts index 94f86b2c..a2b104d9 100644 --- a/apps/server/src/stats/stats-service.ts +++ b/apps/server/src/stats/stats-service.ts @@ -1,14 +1,45 @@ import { Book, BookWithData, + DailyReadingStreak, PageStat, PerDayOfTheWeek, PerMonthReadingTime, + ReadingDayStat, + ReadingPageStat, } from '@koinsight/common/types'; -import { differenceInCalendarDays, format, startOfDay, subDays } from 'date-fns'; -import { groupBy, sum } from 'ramda'; +import { differenceInCalendarDays, format, subDays } from 'date-fns'; +import { sum } from 'ramda'; + +type DateKeyContext = { + formatter: Intl.DateTimeFormat; + offsetsByUtcHour: Map; +}; + +type DailyStatsSummary = { + mostPagesInADay: ReadingPageStat; + longestDay: ReadingDayStat; + currentDailyReadingStreak: DailyReadingStreak; + longestDailyReadingStreak: DailyReadingStreak; +}; + +type ReadingDayTotals = { + duration: number; + pages: number; +}; export class StatsService { + private static dateKeyFormatters = new Map(); + private static dateKeyOffsetsByTimeZone = new Map>(); + + static isValidTimeZone(timeZone: string) { + try { + this.getDateKeyFormatter(timeZone); + return true; + } catch { + return false; + } + } static getPerMonthReadingTime(stats: PageStat[]): PerMonthReadingTime[] { const perMonth = (stats ?? []) .reduce((acc, stat) => { @@ -46,24 +77,33 @@ export class StatsService { .sort((a, b) => a.day - b.day); } - static mostPagesInADay(books: Book[], stats: PageStat[]) { - const max = Math.round(Math.max(...this.getPagesPerDay(stats, books))); - return Math.max(0, max); + static dailyReadingStats(books: Book[], stats: PageStat[], timeZone = 'UTC'): DailyStatsSummary { + const context = this.getDateKeyContext(timeZone); + const totalsByDay = this.getReadingDayTotals(stats, books, context); + const uniqueDays = Array.from(totalsByDay.keys()).sort(); + const longestDailyReadingStreak = this.getLongestDailyReadingStreak(uniqueDays) ?? { days: 0 }; + const today = this.getDateKey(Date.now(), context); + + return { + mostPagesInADay: this.getMostPagesInADay(totalsByDay), + longestDay: this.getLongestDay(totalsByDay), + currentDailyReadingStreak: this.getCurrentDailyReadingStreak(uniqueDays, today), + longestDailyReadingStreak, + }; + } + + static mostPagesInADay(books: Book[], stats: PageStat[], timeZone = 'UTC'): ReadingPageStat { + const context = this.getDateKeyContext(timeZone); + return this.getMostPagesInADay(this.getReadingDayTotals(stats, books, context)); } static totalReadingTime(stats: PageStat[]) { return sum((stats ?? []).map((s) => s.duration)); } - static longestDay(stats: PageStat[]) { - const timePerDay = stats.reduce>((acc, stat) => { - const day = startOfDay(stat.start_time).getTime(); - acc[day] = (acc[day] || 0) + stat.duration; - return acc; - }, {}); - - const maxTime = Math.max(...Object.values(timePerDay ?? [])); - return Math.max(0, maxTime); + static longestDay(stats: PageStat[], timeZone = 'UTC'): ReadingDayStat { + const context = this.getDateKeyContext(timeZone); + return this.getLongestDay(this.getReadingDayTotals(stats, [], context)); } static last7DaysReadTime(stats: PageStat[]) { @@ -72,58 +112,114 @@ export class StatsService { return sum(lastSevenDays.map((s) => s.duration)); } - static currentDailyReadingStreak(stats: PageStat[]) { + static currentDailyReadingStreak(stats: PageStat[], timeZone = 'UTC'): DailyReadingStreak { if (!stats?.length) { - return 0; + return { days: 0 }; } - const uniqueDays = new Set(this.getUniqueReadingDays(stats)); + const context = this.getDateKeyContext(timeZone); + const today = this.getDateKey(Date.now(), context); + const uniqueDays = this.getUniqueReadingDays(stats, context); - let streak = 0; - const today = startOfDay(new Date()).getTime(); - let currentDay = uniqueDays.has(today) ? today : startOfDay(subDays(today, 1)).getTime(); + return this.getCurrentDailyReadingStreak(uniqueDays, today); + } - while (uniqueDays.has(currentDay)) { - streak += 1; - currentDay = startOfDay(subDays(currentDay, 1)).getTime(); + static longestDailyReadingStreak(stats: PageStat[], timeZone = 'UTC'): DailyReadingStreak { + const context = this.getDateKeyContext(timeZone); + const uniqueDays = this.getUniqueReadingDays(stats, context); + const longestStreak = this.getLongestDailyReadingStreak(uniqueDays); + + if (!longestStreak) { + return { days: 0 }; } - return streak; + return longestStreak; } - static longestDailyReadingStreak(stats: PageStat[]) { - const uniqueDays = this.getUniqueReadingDays(stats); + static totalPagesRead(books: BookWithData[]) { + return books.reduce((acc, book) => acc + book.total_read_pages, 0); + } + + private static getUniqueReadingDays(stats: PageStat[], context: DateKeyContext) { + return Array.from(new Set(stats.map((stat) => this.getDateKey(stat.start_time, context)))).sort(); + } + private static getLongestDailyReadingStreak(uniqueDays: string[]) { if (!uniqueDays.length) { - return 0; + return undefined; } - let longestStreak = 1; - let currentStreak = 1; + let longestStreak = { + start: uniqueDays[0], + end: uniqueDays[0], + days: 1, + }; + + let currentStreak = { ...longestStreak }; for (let i = 1; i < uniqueDays.length; i += 1) { - if (differenceInCalendarDays(uniqueDays[i], uniqueDays[i - 1]) === 1) { - currentStreak += 1; + if ( + differenceInCalendarDays( + this.dateKeyToDate(uniqueDays[i]), + this.dateKeyToDate(uniqueDays[i - 1]) + ) === 1 + ) { + currentStreak.end = uniqueDays[i]; + currentStreak.days += 1; } else { - longestStreak = Math.max(longestStreak, currentStreak); - currentStreak = 1; + if (currentStreak.days > longestStreak.days) { + longestStreak = { ...currentStreak }; + } + + currentStreak = { + start: uniqueDays[i], + end: uniqueDays[i], + days: 1, + }; } } - return Math.max(longestStreak, currentStreak); - } + if (currentStreak.days > longestStreak.days) { + longestStreak = { ...currentStreak }; + } - static totalPagesRead(books: BookWithData[]) { - return books.reduce((acc, book) => acc + book.total_read_pages, 0); + return longestStreak; } - private static getUniqueReadingDays(stats: PageStat[]) { - return Array.from(new Set(stats.map((stat) => startOfDay(stat.start_time).getTime()))).sort( - (a, b) => a - b - ); + private static getCurrentDailyReadingStreak(uniqueDays: string[], today: string): DailyReadingStreak { + const currentUniqueDays = uniqueDays.filter((day) => day <= today); + const latestReadingDay = currentUniqueDays[currentUniqueDays.length - 1]; + + if ( + latestReadingDay === undefined || + differenceInCalendarDays(this.dateKeyToDate(today), this.dateKeyToDate(latestReadingDay)) > 1 + ) { + return { days: 0 }; + } + + const readingDays = new Set(currentUniqueDays); + let streak = 0; + let currentDay = latestReadingDay; + let start = latestReadingDay; + + while (readingDays.has(currentDay)) { + streak += 1; + start = currentDay; + currentDay = this.previousDateKey(currentDay); + } + + return { + days: streak, + start, + end: latestReadingDay, + }; } - private static getPagesPerDay(stats: PageStat[], books: Book[]) { + private static getReadingDayTotals( + stats: PageStat[], + books: Book[], + context: DateKeyContext + ): Map { const booksByMd5 = books?.reduce( (acc, book) => { acc[book.md5] = book; @@ -131,23 +227,142 @@ export class StatsService { }, {} as Record ); + const totalsByDay = new Map(); + + stats.forEach((stat) => { + const day = this.getDateKey(stat.start_time, context); + const dayTotals = totalsByDay.get(day) ?? { duration: 0, pages: 0 }; + dayTotals.duration += stat.duration; + dayTotals.pages += this.getStatPageCount(stat, booksByMd5); + totalsByDay.set(day, dayTotals); + }); + + return totalsByDay; + } - const statsPerDay = groupBy((stat: PageStat) => - startOfDay(stat.start_time).getTime().toString() - )(stats); - - const pagesPerDay = Object.values(statsPerDay).map( - (dayStats) => - dayStats?.reduce((acc, stat) => { - if (stat.total_pages && booksByMd5[stat.book_md5]?.reference_pages) { - return acc + (1 / stat.total_pages) * booksByMd5[stat.book_md5].reference_pages!; - } else { - return acc + 1; - } - }, 0) ?? 0 + private static getMostPagesInADay(totalsByDay: Map): ReadingPageStat { + let maxPagesDay: [string, ReadingDayTotals] | undefined; + + totalsByDay.forEach((totals, day) => { + if (!maxPagesDay || totals.pages > maxPagesDay[1].pages) { + maxPagesDay = [day, totals]; + } + }); + + if (!maxPagesDay) { + return { pages: 0 }; + } + + return { + pages: Math.max(0, Math.round(maxPagesDay[1].pages)), + date: maxPagesDay[0], + }; + } + + private static getLongestDay(totalsByDay: Map): ReadingDayStat { + let longestDay: [string, ReadingDayTotals] | undefined; + + totalsByDay.forEach((totals, day) => { + if (!longestDay || totals.duration > longestDay[1].duration) { + longestDay = [day, totals]; + } + }); + + if (!longestDay) { + return { duration: 0 }; + } + + return { + duration: Math.max(0, longestDay[1].duration), + date: longestDay[0], + }; + } + + private static getStatPageCount(stat: PageStat, booksByMd5: Record) { + const referencePages = booksByMd5[stat.book_md5]?.reference_pages; + if (stat.total_pages && referencePages) { + return (1 / stat.total_pages) * referencePages; + } + + return 1; + } + + private static getDateKeyContext(timeZone: string): DateKeyContext { + let offsetsByUtcHour = this.dateKeyOffsetsByTimeZone.get(timeZone); + if (!offsetsByUtcHour) { + offsetsByUtcHour = new Map(); + this.dateKeyOffsetsByTimeZone.set(timeZone, offsetsByUtcHour); + } + + return { + formatter: this.getDateKeyFormatter(timeZone), + offsetsByUtcHour, + }; + } + + private static getDateKeyFormatter(timeZone: string) { + const existingFormatter = this.dateKeyFormatters.get(timeZone); + if (existingFormatter) { + return existingFormatter; + } + + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }); + this.dateKeyFormatters.set(timeZone, formatter); + + return formatter; + } + + private static getDateKey(timestamp: number, context: DateKeyContext) { + const offset = this.getTimeZoneOffset(timestamp, context); + const localDate = new Date(timestamp + offset); + const year = localDate.getUTCFullYear().toString().padStart(4, '0'); + const month = (localDate.getUTCMonth() + 1).toString().padStart(2, '0'); + const day = localDate.getUTCDate().toString().padStart(2, '0'); + + return `${year}-${month}-${day}`; + } + + private static getTimeZoneOffset(timestamp: number, context: DateKeyContext) { + const utcHour = Math.floor(timestamp / 3_600_000); + const existingOffset = context.offsetsByUtcHour.get(utcHour); + if (existingOffset !== undefined) { + return existingOffset; + } + + const roundedTimestamp = timestamp - (timestamp % 1000); + const parts = context.formatter.formatToParts(new Date(roundedTimestamp)); + const valueFor = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((part) => part.type === type)?.value); + const localTimestampAsUtc = Date.UTC( + valueFor('year'), + valueFor('month') - 1, + valueFor('day'), + valueFor('hour'), + valueFor('minute'), + valueFor('second') ); + const offset = localTimestampAsUtc - roundedTimestamp; - return pagesPerDay; + context.offsetsByUtcHour.set(utcHour, offset); + + return offset; } -} + private static dateKeyToDate(dateKey: string) { + const [year, month, day] = dateKey.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day, 12)); + } + + private static previousDateKey(dateKey: string) { + return subDays(this.dateKeyToDate(dateKey), 1).toISOString().slice(0, 10); + } +} diff --git a/apps/web/src/api/use-page-stats.ts b/apps/web/src/api/use-page-stats.ts index 8c2c1d80..a979507e 100644 --- a/apps/web/src/api/use-page-stats.ts +++ b/apps/web/src/api/use-page-stats.ts @@ -1,25 +1,35 @@ -import { PageStat } from '@koinsight/common/types/page-stat'; +import { GetStatsSummaryResponse, PageStat } from '@koinsight/common/types'; import useSWR from 'swr'; import { fetchFromAPI } from './api'; -import { GetAllStatsResponse } from '@koinsight/common/types'; + +export function useStatsSummary() { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return useSWR( + ['stats', timeZone], + () => fetchFromAPI('stats', 'GET', { time_zone: timeZone }), + { + fallbackData: { + perMonth: [], + perDayOfTheWeek: [], + mostPagesInADay: { pages: 0 }, + totalReadingTime: 0, + longestDay: { duration: 0 }, + last7DaysReadTime: 0, + currentDailyReadingStreak: { days: 0 }, + longestDailyReadingStreak: { days: 0 }, + totalPagesRead: 0, + }, + } + ); +} export function usePageStats() { - return useSWR('stats', () => fetchFromAPI('stats'), { - fallbackData: { - stats: [], - perMonth: [], - perDayOfTheWeek: [], - mostPagesInADay: 0, - totalReadingTime: 0, - longestDay: 0, - last7DaysReadTime: 0, - currentDailyReadingStreak: 0, - longestDailyReadingStreak: 0, - totalPagesRead: 0, - }, + return useSWR('stats/page-stats', () => fetchFromAPI('stats/page-stats'), { + fallbackData: [], }); } export function useBookStats(bookMd5: string) { return useSWR(`stats/${bookMd5}`, () => fetchFromAPI(`stats/${bookMd5}`)); -} +} \ No newline at end of file diff --git a/apps/web/src/components/statistics/reading-calendar.tsx b/apps/web/src/components/statistics/reading-calendar.tsx index e783ac18..40bf4604 100644 --- a/apps/web/src/components/statistics/reading-calendar.tsx +++ b/apps/web/src/components/statistics/reading-calendar.tsx @@ -6,9 +6,7 @@ import { formatSecondsToHumanReadable } from '../../utils/dates'; import { DayData, DotTrail } from '../dot-trail/dot-trail'; export function ReadingCalendar(): JSX.Element { - const { - data: { stats }, - } = usePageStats(); + const { data: stats } = usePageStats(); const percentPerDay: Record = useMemo(() => { const timePerDay = stats.reduce>((acc, stat) => { diff --git a/apps/web/src/pages/calendar-page.tsx b/apps/web/src/pages/calendar-page.tsx index 938ba98d..3c0ca8d4 100644 --- a/apps/web/src/pages/calendar-page.tsx +++ b/apps/web/src/pages/calendar-page.tsx @@ -18,10 +18,7 @@ type DayData = { export function CalendarPage(): JSX.Element { const { data: books, isLoading } = useBooks(); - const { - data: { stats: events }, - isLoading: eventsLoading, - } = usePageStats(); + const { data: events, isLoading: eventsLoading } = usePageStats(); const calendarEvents = useMemo>>(() => { if (eventsLoading || !events) { diff --git a/apps/web/src/pages/stats-page/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index 99c5019c..87d0c754 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -17,11 +17,11 @@ import { IconFlame, IconTrophy, } from '@tabler/icons-react'; -import { format, startOfDay } from 'date-fns'; +import { format } from 'date-fns'; import { JSX, useMemo } from 'react'; import { BarProps } from 'recharts'; import { useBooks } from '../../api/books'; -import { usePageStats } from '../../api/use-page-stats'; +import { useStatsSummary } from '../../api/use-page-stats'; import { CustomBar } from '../../components/charts/custom-bar'; import { ReadingCalendar } from '../../components/statistics/reading-calendar'; import { Statistics } from '../../components/statistics/statistics'; @@ -35,7 +35,6 @@ export function StatsPage(): JSX.Element { const { data: { - stats, perMonth, perDayOfTheWeek, mostPagesInADay, @@ -47,7 +46,7 @@ export function StatsPage(): JSX.Element { totalPagesRead, }, isLoading: statsLoading, - } = usePageStats(); + } = useStatsSummary(); const booksByMd5 = useMemo(() => { return books?.reduce( @@ -62,86 +61,16 @@ export function StatsPage(): JSX.Element { const formatStreakDays = (value: number) => `${value} day${value === 1 ? '' : 's'}`; const formatLocalizedNumber = (value: number) => new Intl.NumberFormat().format(value); - const formatStatDate = (dayTimestamp?: number) => - dayTimestamp ? format(dayTimestamp, 'MMM d, yyyy') : 'No reading data yet'; + const formatDateKey = (dateKey?: string) => + dateKey ? format(new Date(`${dateKey}T12:00:00`), 'MMM d, yyyy') : 'No reading data yet'; - const { longestDayTimestamp, mostPagesDayTimestamp, longestStreakRange } = useMemo(() => { - if (!stats?.length) { - return { - longestDayTimestamp: undefined, - mostPagesDayTimestamp: undefined, - longestStreakRange: undefined, - }; - } + const formatDateRange = (dateRange?: { start?: string; end?: string }) => + dateRange?.start && dateRange.end + ? `${formatDateKey(dateRange.start)} - ${formatDateKey(dateRange.end)}` + : 'N/A'; - const durationPerDay = new Map(); - const pagesPerDay = new Map(); - - for (const stat of stats) { - const dayStart = startOfDay(stat.start_time).getTime(); - durationPerDay.set(dayStart, (durationPerDay.get(dayStart) ?? 0) + stat.duration); - - const referencePages = booksByMd5?.[stat.book_md5]?.reference_pages; - const pagesForStat = stat.total_pages && referencePages ? (1 / stat.total_pages) * referencePages : 1; - pagesPerDay.set(dayStart, (pagesPerDay.get(dayStart) ?? 0) + pagesForStat); - } - - let longestDayEntry: [number, number] | undefined; - for (const entry of durationPerDay.entries()) { - if (!longestDayEntry || entry[1] > longestDayEntry[1]) { - longestDayEntry = entry; - } - } - - let mostPagesEntry: [number, number] | undefined; - for (const entry of pagesPerDay.entries()) { - if (!mostPagesEntry || entry[1] > mostPagesEntry[1]) { - mostPagesEntry = entry; - } - } - - const uniqueDays = Array.from(durationPerDay.keys()).sort((a, b) => a - b); - - let bestStart = uniqueDays[0]; - let bestEnd = uniqueDays[0]; - let bestLength = 1; - - let currentStart = uniqueDays[0]; - let currentEnd = uniqueDays[0]; - let currentLength = 1; - - for (let i = 1; i < uniqueDays.length; i += 1) { - const currentDay = uniqueDays[i]; - const previousDay = uniqueDays[i - 1]; - const isConsecutive = currentDay - previousDay === 24 * 60 * 60 * 1000; - - if (isConsecutive) { - currentEnd = currentDay; - currentLength += 1; - } else { - if (currentLength > bestLength) { - bestStart = currentStart; - bestEnd = currentEnd; - bestLength = currentLength; - } - - currentStart = currentDay; - currentEnd = currentDay; - currentLength = 1; - } - } - - if (currentLength > bestLength) { - bestStart = currentStart; - bestEnd = currentEnd; - } - - return { - longestDayTimestamp: longestDayEntry?.[0], - mostPagesDayTimestamp: mostPagesEntry?.[0], - longestStreakRange: `${format(bestStart, 'MMM d, yyyy')} - ${format(bestEnd, 'MMM d, yyyy')}`, - }; - }, [stats, booksByMd5]); + const formatStartedOn = (dateKey?: string) => + dateKey ? `Started on ${formatDateKey(dateKey)}` : undefined; if (booksLoading || statsLoading) { return ( @@ -187,28 +116,29 @@ export function StatsPage(): JSX.Element { }, { label: 'Longest time reading in a day', - value: formatSecondsToHumanReadable(longestDay), - detail: formatStatDate(longestDayTimestamp), + value: formatSecondsToHumanReadable(longestDay.duration), + detail: formatDateKey(longestDay.date), icon: IconClockStar, }, { label: 'Most pages in a day', value: - mostPagesInADay !== null && mostPagesInADay !== undefined - ? formatLocalizedNumber(mostPagesInADay) + mostPagesInADay.pages !== null && mostPagesInADay.pages !== undefined + ? formatLocalizedNumber(mostPagesInADay.pages) : 'N/A', - detail: formatStatDate(mostPagesDayTimestamp), + detail: formatDateKey(mostPagesInADay.date), icon: IconFileStar, }, { label: 'Current Daily Reading Streak', - value: formatStreakDays(currentDailyReadingStreak), + value: formatStreakDays(currentDailyReadingStreak.days), + detail: formatStartedOn(currentDailyReadingStreak.start), icon: IconFlame, }, { label: 'Longest Daily Reading Streak', - value: formatStreakDays(longestDailyReadingStreak), - detail: longestStreakRange ?? 'N/A', + value: formatStreakDays(longestDailyReadingStreak.days), + detail: formatDateRange(longestDailyReadingStreak), icon: IconTrophy, }, ]} @@ -223,7 +153,7 @@ export function StatsPage(): JSX.Element { Weekly stats - + Per day of the week diff --git a/apps/web/src/pages/stats-page/week-stats.tsx b/apps/web/src/pages/stats-page/week-stats.tsx index a63be665..cad8799a 100644 --- a/apps/web/src/pages/stats-page/week-stats.tsx +++ b/apps/web/src/pages/stats-page/week-stats.tsx @@ -1,7 +1,7 @@ import { Book } from '@koinsight/common/types/book'; import { PageStat } from '@koinsight/common/types/page-stat'; import { AreaChart } from '@mantine/charts'; -import { Flex, Popover, Text, useComputedColorScheme, useMantineTheme } from '@mantine/core'; +import { Flex, Loader, Popover, Text, useComputedColorScheme, useMantineTheme } from '@mantine/core'; import { DatePicker } from '@mantine/dates'; import { IconArrowsVertical, @@ -24,16 +24,12 @@ import { } from 'date-fns'; import { groupBy, sum } from 'ramda'; import { useMemo, useState } from 'react'; +import { usePageStats } from '../../api/use-page-stats'; import { Statistics } from '../../components/statistics/statistics'; import { formatSecondsToHumanReadable } from '../../utils/dates'; -export function WeekStats({ - stats, - booksByMd5, -}: { - stats: PageStat[]; - booksByMd5: Record; -}) { +export function WeekStats({ booksByMd5 }: { booksByMd5: Record }) { + const { data: stats, isLoading: statsLoading } = usePageStats(); const colorScheme = useComputedColorScheme(); const { colors } = useMantineTheme(); @@ -108,6 +104,14 @@ export function WeekStats({ return perDayResult; }, [stats, weekStart, weekEnd]); + if (statsLoading) { + return ( + + + + ); + } + return ( <> diff --git a/packages/common/types/stats-api.ts b/packages/common/types/stats-api.ts index 44172dcd..c56358fb 100644 --- a/packages/common/types/stats-api.ts +++ b/packages/common/types/stats-api.ts @@ -1,5 +1,3 @@ -import { PageStat } from './page-stat'; - export type PerMonthReadingTime = { month: string; duration: number; @@ -13,15 +11,30 @@ export type PerDayOfTheWeek = { day: number; }; -export type GetAllStatsResponse = { - stats: PageStat[]; +export type ReadingPageStat = { + pages: number; + date?: string; +}; + +export type ReadingDayStat = { + duration: number; + date?: string; +}; + +export type DailyReadingStreak = { + days: number; + start?: string; + end?: string; +}; + +export type GetStatsSummaryResponse = { perMonth: PerMonthReadingTime[]; perDayOfTheWeek: PerDayOfTheWeek[]; - mostPagesInADay: number; + mostPagesInADay: ReadingPageStat; totalReadingTime: number; - longestDay: number; + longestDay: ReadingDayStat; last7DaysReadTime: number; - currentDailyReadingStreak: number; - longestDailyReadingStreak: number; + currentDailyReadingStreak: DailyReadingStreak; + longestDailyReadingStreak: DailyReadingStreak; totalPagesRead: number; -}; +}; \ No newline at end of file From 57f99d6dd43e5bdd86fe60015cd932c27473c01f Mon Sep 17 00:00:00 2001 From: Dan Forsyth Date: Thu, 30 Jul 2026 21:58:02 -0400 Subject: [PATCH 14/14] Stats performance and calendar fixes with stat api split --- apps/server/src/stats/stats-repository.ts | 19 +++++- apps/server/src/stats/stats-router.test.ts | 26 ++++++++ apps/server/src/stats/stats-router.ts | 15 ++++- apps/web/src/api/use-page-stats.ts | 24 +++++-- apps/web/src/components/calendar/calendar.tsx | 62 ++++++++++++++++--- .../statistics/reading-calendar.tsx | 6 +- apps/web/src/pages/calendar-page.tsx | 36 ++++++++--- apps/web/src/pages/stats-page/week-stats.tsx | 18 ++++-- 8 files changed, 171 insertions(+), 35 deletions(-) diff --git a/apps/server/src/stats/stats-repository.ts b/apps/server/src/stats/stats-repository.ts index 865c7dde..a9d012b5 100644 --- a/apps/server/src/stats/stats-repository.ts +++ b/apps/server/src/stats/stats-repository.ts @@ -6,12 +6,25 @@ export class StatsRepository { return { ...stat, start_time: stat.start_time * 1000 }; } - static async getAll(): Promise { - const stats = await db('page_stat') + static async getAll({ + start, + end, + }: { start?: number; end?: number } = {}): Promise { + const query = db('page_stat') .join('book', 'page_stat.book_md5', 'book.md5') .where({ 'book.soft_deleted': false }) .select('page_stat.*'); + if (start !== undefined) { + query.where('page_stat.start_time', '>=', Math.floor(start / 1000)); + } + + if (end !== undefined) { + query.where('page_stat.start_time', '<=', Math.ceil(end / 1000)); + } + + const stats = await query; + return stats.map(this.updateStartTime); } @@ -33,4 +46,4 @@ export class StatsRepository { ): Promise { return db('page_stat').where({ book_md5, device_id, page, start_time }).update(data); } -} +} \ No newline at end of file diff --git a/apps/server/src/stats/stats-router.test.ts b/apps/server/src/stats/stats-router.test.ts index ce9cbe5a..87b62d7d 100644 --- a/apps/server/src/stats/stats-router.test.ts +++ b/apps/server/src/stats/stats-router.test.ts @@ -54,4 +54,30 @@ describe('GET /stats', () => { expect(response.body).toHaveLength(2); expect(response.body[0]).toHaveProperty('start_time'); }); + it('filters raw page stats by start and end timestamps', async () => { + const inRange = new Date('2025-01-15T12:00:00.000Z').getTime(); + const outOfRange = new Date('2025-02-15T12:00:00.000Z').getTime(); + + await createPageStat(db, book, bookDevice, device, { + duration: 10, + page: 1, + start_time: inRange / 1000, + }); + await createPageStat(db, book, bookDevice, device, { + duration: 20, + page: 2, + start_time: outOfRange / 1000, + }); + + const response = await request(app).get( + `/stats/page-stats?start=${new Date('2025-01-01T00:00:00.000Z').getTime()}&end=${new Date( + '2025-01-31T23:59:59.999Z' + ).getTime()}` + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0].page).toBe(1); + expect(response.body[0].start_time).toBe(inRange); + }); }); diff --git a/apps/server/src/stats/stats-router.ts b/apps/server/src/stats/stats-router.ts index b77b9ee5..184b314e 100644 --- a/apps/server/src/stats/stats-router.ts +++ b/apps/server/src/stats/stats-router.ts @@ -6,6 +6,15 @@ import { StatsService } from './stats-service'; const router = Router(); +function parseTimestampQueryParam(value: unknown) { + if (typeof value !== 'string') { + return undefined; + } + + const timestamp = Number(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + /** * Get stats summary */ @@ -40,8 +49,10 @@ router.get('/', async (req: Request, res: Response) => { /** * Get raw page stats */ -router.get('/page-stats', async (_req: Request, res: Response) => { - const stats = await StatsRepository.getAll(); +router.get('/page-stats', async (req: Request, res: Response) => { + const start = parseTimestampQueryParam(req.query.start); + const end = parseTimestampQueryParam(req.query.end); + const stats = await StatsRepository.getAll({ start, end }); res.status(200).json(stats); }); diff --git a/apps/web/src/api/use-page-stats.ts b/apps/web/src/api/use-page-stats.ts index a979507e..855b814c 100644 --- a/apps/web/src/api/use-page-stats.ts +++ b/apps/web/src/api/use-page-stats.ts @@ -24,10 +24,26 @@ export function useStatsSummary() { ); } -export function usePageStats() { - return useSWR('stats/page-stats', () => fetchFromAPI('stats/page-stats'), { - fallbackData: [], - }); +type PageStatsRange = { + start?: number; + end?: number; +}; + +export function usePageStats(range: PageStatsRange = {}) { + const query = Object.fromEntries( + Object.entries(range).filter(([, value]) => value !== undefined) + ); + + return useSWR( + ['stats/page-stats', range.start, range.end], + () => fetchFromAPI('stats/page-stats', 'GET', query), + { + fallbackData: [], + keepPreviousData: true, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); } export function useBookStats(bookMd5: string) { diff --git a/apps/web/src/components/calendar/calendar.tsx b/apps/web/src/components/calendar/calendar.tsx index dc549617..6ced26b2 100644 --- a/apps/web/src/components/calendar/calendar.tsx +++ b/apps/web/src/components/calendar/calendar.tsx @@ -26,17 +26,52 @@ export type CalendarEvent = { export type CalendarProps = { events: Record>; dayRenderer?: (data: T) => ReactNode; + onDateRangeChange?: (range: { start: number; end: number }) => void; }; -export function Calendar({ events, dayRenderer }: CalendarProps): JSX.Element { - const [currentDate, setCurrentDate] = useState(new Date()); +function getMonthAnchor(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), 1, 12); +} + +function formatMonthValue(date: Date) { + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + + return `${year}-${month}-01`; +} + +function parseMonthValue(value: string | null) { + if (!value) { + return undefined; + } + + const [year, month] = value.split('-').map(Number); + if (!Number.isFinite(year) || !Number.isFinite(month)) { + return undefined; + } + + return new Date(year, month - 1, 1, 12); +} + +export function Calendar({ + events, + dayRenderer, + onDateRangeChange, +}: CalendarProps): JSX.Element { + const [currentDate, setCurrentDate] = useState(() => getMonthAnchor(new Date())); const startDate = startOfWeek(startOfMonth(currentDate), { locale: { options: { weekStartsOn: 1 } }, }); const endDate = endOfWeek(endOfMonth(currentDate), { locale: { options: { weekStartsOn: 1 } } }); + const startTimestamp = startDate.getTime(); + const endTimestamp = endDate.getTime(); const dates = []; + useEffect(() => { + onDateRangeChange?.({ start: startTimestamp, end: endTimestamp }); + }, [endTimestamp, onDateRangeChange, startTimestamp]); + let day = startDate; while (day <= endDate) { const isCurrentMonth = isSameMonth(day, currentDate); @@ -72,11 +107,11 @@ export function Calendar({ events, dayRenderer }: CalendarProps): JSX.Elem switch (e.key) { case 'ArrowLeft': e.preventDefault(); - setCurrentDate(subMonths(currentDate, 1)); + setCurrentDate((date) => getMonthAnchor(subMonths(date, 1))); break; case 'ArrowRight': e.preventDefault(); - setCurrentDate(addMonths(currentDate, 1)); + setCurrentDate((date) => getMonthAnchor(addMonths(date, 1))); break; } } @@ -96,21 +131,30 @@ export function Calendar({ events, dayRenderer }: CalendarProps): JSX.Elem size="xs" variant="light" color="violet" - onClick={() => setCurrentDate(subMonths(currentDate, 1))} + onClick={() => setCurrentDate((date) => getMonthAnchor(subMonths(date, 1)))} > - - setCurrentDate(e!)} /> + { + const selectedMonth = parseMonthValue(value); + if (selectedMonth) { + setCurrentDate(selectedMonth); + } + }} + />
@@ -119,4 +163,4 @@ export function Calendar({ events, dayRenderer }: CalendarProps): JSX.Elem
{dates}
); -} +} \ No newline at end of file diff --git a/apps/web/src/components/statistics/reading-calendar.tsx b/apps/web/src/components/statistics/reading-calendar.tsx index 40bf4604..33b02ef1 100644 --- a/apps/web/src/components/statistics/reading-calendar.tsx +++ b/apps/web/src/components/statistics/reading-calendar.tsx @@ -1,12 +1,14 @@ import { Flex, Title } from '@mantine/core'; -import { formatDate, startOfDay } from 'date-fns'; +import { endOfDay, formatDate, startOfDay, subDays } from 'date-fns'; import { JSX, useMemo } from 'react'; import { usePageStats } from '../../api/use-page-stats'; import { formatSecondsToHumanReadable } from '../../utils/dates'; import { DayData, DotTrail } from '../dot-trail/dot-trail'; export function ReadingCalendar(): JSX.Element { - const { data: stats } = usePageStats(); + const today = endOfDay(new Date()).getTime(); + const start = startOfDay(subDays(today, 750)).getTime(); + const { data: stats } = usePageStats({ start, end: today }); const percentPerDay: Record = useMemo(() => { const timePerDay = stats.reduce>((acc, stat) => { diff --git a/apps/web/src/pages/calendar-page.tsx b/apps/web/src/pages/calendar-page.tsx index 3c0ca8d4..b0cbaaa3 100644 --- a/apps/web/src/pages/calendar-page.tsx +++ b/apps/web/src/pages/calendar-page.tsx @@ -2,9 +2,9 @@ import { PageStat } from '@koinsight/common/types'; import { Book } from '@koinsight/common/types/book'; import { Anchor, Flex, Loader, Title } from '@mantine/core'; import { IconClock } from '@tabler/icons-react'; -import { startOfDay } from 'date-fns/startOfDay'; +import { endOfMonth, endOfWeek, startOfDay, startOfMonth, startOfWeek } from 'date-fns'; import { sum, uniq } from 'ramda'; -import { JSX, useCallback, useMemo } from 'react'; +import { JSX, useCallback, useMemo, useState } from 'react'; import { Link } from 'react-router'; import { useBooks } from '../api/books'; import { usePageStats } from '../api/use-page-stats'; @@ -16,15 +16,24 @@ type DayData = { events: PageStat[]; }; +type DateRange = { + start: number; + end: number; +}; + +function getCalendarDateRange(date: Date): DateRange { + return { + start: startOfWeek(startOfMonth(date), { locale: { options: { weekStartsOn: 1 } } }).getTime(), + end: endOfWeek(endOfMonth(date), { locale: { options: { weekStartsOn: 1 } } }).getTime(), + }; +} + export function CalendarPage(): JSX.Element { const { data: books, isLoading } = useBooks(); - const { data: events, isLoading: eventsLoading } = usePageStats(); + const [dateRange, setDateRange] = useState(() => getCalendarDateRange(new Date())); + const { data: events } = usePageStats(dateRange); const calendarEvents = useMemo>>(() => { - if (eventsLoading || !events) { - return {}; - } - const eventsList = events.reduce>>((acc, event) => { const date = startOfDay(event.start_time); const key = date.toISOString(); @@ -40,7 +49,13 @@ export function CalendarPage(): JSX.Element { }, {}); return eventsList; - }, [events, eventsLoading]); + }, [events]); + + const handleDateRangeChange = useCallback((range: DateRange) => { + setDateRange((currentRange) => + currentRange.start === range.start && currentRange.end === range.end ? currentRange : range + ); + }, []); const getBookByMd5 = useCallback( (md5: Book['md5']) => books?.find((book) => book.md5 === md5), @@ -75,7 +90,7 @@ export function CalendarPage(): JSX.Element { [getBookByMd5] ); - if (isLoading || !books || !events || eventsLoading) { + if (isLoading || !books) { return ( @@ -89,7 +104,8 @@ export function CalendarPage(): JSX.Element { events={calendarEvents} dayRenderer={(data) => getBookNames(data).map((el) =>
{el}
)} + onDateRangeChange={handleDateRangeChange} /> ); -} +} \ No newline at end of file diff --git a/apps/web/src/pages/stats-page/week-stats.tsx b/apps/web/src/pages/stats-page/week-stats.tsx index cad8799a..0912f323 100644 --- a/apps/web/src/pages/stats-page/week-stats.tsx +++ b/apps/web/src/pages/stats-page/week-stats.tsx @@ -29,7 +29,6 @@ import { Statistics } from '../../components/statistics/statistics'; import { formatSecondsToHumanReadable } from '../../utils/dates'; export function WeekStats({ booksByMd5 }: { booksByMd5: Record }) { - const { data: stats, isLoading: statsLoading } = usePageStats(); const colorScheme = useComputedColorScheme(); const { colors } = useMantineTheme(); @@ -37,16 +36,25 @@ export function WeekStats({ booksByMd5 }: { booksByMd5: Record }) startOfWeek(new Date(), { weekStartsOn: 1 }).getTime() ); + const weekStartDate = useMemo( + () => startOfWeek(weekStart, { weekStartsOn: 1 }).getTime(), + [weekStart] + ); + const weekEnd = useMemo(() => { const rawWeekEnd = endOfWeek(weekStart, { weekStartsOn: 1 }).getTime(); const today = endOfDay(new Date()).getTime(); return rawWeekEnd <= today ? rawWeekEnd : today; }, [weekStart]); + const { data: stats, isLoading: statsLoading } = usePageStats({ + start: weekStartDate, + end: weekEnd, + }); + const weekData = useMemo(() => { - const start = startOfWeek(weekStart, { weekStartsOn: 1 }).getTime(); - return stats?.filter(({ start_time }) => start_time < weekEnd && start_time > start); - }, [stats, weekStart, weekEnd]); + return stats?.filter(({ start_time }) => start_time < weekEnd && start_time > weekStartDate); + }, [stats, weekStartDate, weekEnd]); const weekDaysPassed = useMemo( () => differenceInCalendarDays(weekEnd, weekStart) + 1, @@ -102,7 +110,7 @@ export function WeekStats({ booksByMd5 }: { booksByMd5: Record }) } return perDayResult; - }, [stats, weekStart, weekEnd]); + }, [stats, weekStartDate, weekEnd]); if (statsLoading) { return (