diff --git a/CHANGELOG.md b/CHANGELOG.md index cef2fe1..dd07952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to Tome are documented here. Format loosely follows ## [Unreleased] +### Added +- **Reading Timeline — your reading life on one ribbon.** A new Timeline tab on + Reading Stats draws every book you've ever read as a bar in time: one lane + per series (named in a frozen rail on the left), volumes as numbered bars + spanning first to last active day, and the intensity of each day's reading + shaded inside the bar. Lifetime data, including imported KOReader history, + reconciled the same way as the rest of stats. Zoom from a year-at-a-glance + down to month detail, hover any bar for the cover and totals, click through + to the book. The tab renders full-bleed — edge to edge, viewport-tall — and + the ribbon is also available as a regular tile in the dashboard gallery. + ### Fixed - **Chapter maps now extract from EPUB2 books.** Older EPUBs keep their table of contents in an NCX file rather than an EPUB3 nav document; chapter diff --git a/backend/api/stats.py b/backend/api/stats.py index cef0f4d..73e840b 100644 --- a/backend/api/stats.py +++ b/backend/api/stats.py @@ -1158,6 +1158,63 @@ def get_completion_estimates( return result +@router.get("/stats/timeline") +def get_reading_timeline( + tz_offset: int = Query(0, description="Client timezone offset in minutes (JS getTimezoneOffset)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + """Lifetime reading timeline: every book with recorded reading and its active + days, reconciled across live sessions and imported KOReader page-stats. + Day bucketing is the canonical reading day (4h rollover).""" + tzm = date_modifier(tz_offset) + covered = rr.covered_book_ids(db, current_user.id) + day_secs = rr.book_day_seconds(db, current_user.id, tzm, covered) + today = effective_today(tz_offset).isoformat() + if not day_secs: + return {"books": [], "today": today} + + per_book: dict[int, dict[str, int]] = defaultdict(dict) + for (book_id, day), secs in day_secs.items(): + if secs > 0 and day is not None: + per_book[book_id][day] = secs + + meta = {b.id: b for b in db.query(Book).filter(Book.id.in_(per_book.keys())).all()} + statuses = { + s.book_id: s + for s in db.query(UserBookStatus).filter( + UserBookStatus.user_id == current_user.id, + UserBookStatus.book_id.in_(per_book.keys()), + ) + } + books = [] + for book_id, days in per_book.items(): + b = meta.get(book_id) + if b is None or not days: # deleted book — its sessions linger; skip + continue + if sum(days.values()) < 60: # sub-minute lifetime totals are page-flip noise + continue + ordered = sorted(days.items()) + st = statuses.get(book_id) + books.append({ + "book_id": book_id, + "title": b.title, + "author": b.author, + "series": b.series, + "series_index": b.series_index, + "has_cover": bool(b.cover_path), + "status": st.status if st else "unread", + "progress_pct": st.progress_pct if st else None, + "finished_on": st.finished_at.strftime("%Y-%m-%d") if st and st.finished_at else None, + "first_day": ordered[0][0], + "last_day": ordered[-1][0], + "total_seconds": sum(days.values()), + "days": [{"date": d, "seconds": s} for d, s in ordered], + }) + books.sort(key=lambda x: (x["first_day"], x["book_id"])) + return {"books": books, "today": today} + + @router.get("/stats/sessions") def list_sessions( offset: int = Query(0, ge=0), diff --git a/backend/services/reconciled_reading.py b/backend/services/reconciled_reading.py index 96a4d04..854e6cb 100644 --- a/backend/services/reconciled_reading.py +++ b/backend/services/reconciled_reading.py @@ -255,6 +255,29 @@ def book_month_seconds(db, user_id, tzm, covered, start_dt) -> dict[tuple[int, s return out +def book_day_seconds(db, user_id, tzm, covered) -> dict[tuple[int, str], int]: + """(book_id, 'YYYY-MM-DD') -> seconds, all time. For the reading timeline.""" + rs_day = func.date(ReadingSession.started_at, tzm) + rows = ( + _rs_filtered(db, user_id, covered, None, None) + .filter(ReadingSession.book_id.isnot(None)) + .with_entities(ReadingSession.book_id, rs_day.label("d"), + func.coalesce(func.sum(ReadingSession.duration_seconds), 0).label("secs")) + .group_by(ReadingSession.book_id, "d").all() + ) + out: dict[tuple[int, str], int] = {(r.book_id, r.d): int(r.secs or 0) for r in rows} + if covered: + rows2 = ( + _ps_filtered(db, user_id, None, None) + .with_entities(PageStat.book_id, _ps_day(tzm).label("d"), + func.coalesce(func.sum(PageStat.duration_seconds), 0).label("secs")) + .group_by(PageStat.book_id, "d").all() + ) + for r in rows2: + out[(r.book_id, r.d)] = out.get((r.book_id, r.d), 0) + int(r.secs or 0) + return out + + # ── Hour × day-of-week ────────────────────────────────────────────────────────── def hour_dow(db, user_id, tzm, covered, cutoff, range_end) -> dict[tuple[int, int], tuple[int, int]]: diff --git a/frontend/src/components/stats/TimelineRibbon.tsx b/frontend/src/components/stats/TimelineRibbon.tsx new file mode 100644 index 0000000..a570942 --- /dev/null +++ b/frontend/src/components/stats/TimelineRibbon.tsx @@ -0,0 +1,356 @@ +// Reading Timeline — your reading life as a horizontal ribbon. One lane per +// series (standalones get their own), named in a frozen left rail; every book +// is a bar spanning its first to last active day, daily ticks inside carrying +// that day's minutes as intensity (one hue, sequential). Data is lifetime and +// reconciled (imported KOReader history included), from GET /stats/timeline. +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Minus, Plus } from 'lucide-react' +import { api } from '@/lib/api' +import { formatDate, formatDuration } from '@/lib/utils' +import { useChartColors } from '@/lib/useChartAccent' + +interface TimelineBook { + book_id: number + title: string + author: string | null + series: string | null + series_index: number | null + has_cover: boolean + status: string + progress_pct: number | null + finished_on: string | null + first_day: string + last_day: string + total_seconds: number + days: { date: string; seconds: number }[] +} + +interface TimelineResponse { + books: TimelineBook[] + today: string +} + +const DAY_MS = 86400000 +const dayNum = (d: string) => Math.floor(Date.parse(`${d}T00:00:00Z`) / DAY_MS) + +const RAIL_W = 200 +const AXIS_H = 26 +const SUB_H = 22 // one sub-lane (bar row) inside a lane +const BAR_H = 16 +const ZOOMS = [1.5, 3, 6, 12] // px per day + +interface Placed { + book: TimelineBook + sub: number // sub-lane within the group, 0 for almost everything +} + +interface LaneGroup { + key: string + label: string + author: string | null + placed: Placed[] + subCount: number + firstDay: number + lastDay: number + totalSeconds: number +} + +// Group books into series lanes; a lane grows sub-lanes only when two volumes +// were genuinely read in overlapping windows (re-reads, parallel volumes). +function buildLanes(books: TimelineBook[]): LaneGroup[] { + const groups = new Map() + for (const b of books) { + const key = b.series ? `s:${b.series}` : `b:${b.book_id}` + const g = groups.get(key) + if (g) g.push(b) + else groups.set(key, [b]) + } + const lanes: LaneGroup[] = [] + for (const [key, members] of groups) { + members.sort((a, z) => (a.first_day < z.first_day ? -1 : 1)) + const subEnds: number[] = [] + const placed: Placed[] = [] + for (const b of members) { + const start = dayNum(b.first_day) + const end = dayNum(b.last_day) + // <= so finishing one volume and starting the next on the same day + // chains onto one row; only genuinely parallel reads stack. + let sub = subEnds.findIndex((e) => e <= start) + if (sub === -1) { + sub = subEnds.length + subEnds.push(end) + } else { + subEnds[sub] = end + } + placed.push({ book: b, sub }) + } + lanes.push({ + key, + label: members[0].series ?? members[0].title, + author: members[0].author, + placed, + subCount: subEnds.length, + firstDay: dayNum(members[0].first_day), + lastDay: Math.max(...members.map((b) => dayNum(b.last_day))), + totalSeconds: members.reduce((s, b) => s + b.total_seconds, 0), + }) + } + // Most recently active on top: the view opens scrolled to today, so the + // top-right corner — the first thing seen — is the reading happening now. + lanes.sort((a, z) => z.lastDay - a.lastDay || z.firstDay - a.firstDay) + return lanes +} + +const fmtIndex = (si: number) => (Number.isInteger(si) ? String(si) : String(si)) + +// Session-lived cache: switching tabs remounts the widget; the ribbon should +// reappear instantly, not re-fetch and flash its loading state every visit. +let cachedData: TimelineResponse | null = null + +const ZOOM_KEY = 'tome_timeline_zoom' +const initialZoom = () => { + const raw = localStorage.getItem(ZOOM_KEY) + if (raw === null) return 2 // Number(null) is 0 — don't let a fresh browser start zoomed out + const z = Number(raw) + return Number.isInteger(z) && z >= 0 && z < ZOOMS.length ? z : 2 +} + +export function TimelineRibbon({ standalone = false }: { standalone?: boolean } = {}) { + const [data, setData] = useState(cachedData) + const [failed, setFailed] = useState(false) + const [zoom, setZoomState] = useState(initialZoom) + const setZoom = (fn: (z: number) => number) => + setZoomState((z) => { + const next = fn(z) + localStorage.setItem(ZOOM_KEY, String(next)) + return next + }) + const [hover, setHover] = useState<{ b: TimelineBook; x: number; y: number } | null>(null) + const scrollRef = useRef(null) + const colors = useChartColors() + const navigate = useNavigate() + + useEffect(() => { + api + .get(`/stats/timeline?tz_offset=${new Date().getTimezoneOffset()}`) + .then((d) => { + cachedData = d + setData(d) + }) + .catch(() => { + if (!cachedData) setFailed(true) + }) + }, []) + + const lanes = useMemo(() => (data && data.books.length ? buildLanes(data.books) : null), [data]) + + // Panel width, live — zooming out floors at fit-width so the ribbon never + // leaves dead space to the right of today on a wide screen. + const [containerW, setContainerW] = useState(0) + useEffect(() => { + const el = scrollRef.current + if (!el) return + const ro = new ResizeObserver(() => setContainerW(el.clientWidth)) + ro.observe(el) + setContainerW(el.clientWidth) + return () => ro.disconnect() + }, [data]) + + const model = useMemo(() => { + if (!data || !lanes) return null + const minDay = Math.min(...lanes.map((l) => l.firstDay)) - 2 + const maxDay = dayNum(data.today) + 2 + const fit = containerW > RAIL_W + 100 ? (containerW - RAIL_W - 1) / (maxDay - minDay) : 0 + const ppd = Math.max(ZOOMS[zoom], fit) + const months: { x: number; label: string }[] = [] + const first = new Date(minDay * DAY_MS) + const cur = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 1)) + while (Math.floor(cur.getTime() / DAY_MS) < maxDay) { + const m = cur.getUTCMonth() + months.push({ + x: (Math.floor(cur.getTime() / DAY_MS) - minDay) * ppd, + label: m === 0 ? String(cur.getUTCFullYear()) : cur.toLocaleString(undefined, { month: 'short', timeZone: 'UTC' }), + }) + cur.setUTCMonth(m + 1) + } + // Intensity reference: p90 of daily totals, so one marathon day doesn't + // flatten every other tick to invisible. + const daySecs = data.books.flatMap((b) => b.days.map((d) => d.seconds)).sort((a, z) => a - z) + const ref = daySecs[Math.floor(daySecs.length * 0.9)] || 1 + return { + minDay, + ppd, + width: (maxDay - minDay) * ppd, + todayX: (dayNum(data.today) - minDay) * ppd, + months, + ref, + } + }, [data, lanes, zoom, containerW]) + + // Land on the recent end — that's where the reading is. + useEffect(() => { + const el = scrollRef.current + if (el && model) el.scrollLeft = el.scrollWidth + }, [model === null]) // eslint-disable-line react-hooks/exhaustive-deps + + if (failed) return + if (!data) return + if (!model || !lanes) return + + const ppd = model.ppd + const atFitFloor = ppd > ZOOMS[zoom] + 0.001 // preset already below fit — zooming out changes nothing + + return ( + // Standalone hugs its content (the page shows background below a short + // ribbon); as a tile it fills the card body it was given. +
+
+ {standalone && ( +
+

Reading Timeline

+ lifetime +
+ )} + + +
+ +
setHover(null)}> +
+ {/* axis row — sticky against vertical scroll; corner sticky both ways */} +
+
+
+ {model.months + .filter((m) => m.x < model.width - 42) + .map((m) => ( + + {m.label} + + ))} +
+
+ + {lanes.map((lane) => { + const laneH = Math.max(lane.subCount * SUB_H + 6, 36) + const padTop = (laneH - lane.subCount * SUB_H) / 2 + return ( +
+
+ {lane.label} + + {lane.placed.length > 1 ? `${lane.placed.length} vols · ` : ''} + {formatDuration(lane.totalSeconds)} + +
+
+ {/* month grid + today, per lane so the rail stays clean */} + {model.months.map((m) => ( +
+ ))} +
+ + {lane.placed.map(({ book: b, sub }) => { + const d0 = dayNum(b.first_day) + const x = (d0 - model.minDay) * ppd + const barW = Math.max((dayNum(b.last_day) - d0 + 1) * ppd, 8) + const showIndex = b.series && b.series_index != null && barW >= 22 + return ( + + ) + })} +
+
+ ) + })} +
+
+ + {hover && ( +
+ {hover.b.has_cover && ( + + )} +
+
{hover.b.title}
+ {hover.b.author &&
{hover.b.author}
} +
+ {formatDate(hover.b.first_day)} – {formatDate(hover.b.last_day)} · {hover.b.days.length}{' '} + {hover.b.days.length === 1 ? 'day' : 'days'} +
+
+ {formatDuration(hover.b.total_seconds)} + {hover.b.finished_on ? ` · finished ${formatDate(hover.b.finished_on)}` : ''} +
+
+
+ )} +
+ ) +} + +function Empty({ text, pulse }: { text: string; pulse?: boolean }) { + return ( +
+ {text} +
+ ) +} diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 7ebaf37..a6d0d19 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode, type MouseEvent, useMemo } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type MouseEvent, useMemo } from 'react' import ReactGridLayout, { useContainerWidth, type Layout, @@ -16,6 +16,7 @@ import { BookAnimation } from '@/components/BookAnimation' import { DOCS, docsLink } from '@/lib/docs' import { type StatsResponse, type CompletionEstimate } from '@/components/stats/shared' import { ReadingDNABody } from '@/components/stats/ReadingDNACard' +import { TimelineRibbon } from '@/components/stats/TimelineRibbon' import { GoalWidgetBody } from '@/components/stats/GoalWidget' import { listGoals, type Goal } from '@/lib/goals' import { @@ -331,6 +332,13 @@ const WIDGETS: WidgetDef[] = [ size: { w: 6, h: 3, minW: 4, minH: 2 }, render: ({ stats }) => , }, + { + id: 'reading-timeline', + title: 'Reading Timeline', + size: { w: 12, h: 7, minW: 6, minH: 3 }, + fixedWindow: 'lifetime', + render: () => , + }, { id: 'reading-pace', title: 'Reading Pace', @@ -607,6 +615,7 @@ const WIDGET_DESC: Record = { 'series-spotlight': 'One series front and center — you pick which', 'hour-dow': 'When you read — hour × weekday', 'session-timeline': 'Daily sessions on a 24h track', + 'reading-timeline': 'Your reading life — every book a bar in time', 'reading-pace': 'Pages per minute over time', 'pace-by-format': 'Speed by book format', 'speed-trend': 'Are you getting faster?', @@ -656,7 +665,7 @@ const GALLERY_GROUPS: { label: string; ids: string[] }[] = [ }, { label: 'Habits', - ids: ['hour-dow', 'reading-clock', 'reading-dna', 'session-timeline', 'reading-pace', 'pace-by-format', 'true-wpm', 'dow-bar', 'time-of-day', 'time-by-format', 'speed-trend', 'estimates', 'period-comparison', 'monthly-comparison'], + ids: ['hour-dow', 'reading-clock', 'reading-dna', 'reading-timeline', 'session-timeline', 'reading-pace', 'pace-by-format', 'true-wpm', 'dow-bar', 'time-of-day', 'time-by-format', 'speed-trend', 'estimates', 'period-comparison', 'monthly-comparison'], }, { label: 'Library', @@ -689,6 +698,8 @@ const INITIAL_POS: Record = { } const PAD_LABEL: Record = { none: 'None', bit: 'A bit', lot: 'A lot' } +// A tab whose only tile is the Reading Timeline renders full-bleed in view mode: +// no card, no grid, edge-to-edge and viewport-tall. Edit mode falls back to the +// grid so the tile stays manageable like any other. +function FullBleedTimeline() { + const ref = useRef(null) + const [h, setH] = useState(null) + useLayoutEffect(() => { + const measure = () => { + if (ref.current) setH(Math.max(420, window.innerHeight - ref.current.getBoundingClientRect().top - 16)) + } + measure() + window.addEventListener('resize', measure) + return () => window.removeEventListener('resize', measure) + }, []) + return ( +
+ {h !== null && } +
+ ) +} + // ── Persistence (localStorage for the POC; server-side comes later) ───────────── // v2: defaults became the real-Stats-page replica — bumping the key discards @@ -1701,6 +1734,8 @@ export function StatsPage() { } }, []) const active = tabs.find((t) => t.id === activeTabId) ?? tabs[0] + // View-mode-only: a board holding nothing but the timeline escapes the grid. + const soloTimeline = !editMode && active.tiles.length === 1 && active.tiles[0].defId === 'reading-timeline' // genuinely no reading history (not just a quiet range): full onboarding state const neverRead = !!stats && stats.headline.total_sessions === 0 && stats.heatmap_daily.every((d) => d.seconds === 0) @@ -2235,7 +2270,7 @@ export function StatsPage() { {/* edit mode gets extra bottom room so the last tile's resize handles have somewhere to scroll to */} -
+
{/* one-time hint that the page is editable */} {stats && !neverRead && !hintDismissed && !editMode && (
@@ -2291,6 +2326,8 @@ export function StatsPage() {

This board is empty.

{editMode ? 'Use “Add tile” to build your ' + active.label + ' board.' : 'Hit Edit, then Add tile.'}

+ ) : soloTimeline ? ( + ) : (
diff --git a/tests/test_stats_timeline.py b/tests/test_stats_timeline.py new file mode 100644 index 0000000..0478a35 --- /dev/null +++ b/tests/test_stats_timeline.py @@ -0,0 +1,96 @@ +"""GET /stats/timeline — lifetime per-book reading lanes for the Timeline tab. + +Reconciliation contract matches the rest of stats: a book with imported +page-stats uses them for device reading (its device-origin sessions are +ignored), web/manual sessions stay additive, session-only books fall back +to sessions entirely. +""" +from datetime import datetime, timezone + +from backend.models.tome_sync import ReadingSession +from backend.models.ko_stats import PageStat +from backend.models.user_book_status import UserBookStatus + + +def _epoch(y, mo, d, h=12): + return int(datetime(y, mo, d, h, tzinfo=timezone.utc).timestamp()) + + +def _add_session(db, user, book, secs, when, device=None): + db.add(ReadingSession(user_id=user.id, book_id=book.id, started_at=when, + ended_at=when, duration_seconds=secs, pages_turned=5, + device=device)) + + +def _add_pagestats(db, user, book, rows, day=(2026, 1, 10)): + base = _epoch(*day) + for i, secs in enumerate(rows): + db.add(PageStat(user_id=user.id, book_id=book.id, page=i + 1, total_pages=100, + start_time=base + i * 60, duration_seconds=secs, device="Kindle")) + + +def _timeline(client): + r = client.get("/api/stats/timeline") + assert r.status_code == 200 + return r.json() + + +def test_timeline_empty(client): + data = _timeline(client) + assert data["books"] == [] + assert data["today"] + + +def test_timeline_reconciles_sources(client, db, admin_user, make_book): + user, _ = admin_user + covered = make_book(title="Kindle Book") + webonly = make_book(title="Web Book") + # covered: page-stats on two days + a device session that must NOT double-count + _add_pagestats(db, user, covered, [120, 80], day=(2026, 1, 10)) + _add_pagestats(db, user, covered, [300], day=(2026, 1, 14)) + _add_session(db, user, covered, 999, datetime(2026, 1, 10, 12)) # device-origin: ignored + _add_session(db, user, covered, 60, datetime(2026, 1, 12, 21), device="web") # additive + # session-only book + _add_session(db, user, webonly, 90, datetime(2026, 2, 1, 9), device="web") + db.flush() + + books = {b["title"]: b for b in _timeline(client)["books"]} + kb = books["Kindle Book"] + assert kb["first_day"] == "2026-01-10" + assert kb["last_day"] == "2026-01-14" + assert kb["total_seconds"] == 120 + 80 + 300 + 60 # 999 dropped + assert [d["date"] for d in kb["days"]] == ["2026-01-10", "2026-01-12", "2026-01-14"] + assert {d["date"]: d["seconds"] for d in kb["days"]}["2026-01-12"] == 60 + + wb = books["Web Book"] + assert wb["first_day"] == wb["last_day"] == "2026-02-01" + assert wb["total_seconds"] == 90 + + +def test_timeline_ordered_and_meta(client, db, admin_user, make_book): + user, _ = admin_user + late = make_book(title="Later Book") + early = make_book(title="Earlier Book") + _add_session(db, user, late, 400, datetime(2026, 3, 5, 9), device="web") + _add_session(db, user, early, 400, datetime(2025, 11, 2, 9), device="web") + db.add(UserBookStatus(user_id=user.id, book_id=late.id, status="read", + finished_at=datetime(2026, 3, 6, 8))) + db.flush() + + data = _timeline(client) + titles = [b["title"] for b in data["books"]] + assert titles == ["Earlier Book", "Later Book"] # sorted by first activity + lb = data["books"][1] + assert lb["status"] == "read" + assert lb["finished_on"] == "2026-03-06" + + +def test_timeline_drops_subminute_noise(client, db, admin_user, make_book): + user, _ = admin_user + noise = make_book(title="Page Flip") + real = make_book(title="Real Read") + _add_session(db, user, noise, 12, datetime(2026, 4, 1, 9), device="web") + _add_session(db, user, real, 300, datetime(2026, 4, 1, 9), device="web") + db.flush() + titles = [b["title"] for b in _timeline(client)["books"]] + assert titles == ["Real Read"] diff --git a/website/src/pages/docs/stats.astro b/website/src/pages/docs/stats.astro index 9a90e8a..f7084c9 100644 --- a/website/src/pages/docs/stats.astro +++ b/website/src/pages/docs/stats.astro @@ -321,6 +321,7 @@ import { withBase } from '../../lib/path'
  • GET /api/stats?days=30&tz_offset=-120 — the main bundle (everything on this page).
  • GET /api/stats/completion-estimates — projected finish dates for in-progress books.
  • GET /api/stats/sessions — paged session list, oldest first.
  • +
  • GET /api/stats/timeline — lifetime per-book reading spans and active days (the Timeline board).
  • DELETE /api/stats/sessions/{`{id}`} — delete a misfire.
  • GET /api/stats/dashboard / PUT /api/stats/dashboard — your saved boards (opaque JSON, 256 KB cap). This is what syncs layouts across devices.