From 296b14389340c96d49f5f4a6600499430b7c11e5 Mon Sep 17 00:00:00 2001 From: Gerson Umanzor Date: Wed, 29 Apr 2026 10:15:53 -0600 Subject: [PATCH 1/2] feat(web): add toggle to hide sub-minute calendar entries KOReader sometimes records brief opens that surface as 00:00 totals on the calendar. Add a "Hide entries under a minute" switch on the calendar page and per-book calendar to filter those out. --- .../pages/book-page/book-page-calendar.tsx | 67 +++++++++++++------ apps/web/src/pages/calendar-page.tsx | 42 ++++++++++-- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/apps/web/src/pages/book-page/book-page-calendar.tsx b/apps/web/src/pages/book-page/book-page-calendar.tsx index 0d827261..feaf2659 100644 --- a/apps/web/src/pages/book-page/book-page-calendar.tsx +++ b/apps/web/src/pages/book-page/book-page-calendar.tsx @@ -1,8 +1,9 @@ import { BookWithData, PageStat } from '@koinsight/common/types'; +import { Flex, Switch } from '@mantine/core'; import { IconClock } from '@tabler/icons-react'; import { startOfDay } from 'date-fns/startOfDay'; import { sum } from 'ramda'; -import { JSX } from 'react'; +import { JSX, useMemo, useState } from 'react'; import { Calendar, CalendarEvent } from '../../components/calendar/calendar'; import { getDuration, shortDuration } from '../../utils/dates'; @@ -15,26 +16,54 @@ type DayData = { }; export function BookPageCalendar({ book }: BookPageCalendarProps): JSX.Element { - const calendarEvents = book.stats.reduce>>((acc, event) => { - const date = startOfDay(event.start_time); - const key = date.toISOString(); - acc[key] = acc[key] || { date, data: { events: [] } }; - acc[key].data = acc[key]?.data?.events - ? { events: [...acc[key].data.events, event] } - : { events: [event] }; + const [hideEmpty, setHideEmpty] = useState(false); - return acc; - }, {}); + const calendarEvents = useMemo(() => { + const grouped = book.stats.reduce>>((acc, event) => { + const date = startOfDay(event.start_time); + const key = date.toISOString(); + acc[key] = acc[key] || { date, data: { events: [] } }; + acc[key].data = acc[key]?.data?.events + ? { events: [...acc[key].data.events, event] } + : { events: [event] }; + + return acc; + }, {}); + + if (!hideEmpty) { + return grouped; + } + + return Object.entries(grouped).reduce>>( + (acc, [key, entry]) => { + const total = sum(entry.data!.events.map((event) => event.duration)); + if (total >= 60) { + acc[key] = entry; + } + return acc; + }, + {} + ); + }, [book.stats, hideEmpty]); return ( - - events={calendarEvents} - dayRenderer={(data) => ( - <> - {' '} - {shortDuration(getDuration(sum(data.events.map((event) => event.duration))))} - - )} - /> + <> + + setHideEmpty(e.currentTarget.checked)} + /> + + + events={calendarEvents} + dayRenderer={(data) => ( + <> + {' '} + {shortDuration(getDuration(sum(data.events.map((event) => event.duration))))} + + )} + /> + ); } diff --git a/apps/web/src/pages/calendar-page.tsx b/apps/web/src/pages/calendar-page.tsx index 938ba98d..db221176 100644 --- a/apps/web/src/pages/calendar-page.tsx +++ b/apps/web/src/pages/calendar-page.tsx @@ -1,10 +1,10 @@ import { PageStat } from '@koinsight/common/types'; import { Book } from '@koinsight/common/types/book'; -import { Anchor, Flex, Loader, Title } from '@mantine/core'; +import { Anchor, Flex, Loader, Switch, Title } from '@mantine/core'; import { IconClock } from '@tabler/icons-react'; import { startOfDay } from 'date-fns/startOfDay'; 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'; @@ -23,6 +23,8 @@ export function CalendarPage(): JSX.Element { isLoading: eventsLoading, } = usePageStats(); + const [hideEmpty, setHideEmpty] = useState(false); + const calendarEvents = useMemo>>(() => { if (eventsLoading || !events) { return {}; @@ -42,8 +44,31 @@ export function CalendarPage(): JSX.Element { return acc; }, {}); - return eventsList; - }, [events, eventsLoading]); + if (!hideEmpty) { + return eventsList; + } + + return Object.entries(eventsList).reduce>>( + (acc, [key, entry]) => { + const totalsByBook = entry.data!.events.reduce>((totals, event) => { + totals[event.book_md5] = (totals[event.book_md5] ?? 0) + event.duration; + return totals; + }, {}); + + const filtered = entry.data!.events.filter( + (event) => totalsByBook[event.book_md5] >= 60 + ); + + if (filtered.length === 0) { + return acc; + } + + acc[key] = { ...entry, data: { events: filtered } }; + return acc; + }, + {} + ); + }, [events, eventsLoading, hideEmpty]); const getBookByMd5 = useCallback( (md5: Book['md5']) => books?.find((book) => book.md5 === md5), @@ -88,7 +113,14 @@ export function CalendarPage(): JSX.Element { return ( <> - Calendar + + Calendar + setHideEmpty(e.currentTarget.checked)} + /> + events={calendarEvents} dayRenderer={(data) => getBookNames(data).map((el) =>
{el}
)} From e5f660b5abf3335160e81f1a2d385f2e211a26d9 Mon Sep 17 00:00:00 2001 From: Gerson Umanzor Date: Wed, 29 Apr 2026 10:15:59 -0600 Subject: [PATCH 2/2] chore(server): seed sub-minute reading sessions Adds short "accidental open" page stats across recent days so the new calendar toggle has data to filter against during local development. --- apps/server/src/db/seeds/08_short_sessions.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/server/src/db/seeds/08_short_sessions.ts diff --git a/apps/server/src/db/seeds/08_short_sessions.ts b/apps/server/src/db/seeds/08_short_sessions.ts new file mode 100644 index 00000000..007410ff --- /dev/null +++ b/apps/server/src/db/seeds/08_short_sessions.ts @@ -0,0 +1,45 @@ +import { PageStat } from '@koinsight/common/types/page-stat'; +import { setHours, setMinutes, setSeconds, startOfDay, subDays } from 'date-fns'; +import { Knex } from 'knex'; +import { db } from '../../knex'; +import { createPageStat } from '../factories/page-stat-factory'; +import { SEEDED_DEVICES } from './01_devices'; +import { SEEDED_BOOKS } from './02_books'; +import { SEEDED_BOOK_DEVICES } from './03_book_devices'; + +// Simulates "accidental opens" in KOReader: a book is opened briefly and +// shows up on the calendar with a 00:00 total. Lets us exercise the +// "Hide entries under a minute" toggle on the calendar views. +export async function seed(_knex: Knex): Promise { + const today = new Date(); + const promises: Promise[] = []; + + const targetBooks = SEEDED_BOOKS.slice(0, 6); + + targetBooks.forEach((book, bookIndex) => { + const bookDevice = SEEDED_BOOK_DEVICES.find((bd) => bd.book_md5 === book.md5); + const device = SEEDED_DEVICES.find((d) => d.id === bookDevice?.device_id); + if (!bookDevice || !device) return; + + // Spread short sessions across the last 14 days, one per book per day, + // so multiple short entries appear on the same days too. + for (let dayOffset = 1; dayOffset <= 14; dayOffset++) { + if ((dayOffset + bookIndex) % 3 !== 0) continue; + + const day = subDays(startOfDay(today), dayOffset); + const startTime = setSeconds(setMinutes(setHours(day, 9 + bookIndex), 15), 0); + + promises.push( + createPageStat(db, book, bookDevice, device, { + page: 1, + start_time: startTime.valueOf() / 1000, + duration: 5 + ((dayOffset + bookIndex) % 20), // 5-24 seconds + total_pages: bookDevice.pages, + }) + ); + } + }); + + const stats = await Promise.all(promises); + console.log(`✓ Seeded ${stats.length} short (under a minute) reading sessions`); +}