Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
32 changes: 31 additions & 1 deletion app/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,6 +214,30 @@ def _rec_table(recs: Sequence[Recommendation]) -> str:
"""


def _forecast_table(forecasts: Sequence[BookForecast]) -> str:
if not forecasts:
return "<p>Nothing in progress to forecast right now.</p>"
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'<tr><th scope="row">{escape(f.title)}</th>'
f"<td>{escape(', '.join(f.authors) or 'unknown')}</td>"
f"<td>{escape(estimate)}</td>"
f"<td>{escape(fc.basis)}</td></tr>"
)
return (
"<table><caption>Time to finish, at your recent reading pace — a range, "
"never a single number, computed locally from your own page timing</caption>"
'<thead><tr><th scope="col">Title</th><th scope="col">Author</th>'
'<th scope="col">Estimate</th><th scope="col">Based on</th></tr></thead>'
f"<tbody>{rows}</tbody></table>"
)


def _series_table(series_next: Sequence[SeriesNext]) -> str:
if not series_next:
return "<p>No series to continue right now.</p>"
Expand Down Expand Up @@ -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] = (),
Expand Down Expand Up @@ -524,6 +552,8 @@ def render_dashboard(
f"{_data_status_section(refreshed_at, stale)}"
"<h2>Currently reading</h2>"
f'<ul class="books">{reading_items}</ul>'
"<h2>Time to finish</h2>"
f"{_forecast_table(forecasts)}"
"<h2>Reading stats</h2>"
f"{_stats_table(stats)}"
f"{_theme_mix_table(stats)}"
Expand Down
37 changes: 36 additions & 1 deletion app/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -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, ...] = ()
Expand All @@ -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],
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions docs/ideation/03-expansions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/test_render_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Loading