diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index a797e8f9..c3103d3c 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -549,6 +549,9 @@ STR_READING_STATS_FOR_THIS_BOOK: "Reading stats" STR_READING_STATS_LONGEST: "Longest" STR_READING_STATS_PAGES_PER_MIN: "Pages/min" STR_READING_STATS_HISTORY: "History" +STR_READING_STATS_FINISHED: "Finished" +STR_READING_STATS_ETA: "Time to finish" +STR_READING_STATS_PACE: "Reading pace" STR_LOAD_XTC_FAILED: "Failed to load XTC file" STR_LOAD_EPUB_FAILED: "Failed to load EPUB file" STR_FW_VERSION: "FW version" diff --git a/platformio.ini b/platformio.ini index 03f004c4..7eb218f5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,8 +15,7 @@ upload_speed = 921600 check_tool = cppcheck check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr check_skip_packages = yes - -board_upload.flash_size = 16MB +would board_upload.flash_size = 16MB board_upload.maximum_size = 16777216 board_upload.offset_address = 0x10000 diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 18613f15..7dfb7d71 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -624,7 +624,11 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char obj["firstReadEpoch"] = static_cast(book.firstReadEpoch); obj["lastReadEpoch"] = static_cast(book.lastReadEpoch); obj["progress"] = book.progress; - obj["finished"] = book.finished; + obj["finishedCount"] = book.finishedCount; + obj["lastFinishedEpoch"] = static_cast(book.lastFinishedEpoch); + // Derived for backwards-compatibility with consumers (web dashboard, + // older firmware) that still read the bool field. + obj["finished"] = book.finishedCount > 0; writeDays(obj["days"].to(), book.days); } @@ -677,7 +681,15 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json book.firstReadEpoch = static_cast(obj["firstReadEpoch"] | (int64_t)0); book.lastReadEpoch = static_cast(obj["lastReadEpoch"] | (int64_t)0); book.progress = obj["progress"] | (uint8_t)0; - book.finished = obj["finished"] | false; + // finishedCount is the canonical field. Old files that only have the + // bool "finished" land here as 1 so the per-book screen still shows the + // book as having been finished at least once. + if (!obj["finishedCount"].isNull()) { + book.finishedCount = obj["finishedCount"] | (uint16_t)0; + } else if (obj["finished"] | false) { + book.finishedCount = 1; + } + book.lastFinishedEpoch = static_cast(obj["lastFinishedEpoch"] | (int64_t)0); readDays(obj["days"].as(), book.days); store.books.push_back(std::move(book)); } diff --git a/src/ReadingSessionTracker.cpp b/src/ReadingSessionTracker.cpp index 57190c04..c5b3a236 100644 --- a/src/ReadingSessionTracker.cpp +++ b/src/ReadingSessionTracker.cpp @@ -49,6 +49,14 @@ void ReadingSessionTracker::updateProgress(uint8_t progress) { lastKnownProgress = progress; } +void ReadingSessionTracker::markFinished() { + if (!active) return; + const int64_t walltime = HalClock::isSynced() ? static_cast(HalClock::now()) : 0; + READING_STATS.markFinished(docId, title, author, static_cast(walltime)); + READING_STATS.saveToFile(); + LOG_DBG("RST", "Marked finished doc=%s wall=%lld", docId.c_str(), (long long)walltime); +} + void ReadingSessionTracker::end() { if (!active) return; // Final idle flush so we credit the time between the last page turn and now, diff --git a/src/ReadingSessionTracker.h b/src/ReadingSessionTracker.h index e34a30da..d440d374 100644 --- a/src/ReadingSessionTracker.h +++ b/src/ReadingSessionTracker.h @@ -41,6 +41,12 @@ class ReadingSessionTracker { // out when the session is flushed. Cheap; OK to call on every page turn. void updateProgress(uint8_t progress); + // Record that the user has marked the current session's book as finished. + // Persists immediately (a finish event is rare and the user expects it to + // survive even if the device dies before the session ends normally). + // No-op when no session is active. + void markFinished(); + // Flush the session into ReadingStatsStore and reset internal state. // Subsequent onPageTurn() calls are no-ops until begin() is called again. // Persists the stats file. If the session contributed no reading time it diff --git a/src/ReadingStats.cpp b/src/ReadingStats.cpp index 83b0563f..9a187280 100644 --- a/src/ReadingStats.cpp +++ b/src/ReadingStats.cpp @@ -146,11 +146,75 @@ uint16_t ReadingStatsStore::computeLongestStreak() const { return longest; } +void ReadingStatsStore::markFinished(const std::string& docId, const std::string& title, const std::string& author, + time_t walltimeEpoch) { + if (docId.empty()) return; + auto it = std::find_if(books.begin(), books.end(), [&docId](const BookReadingStats& b) { return b.docId == docId; }); + if (it == books.end()) { + BookReadingStats fresh; + fresh.docId = docId; + fresh.title = title; + fresh.author = author; + books.push_back(std::move(fresh)); + it = books.end() - 1; + } else { + if (!title.empty()) it->title = title; + if (!author.empty()) it->author = author; + } + it->finishedCount += 1; + it->progress = 100; + if (walltimeEpoch != 0) { + it->lastFinishedEpoch = walltimeEpoch; + if (it->lastReadEpoch < walltimeEpoch) it->lastReadEpoch = walltimeEpoch; + } +} + +size_t ReadingStatsStore::getFinishedBookCount() const { + size_t n = 0; + for (const auto& b : books) { + if (b.finishedCount > 0) ++n; + } + return n; +} + const BookReadingStats* ReadingStatsStore::findBook(const std::string& docId) const { auto it = std::find_if(books.begin(), books.end(), [&docId](const BookReadingStats& b) { return b.docId == docId; }); return it == books.end() ? nullptr : &*it; } +float ReadingStatsStore::globalAvgSecondsPerPercent() const { + if (globalTotalSeconds < MIN_GLOBAL_SECONDS_FOR_RATE) return 0.0f; + // Average over books that have actual progress recorded. A book at 0% + // contributes time but no progress denominator and would skew the rate + // toward infinity. Books with progress >= MIN_BOOK_PROGRESS_FOR_PERSONAL_RATE + // are considered "real readings" for the purpose of the global average. + uint32_t totalProgressPercents = 0; + uint32_t totalSecondsFromCountedBooks = 0; + for (const auto& b : books) { + if (b.progress < MIN_BOOK_PROGRESS_FOR_PERSONAL_RATE) continue; + totalProgressPercents += b.progress; + totalSecondsFromCountedBooks += b.totalSeconds; + } + if (totalProgressPercents == 0 || totalSecondsFromCountedBooks == 0) return 0.0f; + return static_cast(totalSecondsFromCountedBooks) / static_cast(totalProgressPercents); +} + +float ReadingStatsStore::avgSecondsPerPercent(const std::string& docId) const { + const BookReadingStats* b = findBook(docId); + if (b && b->progress >= MIN_BOOK_PROGRESS_FOR_PERSONAL_RATE && b->totalSeconds > 0) { + return static_cast(b->totalSeconds) / static_cast(b->progress); + } + return globalAvgSecondsPerPercent(); +} + +uint32_t ReadingStatsStore::estimateRemainingSeconds(const std::string& docId, float remainingPercent) const { + if (remainingPercent <= 0.0f) return 0; + if (remainingPercent > 100.0f) remainingPercent = 100.0f; + const float rate = avgSecondsPerPercent(docId); + if (rate <= 0.0f) return 0; + return static_cast(remainingPercent * rate + 0.5f); +} + bool ReadingStatsStore::saveToFile() const { Storage.mkdir("/.crosspoint"); return JsonSettingsIO::saveReadingStats(*this, READING_STATS_FILE); diff --git a/src/ReadingStats.h b/src/ReadingStats.h index c9328d38..a6c8dc1d 100644 --- a/src/ReadingStats.h +++ b/src/ReadingStats.h @@ -29,8 +29,9 @@ struct BookReadingStats { // 0 if HalClock was never synced when the session ran. Treat as "unknown". time_t firstReadEpoch = 0; time_t lastReadEpoch = 0; - uint8_t progress = 0; // 0-100, snapshot of last known progress - bool finished = false; // user-marked finished (Phase 1: always false) + uint8_t progress = 0; // 0-100, snapshot of last known progress + uint16_t finishedCount = 0; // number of times the user has marked it finished + time_t lastFinishedEpoch = 0; // wallclock of the most recent finish (0 if unknown) // Sparse day buckets, sorted ascending by dayIndex. Only days with reading // are stored — the typical case is a few dozen entries. Bucket with // dayIndex == 0 is reserved for "clock-unknown" sessions and is excluded @@ -78,14 +79,46 @@ class ReadingStatsStore { void recordSession(const std::string& docId, const std::string& title, const std::string& author, uint32_t sessionSeconds, uint32_t sessionPagesTurned, uint8_t progress, time_t walltimeEpoch); + // Mark the given book as having been finished once more. Bumps the per- + // book counter and last-finished epoch (when walltime is available). + // Creates the per-book entry on demand for books that have been opened + // but never accumulated a session — e.g. a quick "mark as read" from the + // menu before any reading time was recorded. + void markFinished(const std::string& docId, const std::string& title, const std::string& author, + time_t walltimeEpoch); + // Lookup by document hash; returns nullptr if unknown. const BookReadingStats* findBook(const std::string& docId) const; + // ---- Reading speed / time-to-finish estimates ----------------------------- + // + // Average seconds spent reading per 1% of book progress. We compute this + // from a book's own history when it has covered enough ground to be + // statistically meaningful, otherwise fall back to the global average over + // all books. Returns 0 when no usable signal exists yet (caller should + // render the ETA as "—"). + // + // The minimum-progress gate prevents wildly optimistic estimates from books + // the user has barely opened (e.g. 30 seconds of reading at 2% progress + // shouldn't extrapolate to a 25-minute book). + static constexpr uint8_t MIN_BOOK_PROGRESS_FOR_PERSONAL_RATE = 3; // % + static constexpr uint32_t MIN_GLOBAL_SECONDS_FOR_RATE = 60; // s + float avgSecondsPerPercent(const std::string& docId) const; + float globalAvgSecondsPerPercent() const; + + // ETA in seconds for finishing `remainingPercent` (0..100) of a book. + // Uses the per-book rate when available, else the global average. Returns + // 0 when no rate is available or when remainingPercent <= 0. + uint32_t estimateRemainingSeconds(const std::string& docId, float remainingPercent) const; + const std::vector& getBooks() const { return books; } uint32_t getGlobalTotalSeconds() const { return globalTotalSeconds; } uint32_t getGlobalTotalSessions() const { return globalTotalSessions; } uint32_t getGlobalTotalPagesTurned() const { return globalTotalPagesTurned; } size_t getBookCount() const { return books.size(); } + // Count of distinct books that have been finished at least once. Derived + // on read so we don't need a separate aggregate counter to keep in sync. + size_t getFinishedBookCount() const; // Read-only view of the global day map. const std::vector& getGlobalDays() const { return globalDays; } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f6a4b84e..9c2a8a94 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -505,6 +505,9 @@ void EpubReaderActivity::loop() { requestUpdate(); return; } + // User confirmed they're done with this book — credit a finish + // to the in-flight session before any tear-down side effects. + globalReadingSessionTracker().markFinished(); const auto& menuResult = std::get(result.data); if (menuResult.action == static_cast(BookFinished::FinishedBookAction::GoHome)) { if (SETTINGS.moveFinishedBooksToCompleted) { @@ -765,6 +768,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); return; } + globalReadingSessionTracker().markFinished(); const auto& menuResult = std::get(result.data); if (menuResult.action == static_cast(BookFinished::FinishedBookAction::GoHome)) { if (SETTINGS.moveFinishedBooksToCompleted) { @@ -1618,6 +1622,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { requestUpdate(); return; } + globalReadingSessionTracker().markFinished(); const auto& menuResult = std::get(result.data); if (menuResult.action == static_cast(BookFinished::FinishedBookAction::GoHome)) { if (SETTINGS.moveFinishedBooksToCompleted) { diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 330f8958..3ca84738 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -302,6 +302,7 @@ void MdReaderActivity::loop() { requestUpdate(); return; } + globalReadingSessionTracker().markFinished(); const auto& menuResult = std::get(result.data); if (menuResult.action == static_cast(BookFinished::FinishedBookAction::GoHome)) { if (SETTINGS.moveFinishedBooksToCompleted) { diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 08fcc502..fd301d65 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -257,6 +257,7 @@ void TxtReaderActivity::launchFinishedBookFlow() { requestUpdate(); return; } + globalReadingSessionTracker().markFinished(); const auto& menuResult = std::get(result.data); if (menuResult.action == static_cast(BookFinished::FinishedBookAction::GoHome)) { if (SETTINGS.moveFinishedBooksToCompleted) { diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index a27840b4..0b22adf4 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -156,6 +156,7 @@ void XtcReaderActivity::loop() { requestUpdate(); return; } + globalReadingSessionTracker().markFinished(); const auto& menuResult = std::get(result.data); if (menuResult.action == static_cast(BookFinished::FinishedBookAction::GoHome)) { if (SETTINGS.moveFinishedBooksToCompleted) { diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp index 00115b42..fb04aab0 100644 --- a/src/activities/settings/ReadingStatsActivity.cpp +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -129,6 +129,11 @@ void ReadingStatsActivity::render(RenderLock&&) { b.rowLR(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned())); b.rowLR(tr(STR_READING_STATS_PAGES_PER_MIN), formatPagesPerMin(store.getGlobalTotalPagesTurned(), store.getGlobalTotalSeconds())); + // Only show the finished row once the user has actually finished a book — + // otherwise it's just clutter saying "0". + if (store.getFinishedBookCount() > 0) { + b.rowLR(tr(STR_READING_STATS_FINISHED), std::to_string(store.getFinishedBookCount())); + } }); // ---- 30-day sparkline card ---- diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp index fad0d9d8..56a1a0de 100644 --- a/src/activities/settings/ReadingStatsBookDetailActivity.cpp +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -143,12 +143,32 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) { layout.card(tr(STR_READING_STATS_TOTAL_TIME), [&](CardLayout::Body& b) { b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(book->totalSeconds)); b.rowLR(tr(STR_READING_STATS_PAGES_PER_MIN), formatPagesPerMin(book->pagesTurned, book->totalSeconds)); + // Time-to-finish estimate based on the user's pace. We display it on the + // detail screen so the reader sees the projection even when not actively + // reading the book; the reader's status bar can pick this up live. + if (book->progress < 100) { + const float remainingPercent = 100.0f - static_cast(book->progress); + const uint32_t etaSeconds = store.estimateRemainingSeconds(book->docId, remainingPercent); + b.rowLR(tr(STR_READING_STATS_ETA), + etaSeconds > 0 ? formatDuration(etaSeconds) : std::string(tr(STR_READING_STATS_UNKNOWN))); + } }); // ---- History card ---- layout.card(tr(STR_READING_STATS_HISTORY), [&](CardLayout::Body& b) { b.rowLR(tr(STR_READING_STATS_FIRST_READ), formatDateOrRelative(book->firstReadEpoch)); b.rowLR(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch)); + if (book->finishedCount > 0) { + // Pretty-print as just the date for a single finish, or "Nx — date" + // for re-reads so the count and recency are both visible. + std::string val = formatDateOrRelative(book->lastFinishedEpoch); + if (book->finishedCount > 1) { + char buf[16]; + snprintf(buf, sizeof(buf), "%ux — ", book->finishedCount); + val = std::string(buf) + val; + } + b.rowLR(tr(STR_READING_STATS_FINISHED), val); + } }); // ---- Per-book sparkline (only when clock-anchored data exists) ---- diff --git a/src/activities/settings/ReadingStatsBookListActivity.cpp b/src/activities/settings/ReadingStatsBookListActivity.cpp index dcbef8f0..6b6fa103 100644 --- a/src/activities/settings/ReadingStatsBookListActivity.cpp +++ b/src/activities/settings/ReadingStatsBookListActivity.cpp @@ -94,8 +94,11 @@ void ReadingStatsBookListActivity::render(RenderLock&&) { [this](int index) { const auto* b = sortedBooks[index]; // Title is the primary label; fall back to docId so a row without - // metadata is still recognizable. - return b->title.empty() ? b->docId : b->title; + // metadata is still recognizable. Finished books get a leading + // checkmark so the user can spot completions at a glance. + std::string label = b->title.empty() ? b->docId : b->title; + if (b->finishedCount > 0) label = "✓ " + label; + return label; }, [this](int index) { // Subtitle row: author, when known. Empty string is treated by the diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index eccddf66..66e61652 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -446,7 +446,12 @@ void CrossPointWebServer::handleStatsApi() const { doc["totalSessions"] = store.getGlobalTotalSessions(); doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned(); doc["bookCount"] = static_cast(store.getBookCount()); + doc["finishedBookCount"] = static_cast(store.getFinishedBookCount()); doc["todayDayIndex"] = today; + // Global reading pace (seconds per book progress percent) — exposed so the + // dashboard can render fallback ETAs for books that don't yet have enough + // personal data to estimate from. + doc["globalSecondsPerPercent"] = store.globalAvgSecondsPerPercent(); if (haveStreak) { doc["currentStreak"] = store.computeCurrentStreak(today); doc["longestStreak"] = store.computeLongestStreak(); @@ -473,7 +478,15 @@ void CrossPointWebServer::handleStatsApi() const { obj["firstReadEpoch"] = static_cast(book.firstReadEpoch); obj["lastReadEpoch"] = static_cast(book.lastReadEpoch); obj["progress"] = book.progress; - obj["finished"] = book.finished; + obj["finishedCount"] = book.finishedCount; + obj["lastFinishedEpoch"] = static_cast(book.lastFinishedEpoch); + // Keep the legacy bool so the existing dashboard JS keeps working. + obj["finished"] = book.finishedCount > 0; + // Estimated seconds to finish the book at the user's pace. 0 = unknown + // (no rate available yet, or the book is already at 100%). + const float remainingPercent = book.progress < 100 ? (100.0f - static_cast(book.progress)) : 0.0f; + obj["etaSeconds"] = store.estimateRemainingSeconds(book.docId, remainingPercent); + obj["secondsPerPercent"] = store.avgSecondsPerPercent(book.docId); JsonArray days = obj["days"].to(); for (const auto& d : book.days) { JsonArray pair = days.add(); diff --git a/src/network/html/StatsPage.html b/src/network/html/StatsPage.html index caf41751..0bbe7bcc 100644 --- a/src/network/html/StatsPage.html +++ b/src/network/html/StatsPage.html @@ -305,6 +305,18 @@ return ((pages * 60) / seconds).toFixed(1); } + // Compact "time-to-finish" rendering. For long projections, "1h 23m" is + // less noisy than "1h 23m 45s" — round to the nearest minute and drop the + // seconds. + function formatEta(seconds) { + if (!seconds || seconds <= 0) return "—"; + const h = Math.floor(seconds / 3600); + const m = Math.round((seconds % 3600) / 60); + if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`; + if (m > 0) return `${m}m`; + return "<1m"; + } + // Renders a 30-bar sparkline into `host` for the day window // [todayDayIndex - 29 .. todayDayIndex] using a map of dayIndex → seconds. function renderSparkline(host, daysMap, todayDayIndex, windowSize = 30) { @@ -382,6 +394,9 @@ allTime.className = "card"; const curStreak = data.currentStreak != null ? String(data.currentStreak) : "—"; const maxStreak = data.longestStreak != null ? String(data.longestStreak) : "—"; + const finishedRow = (data.finishedBookCount && data.finishedBookCount > 0) + ? `
Finished${data.finishedBookCount}
` + : ""; allTime.innerHTML = `

