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 ( + "" + '' + '' + f"{rows}
Time to finish, at your recent reading pace — a range, " + "never a single number, computed locally from your own page timing
TitleAuthorEstimateBased on
" + ) + + 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