Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions backend/api/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions backend/services/reconciled_reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
Loading
Loading