From 9f62e708dfc0e90684a71d4276cf2f51fe5b318d Mon Sep 17 00:00:00 2001 From: bndct-devops Date: Mon, 6 Jul 2026 23:06:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(stats):=20Reading=20Timeline=20=E2=80=94?= =?UTF-8?q?=20lifetime=20Gantt=20ribbon=20of=20your=20reading=20life?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Timeline tab on Reading Stats: one lane per series (frozen left rail), volumes as numbered bars spanning first to last active day, daily reading intensity shaded inside each bar. Lifetime data, reconciled across live sessions and imported KOReader page-stats via the same page-stats-win rule as the rest of stats, bucketed by the canonical 4h-rollover reading day. - GET /api/stats/timeline + reconciled_reading.book_day_seconds - TimelineRibbon widget registered in the stats dashboard (gallery tile under Habits + default 'timeline' tab, auto-appended to saved boards) - Full-bleed rendering when a board holds only the timeline (view mode); Edit mode falls back to the grid so the tile stays manageable - Lanes sorted by most recent activity; view opens at today - Zoom presets floored at fit-width, persisted; session-cached data so tab switches render instantly; same-day volume chaining on one row - Sub-minute lifetime totals filtered as page-flip noise - 4 endpoint tests; docs section on the website stats page Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 + backend/api/stats.py | 57 +++ backend/services/reconciled_reading.py | 23 ++ .../src/components/stats/TimelineRibbon.tsx | 356 ++++++++++++++++++ frontend/src/pages/StatsPage.tsx | 43 ++- tests/test_stats_timeline.py | 96 +++++ website/src/pages/docs/stats.astro | 1 + 7 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/stats/TimelineRibbon.tsx create mode 100644 tests/test_stats_timeline.py 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.