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/.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/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); 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 a059be01..9df446dd 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,7 +36,49 @@ 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.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'); + }); + + 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); + }); +}); \ No newline at end of file diff --git a/apps/server/src/stats/stats-router.ts b/apps/server/src/stats/stats-router.ts index ff8285e7..a9af94ae 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'; @@ -6,35 +6,56 @@ 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 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 response: GetAllStatsResponse = { - stats, + const response: GetStatsSummaryResponse = { perMonth, perDayOfTheWeek, - mostPagesInADay, + mostPagesInADay: dailyReadingStats.mostPagesInADay, totalReadingTime, - longestDay, + longestDay: dailyReadingStats.longestDay, last7DaysReadTime, + 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 start = parseTimestampQueryParam(req.query.start); + const end = parseTimestampQueryParam(req.query.end); + const stats = await StatsRepository.getAll({ start, end }); + res.status(200).json(stats); +}); + /** * Get stats by book md5 */ @@ -44,4 +65,4 @@ router.get('/:book_md5', async (req: Request<{ book_md5: string }>, res: Respons res.status(200).json(book); }); -export { router as statsRouter }; +export { router as statsRouter }; \ No newline at end of file diff --git a/apps/server/src/stats/stats-service.test.ts b/apps/server/src/stats/stats-service.test.ts index 5e605dac..aa187912 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; @@ -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 }); }); }); @@ -195,6 +205,188 @@ 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({ days: 3, start: '2025-04-08', end: '2025-04-10' }); + }); + + 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); + + 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({ days: 3, start: '2025-04-07', end: '2025-04-09' }); + }); + + 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({ 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', + }); + }); + + 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', + }); + }); + }); + + 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({ + days: 4, + start: '2025-04-01', + end: '2025-04-04', + }); + }); + + it('returns 0 with no stats', () => { + const result = StatsService.longestDailyReadingStreak([]); + expect(result).toEqual({ days: 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..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 { 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,11 +112,114 @@ export class StatsService { return sum(lastSevenDays.map((s) => s.duration)); } + static currentDailyReadingStreak(stats: PageStat[], timeZone = 'UTC'): DailyReadingStreak { + if (!stats?.length) { + return { days: 0 }; + } + + const context = this.getDateKeyContext(timeZone); + const today = this.getDateKey(Date.now(), context); + const uniqueDays = this.getUniqueReadingDays(stats, context); + + return this.getCurrentDailyReadingStreak(uniqueDays, today); + } + + 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 longestStreak; + } + static totalPagesRead(books: BookWithData[]) { return books.reduce((acc, book) => acc + book.total_read_pages, 0); } - private static getPagesPerDay(stats: PageStat[], books: Book[]) { + 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 undefined; + } + + let longestStreak = { + start: uniqueDays[0], + end: uniqueDays[0], + days: 1, + }; + + let currentStreak = { ...longestStreak }; + + for (let i = 1; i < uniqueDays.length; i += 1) { + if ( + differenceInCalendarDays( + this.dateKeyToDate(uniqueDays[i]), + this.dateKeyToDate(uniqueDays[i - 1]) + ) === 1 + ) { + currentStreak.end = uniqueDays[i]; + currentStreak.days += 1; + } else { + if (currentStreak.days > longestStreak.days) { + longestStreak = { ...currentStreak }; + } + + currentStreak = { + start: uniqueDays[i], + end: uniqueDays[i], + days: 1, + }; + } + } + + if (currentStreak.days > longestStreak.days) { + longestStreak = { ...currentStreak }; + } + + return longestStreak; + } + + 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 getReadingDayTotals( + stats: PageStat[], + books: Book[], + context: DateKeyContext + ): Map { const booksByMd5 = books?.reduce( (acc, book) => { acc[book.md5] = book; @@ -84,22 +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; + } + + 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; + } - 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 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; + + 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)); + } - return pagesPerDay; + 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 797b3aa6..855b814c 100644 --- a/apps/web/src/api/use-page-stats.ts +++ b/apps/web/src/api/use-page-stats.ts @@ -1,23 +1,51 @@ -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() { - return useSWR('stats', () => fetchFromAPI('stats'), { - fallbackData: { - stats: [], - perMonth: [], - perDayOfTheWeek: [], - mostPagesInADay: 0, - totalReadingTime: 0, - longestDay: 0, - last7DaysReadTime: 0, - totalPagesRead: 0, - }, - }); +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, + }, + } + ); +} + +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) { return useSWR(`stats/${bookMd5}`, () => fetchFromAPI(`stats/${bookMd5}`)); -} +} \ No newline at end of file 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 e783ac18..4ec9f503 100644 --- a/apps/web/src/components/statistics/reading-calendar.tsx +++ b/apps/web/src/components/statistics/reading-calendar.tsx @@ -1,14 +1,14 @@ -import { Flex, Title } from '@mantine/core'; -import { formatDate, startOfDay } from 'date-fns'; +import { Flex } from '@mantine/core'; +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) => { @@ -38,4 +38,4 @@ export function ReadingCalendar(): JSX.Element { ); -} +} \ No newline at end of file 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/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) => { diff --git a/apps/web/src/pages/calendar-page.tsx b/apps/web/src/pages/calendar-page.tsx index 938ba98d..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,18 +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: { stats: 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(); @@ -43,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), @@ -78,7 +90,7 @@ export function CalendarPage(): JSX.Element { [getBookByMd5] ); - if (isLoading || !books || !events || eventsLoading) { + if (isLoading || !books) { return ( @@ -92,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/stats-page.tsx b/apps/web/src/pages/stats-page/stats-page.tsx index 104bf2fb..87d0c754 100644 --- a/apps/web/src/pages/stats-page/stats-page.tsx +++ b/apps/web/src/pages/stats-page/stats-page.tsx @@ -9,11 +9,19 @@ import { useComputedColorScheme, useMantineTheme, } from '@mantine/core'; -import { IconClock, IconMaximize, IconPageBreak } from '@tabler/icons-react'; +import { + IconFiles, + IconFileStar, + IconClock, + IconClockStar, + IconFlame, + IconTrophy, +} from '@tabler/icons-react'; +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'; @@ -27,17 +35,18 @@ export function StatsPage(): JSX.Element { const { data: { - stats, perMonth, perDayOfTheWeek, mostPagesInADay, totalReadingTime, longestDay, last7DaysReadTime, + currentDailyReadingStreak, + longestDailyReadingStreak, totalPagesRead, }, isLoading: statsLoading, - } = usePageStats(); + } = useStatsSummary(); const booksByMd5 = useMemo(() => { return books?.reduce( @@ -49,6 +58,20 @@ 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 formatDateKey = (dateKey?: string) => + dateKey ? format(new Date(`${dateKey}T12:00:00`), 'MMM d, yyyy') : 'No reading data yet'; + + const formatDateRange = (dateRange?: { start?: string; end?: string }) => + dateRange?.start && dateRange.end + ? `${formatDateKey(dateRange.start)} - ${formatDateKey(dateRange.end)}` + : 'N/A'; + + const formatStartedOn = (dateKey?: string) => + dateKey ? `Started on ${formatDateKey(dateKey)}` : undefined; + if (booksLoading || statsLoading) { return ( @@ -88,18 +111,35 @@ 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, + value: formatSecondsToHumanReadable(longestDay.duration), + detail: formatDateKey(longestDay.date), + icon: IconClockStar, }, { label: 'Most pages in a day', - value: mostPagesInADay ?? 'N/A', - icon: IconMaximize, + value: + mostPagesInADay.pages !== null && mostPagesInADay.pages !== undefined + ? formatLocalizedNumber(mostPagesInADay.pages) + : 'N/A', + detail: formatDateKey(mostPagesInADay.date), + icon: IconFileStar, + }, + { + label: 'Current Daily Reading Streak', + value: formatStreakDays(currentDailyReadingStreak.days), + detail: formatStartedOn(currentDailyReadingStreak.start), + icon: IconFlame, + }, + { + label: 'Longest Daily Reading Streak', + value: formatStreakDays(longestDailyReadingStreak.days), + detail: formatDateRange(longestDailyReadingStreak), + icon: IconTrophy, }, ]} /> @@ -113,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..7dea6a96 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, @@ -16,7 +16,6 @@ import { endOfWeek, format, formatDate, - getDay, isBefore, isSameDay, startOfDay, @@ -24,16 +23,11 @@ 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 colorScheme = useComputedColorScheme(); const { colors } = useMantineTheme(); @@ -41,16 +35,25 @@ export function WeekStats({ 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, @@ -68,7 +71,7 @@ export function WeekStats({ } }, 0) ?? 0 ), - [weekData] + [booksByMd5, weekData] ); const avgPagesPerDay = useMemo(() => { @@ -88,7 +91,7 @@ export function WeekStats({ ); return Math.round(sum(pagesPerDay) / pagesPerDay.length); - }, [weekData]); + }, [booksByMd5, weekData]); const perDay = useMemo(() => { const perDayResult = []; @@ -108,6 +111,14 @@ export function WeekStats({ return perDayResult; }, [stats, weekStart, weekEnd]); + if (statsLoading) { + return ( + + + + ); + } + return ( <> @@ -175,4 +186,4 @@ export function WeekStats({ /> ); -} +} \ No newline at end of file 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 diff --git a/packages/common/types/stats-api.ts b/packages/common/types/stats-api.ts index 5942a85b..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,13 +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: DailyReadingStreak; + longestDailyReadingStreak: DailyReadingStreak; totalPagesRead: number; -}; +}; \ No newline at end of file