Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
acf0560
Catching and reseting IsLoading if cover search fails
dannoh Jun 15, 2026
b885215
Added Daily reading streaks
dannoh Jun 16, 2026
81fd213
Update stat cards with details and icons
dannoh Jun 16, 2026
7b4d655
chore: prepare local fork deployment
dannoh Jul 30, 2026
dcbc81b
Merge pull request #1 from dannoh/ReadingStats
dannoh Jul 30, 2026
3e1fc6f
Merge pull request #2 from dannoh/FixFailedCoverSearch
dannoh Jul 30, 2026
abd1f2c
Merge remote-tracking branch 'origin/master' into LocalDocker
dannoh Jul 30, 2026
412a7cd
Merge pull request #3 from dannoh/LocalDocker
dannoh Jul 30, 2026
f13f33f
Local deploy example
dannoh Jul 30, 2026
a8bd068
Merge pull request #4 from dannoh/LocalDocker
dannoh Jul 30, 2026
3effcc2
If you haven't read yet today it won't break your streak
dannoh Jul 30, 2026
fe76bca
Fixed current reading stats
dannoh Jul 30, 2026
aa1469b
Changed how we calculate date diff
dannoh Jul 30, 2026
4c650f3
Rework of the stats service
dannoh Jul 30, 2026
0f5a14a
Rework of the stats service
dannoh Jul 30, 2026
f2e1911
Better timezone fix
dannoh Jul 31, 2026
f305364
split stats into 2 calls
dannoh Jul 31, 2026
da71a5e
Split the stats endpoint into stats and page-stats (dates read)
dannoh Jul 31, 2026
b9fe16d
Merge pull request #5 from dannoh/ReadingStatsFixes
dannoh Jul 31, 2026
57f99d6
Stats performance and calendar fixes with stat api split
dannoh Jul 31, 2026
1d4f276
Merge remote-tracking branch 'origin/master' into ReadingStats
dannoh Jul 31, 2026
4e1a230
Merge remote-tracking branch 'origin/master' into ReadingStats
dannoh Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ node_modules
.env.development
.env.production

deploy.sh

*.tsbuildinfo
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/knex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
19 changes: 16 additions & 3 deletions apps/server/src/stats/stats-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,25 @@ export class StatsRepository {
return { ...stat, start_time: stat.start_time * 1000 };
}

static async getAll(): Promise<PageStat[]> {
const stats = await db<PageStat>('page_stat')
static async getAll({
start,
end,
}: { start?: number; end?: number } = {}): Promise<PageStat[]> {
const query = db<PageStat>('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);
}

Expand All @@ -33,4 +46,4 @@ export class StatsRepository {
): Promise<number> {
return db<PageStat>('page_stat').where({ book_md5, device_id, page, start_time }).update(data);
}
}
}
48 changes: 45 additions & 3 deletions apps/server/src/stats/stats-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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);
});
});
41 changes: 31 additions & 10 deletions apps/server/src/stats/stats-router.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,61 @@
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';
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
*/
Expand All @@ -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 };
Loading
Loading