From 6e513cb84cb667641d5f5d7f6b0c37f23f04fc1b Mon Sep 17 00:00:00 2001
From: ChelseaKR <3114598+ChelseaKR@users.noreply.github.com>
Date: Tue, 14 Jul 2026 21:01:28 +0000
Subject: [PATCH] feat(dashboard): wire reading-pace forecasts into a Time to
finish section (EXP-04)
app.forecast shipped as a pure, tested module but nothing rendered it. Build a
per-book BookForecast for every currently-reading title (remaining pages from
the reading stat; no stat -> honest Forecast.unknown()), thread a forecasts
field through DashboardView, and render an accessible 'Time to finish' table
mirroring the existing sections: a range never a single number, the window
basis disclosed, and unestimable books shown as such rather than guessed.
BookForecast lives in app.view and is imported into app.render under
TYPE_CHECKING (annotations are already lazy) so no import cycle is introduced.
Tests: an estimable book renders an hours range with its basis and passes the
a11y structural check; a book with no page stat renders the honest
'not enough recent reading to estimate' text.
Signed-off-by: ChelseaKR <3114598+ChelseaKR@users.noreply.github.com>
---
CHANGELOG.md | 5 ++++
app/render.py | 32 +++++++++++++++++++-
app/view.py | 37 +++++++++++++++++++++++-
docs/ideation/03-expansions.md | 6 ++--
tests/test_render_view.py | 53 ++++++++++++++++++++++++++++++++++
5 files changed, 129 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d7e080..79e57ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,11 @@ accessibility/responsible-tech sign-offs; the automated build, SBOM, GHCR,
keyless-signing/provenance, release, and verify-published lifecycle is in place.
### Added
+- A "Time to finish" dashboard section: for each currently-reading book, an
+ accessible table shows a ranged time-to-finish estimate from your recent
+ reading pace (never a single number), computed locally by the existing
+ `app.forecast` module; books without enough recent reading say so plainly
+ rather than guess (EXP-04 dashboard wiring).
- Structured JSON request logging, `/livez`, and fail-closed `/readyz` (#13).
- `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` (#12).
- Trivy container CVE scan, blocking on HIGH/CRITICAL (#11).
diff --git a/app/render.py b/app/render.py
index 109784b..d8691ec 100644
--- a/app/render.py
+++ b/app/render.py
@@ -20,9 +20,12 @@
import datetime
from collections.abc import Sequence
from html import escape
-from typing import Optional
+from typing import TYPE_CHECKING, Optional
from ingest.models import ReadingState, Recommendation
+
+if TYPE_CHECKING:
+ from app.view import BookForecast
from recommender.lists import CuratedList
from app.diversity import DiversityReport
@@ -211,6 +214,30 @@ def _rec_table(recs: Sequence[Recommendation]) -> str:
"""
+def _forecast_table(forecasts: Sequence[BookForecast]) -> str:
+ if not forecasts:
+ return "
Nothing in progress to forecast right now.
"
+ rows = ""
+ for f in forecasts:
+ fc = f.forecast
+ # Estimable books show an hour range (never a single number); the rest
+ # honestly show no estimate, with the reason in the "Based on" column.
+ estimate = f"{fc.low_hours:g}–{fc.high_hours:g} hours" if fc.estimable else "—"
+ rows += (
+ f'| {escape(f.title)} | '
+ f"{escape(', '.join(f.authors) or 'unknown')} | "
+ f"{escape(estimate)} | "
+ f"{escape(fc.basis)} |
"
+ )
+ return (
+ "Time to finish, at your recent reading pace — a range, "
+ "never a single number, computed locally from your own page timing"
+ '| Title | Author | '
+ 'Estimate | Based on |
'
+ f"{rows}
"
+ )
+
+
def _series_table(series_next: Sequence[SeriesNext]) -> str:
if not series_next:
return "No series to continue right now.
"
@@ -486,6 +513,7 @@ def render_dashboard(
wrapped: Wrapped,
recommendations: Sequence[Recommendation],
*,
+ forecasts: Sequence[BookForecast] = (),
series_next: Sequence[SeriesNext] = (),
to_read: Sequence[ReadingState] = (),
library: Sequence[ReadingState] = (),
@@ -524,6 +552,8 @@ def render_dashboard(
f"{_data_status_section(refreshed_at, stale)}"
"Currently reading
"
f''
+ "Time to finish
"
+ f"{_forecast_table(forecasts)}"
"Reading stats
"
f"{_stats_table(stats)}"
f"{_theme_mix_table(stats)}"
diff --git a/app/view.py b/app/view.py
index c36bbe4..c27e7c3 100644
--- a/app/view.py
+++ b/app/view.py
@@ -19,6 +19,7 @@
from recommender.lists import CuratedList
from app.diversity import DEFAULT_DIMENSIONS, DiversityReport, compute_diversity, load_lens_config
+from app.forecast import Forecast, forecast_book
from app.goals import Goal, compute_goals
from app.shelf import SeriesNext, series_continuations, to_read
from app.stats import ReadingStats, compute_stats
@@ -30,6 +31,15 @@
STALE_AFTER_SECONDS = 7 * 24 * 60 * 60 # 7 days
+@dataclass(frozen=True)
+class BookForecast:
+ """A currently-reading book paired with its time-to-finish forecast."""
+
+ title: str
+ authors: tuple[str, ...]
+ forecast: Forecast
+
+
@dataclass(frozen=True)
class DashboardView:
"""Everything the dashboard renders, assembled once."""
@@ -39,6 +49,7 @@ class DashboardView:
stats: ReadingStats
wrapped: Wrapped
recommendations: tuple[Recommendation, ...]
+ forecasts: tuple[BookForecast, ...] = ()
series_next: tuple[SeriesNext, ...] = ()
to_read: tuple[ReadingState, ...] = ()
library: tuple[ReadingState, ...] = ()
@@ -62,6 +73,19 @@ def _infer_today_and_year(
return today_ordinal, year
+def _remaining_pages(state: ReadingState) -> int:
+ """Pages left in a book from its reading stat; 0 when it can't be known.
+
+ ``forecast_book`` treats a non-positive remaining count as unestimable, so a
+ book with no page stat honestly reports "not enough recent reading to
+ estimate" rather than a guessed range.
+ """
+ stat = state.stat
+ if stat is None or stat.total_pages <= 0:
+ return 0
+ return max(0, stat.total_pages - stat.pages_read)
+
+
def build_view(
states: list[ReadingState],
daily_activity: list[DailyActivity],
@@ -121,16 +145,26 @@ def build_view(
dnf_signals=dnf_signals,
)
library = sorted(states, key=lambda s: (s.title.lower(), s.authors))
+ reading_now = tuple(currently_reading(states))
+ forecasts = tuple(
+ BookForecast(
+ title=s.title,
+ authors=s.authors,
+ forecast=forecast_book(_remaining_pages(s), daily_activity),
+ )
+ for s in reading_now
+ )
stale = False
if refreshed_at is not None:
current = int(time.time()) if now is None else now
stale = (current - refreshed_at) > STALE_AFTER_SECONDS
return DashboardView(
- currently_reading=tuple(currently_reading(states)),
+ currently_reading=reading_now,
finished=tuple(finished(states)),
stats=stats,
wrapped=wrapped,
recommendations=tuple(recs),
+ forecasts=forecasts,
series_next=tuple(series_continuations(states)),
to_read=tuple(to_read(states)),
library=tuple(library),
@@ -153,6 +187,7 @@ def render_view(view: DashboardView) -> str:
view.stats,
view.wrapped,
view.recommendations,
+ forecasts=view.forecasts,
series_next=view.series_next,
to_read=view.to_read,
library=view.library,
diff --git a/docs/ideation/03-expansions.md b/docs/ideation/03-expansions.md
index 6e5da68..f61b1a2 100644
--- a/docs/ideation/03-expansions.md
+++ b/docs/ideation/03-expansions.md
@@ -80,8 +80,10 @@ combined remaining-pages total and adds a weeks-ish gloss to the basis when
the high end is large. `tests/test_forecast.py` pins the p25/p75 math against
a hand-computable fixture and covers the thin-data and zero/negative-pages
fallbacks. Not wired into `render.py`/`app/server.py` — the spec scoped this
-item to the pure module + fixture test; wiring into the dashboard view is a
-natural, still-open follow-up.
+item to the pure module + fixture test. The dashboard wiring is now done: the
+view builds a per-book `BookForecast` for every currently-reading title and the
+renderer shows an accessible "Time to finish" section (a range, never a single
+number; unestimable books say so).
**Pitch:** "at your recent pace, this 384-page book ≈ 8–10 hours; this series
≈ 6 weeks" — locally, from KOReader page timing.
**Impact:** turns data the system already has
diff --git a/tests/test_render_view.py b/tests/test_render_view.py
index b477e73..683b1ca 100644
--- a/tests/test_render_view.py
+++ b/tests/test_render_view.py
@@ -41,6 +41,7 @@ def test_render_contains_all_sections(tmp_path: Path) -> None:
)
for heading in (
"Currently reading",
+ "Time to finish",
"Reading stats",
"Reading Wrapped",
"Recommended for you",
@@ -171,3 +172,55 @@ def test_hide_sensitive_redacts_diversity_section(states: list, candidates: tupl
assert "Privacy:" in section
# The redacted page is still fully accessible (the a11y contract holds).
assert check_html(html) == []
+
+
+def test_forecasts_render_a_ranged_estimate() -> None:
+ from ingest.models import ReadingStat
+
+ stat = ReadingStat(
+ key="k",
+ title="Pace Book",
+ authors=("Author One",),
+ pages_read=100,
+ total_pages=300,
+ read_time_seconds=3600,
+ last_read_ts=1_700_000_000,
+ sessions=6,
+ )
+ state = ReadingState(
+ title="Pace Book",
+ authors=("Author One",),
+ status=ReadingStatus.READING,
+ stat=stat,
+ )
+ # Six recent days with pages read give a solid per-page pace sample
+ # (>= MIN_DAYS_FOR_ESTIMATE), so the forecast is estimable.
+ daily = [DailyActivity(day_ordinal=d, seconds=600, pages=10) for d in range(1, 7)]
+ view = build_view([state], daily, ())
+
+ assert len(view.forecasts) == 1
+ forecast = view.forecasts[0].forecast
+ assert forecast.estimable
+ assert forecast.low_hours > 0
+ assert forecast.high_hours >= forecast.low_hours
+
+ html = render_view(view)
+ assert "Time to finish" in html
+ assert "hours" in html
+ assert "from your last" in html # the window basis is disclosed, never hidden
+ assert check_html(html) == [] # the new section is accessible
+
+
+def test_forecast_without_page_stat_is_honestly_unestimated() -> None:
+ # A currently-reading book with no reading stat can't be forecast; it must
+ # say so rather than guess (unknown stays first-class).
+ state = ReadingState(
+ title="No Stats Yet",
+ authors=("Author Two",),
+ status=ReadingStatus.READING,
+ )
+ view = build_view([state], [DailyActivity(1, 600, 10)], ())
+ assert len(view.forecasts) == 1
+ assert not view.forecasts[0].forecast.estimable
+ html = render_view(view)
+ assert "not enough recent reading to estimate" in html