All time

@@ -394,6 +409,7 @@
Total time${formatDuration(data.totalSeconds)}
Pages turned${data.totalPagesTurned}
Pages/min${formatPagesPerMin(data.totalPagesTurned, data.totalSeconds)}
+ ${finishedRow}
`; content.appendChild(allTime); @@ -423,8 +439,8 @@ Title Time - Sessions - Pages + Progress + To finish Last read @@ -436,11 +452,15 @@ const tr = document.createElement("tr"); tr.className = "book-row"; tr.dataset.docId = b.docId; + const finishedMark = b.finishedCount > 0 + ? `` + : ""; + const etaCell = b.progress >= 100 ? "done" : formatEta(b.etaSeconds); tr.innerHTML = ` - ${escapeHtml(b.title || b.docId)} + ${finishedMark}${escapeHtml(b.title || b.docId)} ${formatDuration(b.totalSeconds)} - ${b.sessions} - ${b.pagesTurned} + ${b.progress}% + ${etaCell} ${formatDateOrRelative(b.lastReadEpoch)} `; tr.addEventListener("click", () => toggleBookDetail(b, tr, data.todayDayIndex)); @@ -483,6 +503,12 @@ const cell = document.createElement("td"); cell.colSpan = 5; const avg = book.sessions > 0 ? formatDuration(Math.floor(book.totalSeconds / book.sessions)) : "—"; + const finishedDetail = book.finishedCount > 0 + ? `
Finished${book.finishedCount}× — ${formatDateOrRelative(book.lastFinishedEpoch)}
` + : ""; + const etaDetail = book.progress < 100 + ? `
Time to finish${formatEta(book.etaSeconds)}
` + : ""; cell.innerHTML = `
Author${escapeHtml(book.author || "—")}
@@ -491,6 +517,8 @@
Pages/min${formatPagesPerMin(book.pagesTurned, book.totalSeconds)}
First read${formatDateOrRelative(book.firstReadEpoch)}
Last read${formatDateOrRelative(book.lastReadEpoch)}
+ ${etaDetail} + ${finishedDetail}
`; if (todayDayIndex && book.days && book.days.length > 0) {