From 65418c260602debabf241680a618c4d6ea9ac999 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 19 May 2026 23:49:42 +0200 Subject: [PATCH 01/14] Phase 1 - basic architecture --- lib/I18n/translations/english.yaml | 8 ++ src/JsonSettingsIO.cpp | 63 ++++++++ src/JsonSettingsIO.h | 5 + src/ReadingSessionTracker.cpp | 91 ++++++++++++ src/ReadingSessionTracker.h | 84 +++++++++++ src/ReadingStats.cpp | 73 ++++++++++ src/ReadingStats.h | 74 ++++++++++ src/activities/reader/EpubReaderActivity.cpp | 17 +++ .../settings/ReadingStatsActivity.cpp | 136 ++++++++++++++++++ .../settings/ReadingStatsActivity.h | 23 +++ .../settings/SettingActionDispatch.cpp | 3 + src/activities/settings/SettingInfo.h | 1 + src/activities/settings/SettingsActivity.cpp | 3 + src/main.cpp | 2 + 14 files changed, 583 insertions(+) create mode 100644 src/ReadingSessionTracker.cpp create mode 100644 src/ReadingSessionTracker.h create mode 100644 src/ReadingStats.cpp create mode 100644 src/ReadingStats.h create mode 100644 src/activities/settings/ReadingStatsActivity.cpp create mode 100644 src/activities/settings/ReadingStatsActivity.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 6310742f..6f1b76ae 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -529,6 +529,14 @@ STR_OVERLAY_READING_PROGRESS: "Reading progress: Page %lu/%u - %.0f%%" STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "Reading progress: Page %lu" STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Page %d/%d - %.0f%% read" STR_SYSTEM_INFO: "System Information" +STR_READING_STATS: "Reading Stats" +STR_READING_STATS_TOTAL_TIME: "Total time" +STR_READING_STATS_SESSIONS: "Sessions" +STR_READING_STATS_PAGES: "Pages turned" +STR_READING_STATS_BOOKS: "Books tracked" +STR_READING_STATS_CURRENT_SESSION: "This session" +STR_READING_STATS_NO_DATA: "No reading recorded yet" +STR_READING_STATS_TOP_BOOKS: "Top books" 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/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index bea75ab2..d49d0f5a 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -13,6 +13,7 @@ #include "CrossPointState.h" #include "KOReaderCredentialStore.h" #include "OpdsServerStore.h" +#include "ReadingStats.h" #include "RecentBooksStore.h" #include "SettingsList.h" #include "WifiCredentialStore.h" @@ -589,3 +590,65 @@ bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* ne LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size()); return true; } + +// ---- ReadingStatsStore ---- + +bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char* path) { + JsonDocument doc; + doc["totalSeconds"] = store.getGlobalTotalSeconds(); + doc["totalSessions"] = store.getGlobalTotalSessions(); + doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned(); + + JsonArray arr = doc["books"].to(); + for (const auto& book : store.getBooks()) { + JsonObject obj = arr.add(); + obj["docId"] = book.docId; + obj["title"] = book.title; + obj["author"] = book.author; + obj["totalSeconds"] = book.totalSeconds; + obj["pagesTurned"] = book.pagesTurned; + obj["sessions"] = book.sessions; + obj["firstReadEpoch"] = static_cast(book.firstReadEpoch); + obj["lastReadEpoch"] = static_cast(book.lastReadEpoch); + obj["progress"] = book.progress; + obj["finished"] = book.finished; + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json) { + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("RST", "JSON parse error: %s", error.c_str()); + return false; + } + + store.books.clear(); + store.globalTotalSeconds = doc["totalSeconds"] | (uint32_t)0; + store.globalTotalSessions = doc["totalSessions"] | (uint32_t)0; + store.globalTotalPagesTurned = doc["totalPagesTurned"] | (uint32_t)0; + + JsonArray arr = doc["books"].as(); + for (JsonObject obj : arr) { + BookReadingStats book; + book.docId = obj["docId"] | std::string(""); + if (book.docId.empty()) continue; // skip corrupt entries + book.title = obj["title"] | std::string(""); + book.author = obj["author"] | std::string(""); + book.totalSeconds = obj["totalSeconds"] | (uint32_t)0; + book.pagesTurned = obj["pagesTurned"] | (uint32_t)0; + book.sessions = obj["sessions"] | (uint32_t)0; + 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; + store.books.push_back(std::move(book)); + } + + LOG_DBG("RST", "Reading stats loaded (%zu books, %u s total)", store.books.size(), store.globalTotalSeconds); + return true; +} diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h index 40d6c4d2..0d863439 100644 --- a/src/JsonSettingsIO.h +++ b/src/JsonSettingsIO.h @@ -6,6 +6,7 @@ class WifiCredentialStore; class KOReaderCredentialStore; class RecentBooksStore; class OpdsServerStore; +class ReadingStatsStore; namespace JsonSettingsIO { @@ -33,4 +34,8 @@ bool loadRecentBooks(RecentBooksStore& store, const char* json); bool saveOpds(const OpdsServerStore& store, const char* path); bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr); +// ReadingStatsStore +bool saveReadingStats(const ReadingStatsStore& store, const char* path); +bool loadReadingStats(ReadingStatsStore& store, const char* json); + } // namespace JsonSettingsIO diff --git a/src/ReadingSessionTracker.cpp b/src/ReadingSessionTracker.cpp new file mode 100644 index 00000000..57190c04 --- /dev/null +++ b/src/ReadingSessionTracker.cpp @@ -0,0 +1,91 @@ +#include "ReadingSessionTracker.h" + +#include // millis() +#include +#include + +#include "ReadingStats.h" + +ReadingSessionTracker& globalReadingSessionTracker() { + static ReadingSessionTracker instance; + return instance; +} + +void ReadingSessionTracker::flushIdleSinceLastActivity() { + if (!active) return; + const uint32_t now = millis(); + const uint32_t delta = now - lastActivityMs; // unsigned wrap is fine + // Cap idle gaps. A user idle for an hour shouldn't get credited for it. + const uint32_t capped = delta > MAX_IDLE_MS ? MAX_IDLE_MS : delta; + accumulatedMs += capped; + lastActivityMs = now; +} + +void ReadingSessionTracker::begin(const std::string& docId_, const std::string& title_, const std::string& author_) { + if (active) { + // Don't lose data from a previous session that wasn't explicitly closed. + end(); + } + active = true; + docId = docId_; + title = title_; + author = author_; + walltimeStartEpoch = HalClock::isSynced() ? static_cast(HalClock::now()) : 0; + lastActivityMs = millis(); + accumulatedMs = 0; + pagesTurnedThisSession = 0; + lastKnownProgress = 0; + LOG_DBG("RST", "Session begin doc=%s sync=%d", docId.c_str(), HalClock::isSynced() ? 1 : 0); +} + +void ReadingSessionTracker::onPageTurn() { + if (!active) return; + flushIdleSinceLastActivity(); + pagesTurnedThisSession += 1; +} + +void ReadingSessionTracker::updateProgress(uint8_t progress) { + if (!active) return; + lastKnownProgress = progress; +} + +void ReadingSessionTracker::end() { + if (!active) return; + // Final idle flush so we credit the time between the last page turn and now, + // capped at MAX_IDLE_MS just like all the other gaps. + flushIdleSinceLastActivity(); + + const uint32_t seconds = static_cast(accumulatedMs / 1000); + // Prefer the walltime captured at begin(); if HalClock has only just become + // synced, fall back to "now" as the lastReadEpoch. + int64_t walltime = walltimeStartEpoch; + if (walltime == 0 && HalClock::isSynced()) { + walltime = static_cast(HalClock::now()); + } + + LOG_DBG("RST", "Session end doc=%s secs=%u pages=%u prog=%u wall=%lld", docId.c_str(), seconds, + pagesTurnedThisSession, lastKnownProgress, (long long)walltime); + + if (seconds > 0 && !docId.empty()) { + READING_STATS.recordSession(docId, title, author, seconds, pagesTurnedThisSession, lastKnownProgress, + static_cast(walltime)); + READING_STATS.saveToFile(); + } + + active = false; + docId.clear(); + title.clear(); + author.clear(); + walltimeStartEpoch = 0; + lastActivityMs = 0; + accumulatedMs = 0; + pagesTurnedThisSession = 0; + lastKnownProgress = 0; +} + +uint32_t ReadingSessionTracker::getLiveSeconds() const { + if (!active) return 0; + // Best-effort live readout — does not mutate state, so it does not include + // the in-flight idle gap. Good enough for "you've been reading for X". + return static_cast(accumulatedMs / 1000); +} diff --git a/src/ReadingSessionTracker.h b/src/ReadingSessionTracker.h new file mode 100644 index 00000000..e34a30da --- /dev/null +++ b/src/ReadingSessionTracker.h @@ -0,0 +1,84 @@ +#pragma once +#include +#include + +// Tracks a single reading session and flushes its accumulated time/pages into +// the ReadingStatsStore when it ends. +// +// Design notes +// - Idle-clamped (CrossPet/KOReader pattern). Each page-turn delta is capped +// at MAX_IDLE_MS, so leaving a book open overnight doesn't poison totals. +// - Two clock sources, mandatory dual-track: +// * elapsedMs accumulator driven by millis() — always correct, survives +// a never-synced HalClock. +// * walltimeStartEpoch snapshot from HalClock::now() if synced — gives +// "first read" / "last read" wallclock for the UI. +// Whichever is available at flush time wins. If HalClock becomes synced +// mid-session, the next flush starts producing real epochs without losing +// the elapsed millis already accumulated. +// - Lifecycle is owned by the reader activity: +// begin(...) on reader enter +// onPageTurn() on each forward/backward page turn +// end() on reader exit / sleep / power off +// Multiple begin() calls without an end() are tolerated — they restart +// the session, flushing the prior one first. +class ReadingSessionTracker { + public: + // Maximum gap (millis) between two activity events that still counts toward + // session time. Beyond this, the gap is treated as idle and dropped. + static constexpr uint32_t MAX_IDLE_MS = 90 * 1000; + + // Start a session for `docId`. If a previous session is still open it is + // flushed first under its own docId. + void begin(const std::string& docId, const std::string& title, const std::string& author); + + // Notify of any activity that should accumulate reading time. Typically + // called from the reader's page-turn path. Safe to call before begin() + // (no-op). + void onPageTurn(); + + // Update the snapshot of the book's progress (0-100). Cached and written + // out when the session is flushed. Cheap; OK to call on every page turn. + void updateProgress(uint8_t progress); + + // 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 + // is silently dropped (no entry created). + void end(); + + // True while a session is active (between begin() and end()). + bool isActive() const { return active; } + + // Live read-only view of the in-flight session. Useful for UI ("you've + // been reading for X minutes"). Returns 0 when no session is active. + uint32_t getLiveSeconds() const; + uint32_t getLivePages() const { return pagesTurnedThisSession; } + const std::string& getDocId() const { return docId; } + + private: + void flushIdleSinceLastActivity(); + + bool active = false; + std::string docId; + std::string title; + std::string author; + + // Wall clock anchor at begin() — 0 if HalClock wasn't synced then. + // We don't refresh this if HalClock becomes synced mid-session; we just + // pick it up on the next session. + int64_t walltimeStartEpoch = 0; + + // millis() at the most recent activity (begin or page turn). Used to + // compute the next idle-clamped delta. + uint32_t lastActivityMs = 0; + + // Idle-clamped accumulator (milliseconds). + uint64_t accumulatedMs = 0; + + uint32_t pagesTurnedThisSession = 0; + uint8_t lastKnownProgress = 0; +}; + +// Global tracker singleton; readers route their lifecycle calls through here. +ReadingSessionTracker& globalReadingSessionTracker(); diff --git a/src/ReadingStats.cpp b/src/ReadingStats.cpp new file mode 100644 index 00000000..cbfb34d5 --- /dev/null +++ b/src/ReadingStats.cpp @@ -0,0 +1,73 @@ +#include "ReadingStats.h" + +#include +#include +#include + +#include + +namespace { +constexpr char READING_STATS_FILE[] = "/.crosspoint/reading-stats.json"; +} // namespace + +ReadingStatsStore ReadingStatsStore::instance; + +void ReadingStatsStore::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) { + if (docId.empty() || sessionSeconds == 0) { + // Nothing to credit. Title-update-only flows go through a different path. + 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 { + // Update title/author opportunistically — they may have been blank if the + // book was first opened before metadata was indexed. + if (!title.empty()) it->title = title; + if (!author.empty()) it->author = author; + } + + it->totalSeconds += sessionSeconds; + it->pagesTurned += sessionPagesTurned; + it->sessions += 1; + it->progress = progress; + if (walltimeEpoch != 0) { + if (it->firstReadEpoch == 0) it->firstReadEpoch = walltimeEpoch; + it->lastReadEpoch = walltimeEpoch; + } + + globalTotalSeconds += sessionSeconds; + globalTotalSessions += 1; + globalTotalPagesTurned += sessionPagesTurned; +} + +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; +} + +bool ReadingStatsStore::saveToFile() const { + Storage.mkdir("/.crosspoint"); + return JsonSettingsIO::saveReadingStats(*this, READING_STATS_FILE); +} + +bool ReadingStatsStore::loadFromFile() { + if (!Storage.exists(READING_STATS_FILE)) { + return false; + } + String json = Storage.readFile(READING_STATS_FILE); + if (json.isEmpty()) { + return false; + } + return JsonSettingsIO::loadReadingStats(*this, json.c_str()); +} diff --git a/src/ReadingStats.h b/src/ReadingStats.h new file mode 100644 index 00000000..476a0630 --- /dev/null +++ b/src/ReadingStats.h @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#include +#include + +// Per-book reading statistics. Keyed by KOReader document hash (or filename +// hash fallback) so a renamed/moved file keeps its history. +// +// Phase-1 schema is intentionally narrow: aggregate counters plus first/last +// timestamps. Day-bucket history for sparklines/heatmaps lands in phase 2. +struct BookReadingStats { + std::string docId; + std::string title; + std::string author; + uint32_t totalSeconds = 0; // idle-clamped, sum across all sessions + uint32_t pagesTurned = 0; // forward + backward + uint32_t sessions = 0; // session-open count + // 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) +}; + +class ReadingStatsStore; +namespace JsonSettingsIO { +bool loadReadingStats(ReadingStatsStore& store, const char* json); +} // namespace JsonSettingsIO + +// Singleton store for per-book + global reading stats. +// +// Persistence model (mirrors RecentBooksStore): +// /.crosspoint/reading-stats.json — one file, all books + global counters +// +// One file is fine for phase 1: ESP32 memory + SD seek cost both favour a +// single small JSON over per-book files. We'll split if it ever grows too +// large (likely never — 50 books * ~120 bytes ≈ 6 KB). +class ReadingStatsStore { + static ReadingStatsStore instance; + + std::vector books; + + // Global aggregates — sum across all books. + uint32_t globalTotalSeconds = 0; + uint32_t globalTotalSessions = 0; + uint32_t globalTotalPagesTurned = 0; + + friend bool JsonSettingsIO::loadReadingStats(ReadingStatsStore&, const char*); + + public: + static ReadingStatsStore& getInstance() { return instance; } + + // Apply a finished session to the store. Creates a per-book entry on first + // use. Increments aggregate counters. Updates first/last epoch when + // walltimeEpoch != 0 (HalClock was synced). Caller is responsible for + // calling saveToFile() — we don't auto-persist on every page turn. + 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); + + // Lookup by document hash; returns nullptr if unknown. + const BookReadingStats* findBook(const std::string& docId) 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(); } + + bool saveToFile() const; + bool loadFromFile(); +}; + +#define READING_STATS ReadingStatsStore::getInstance() diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index aac4dbc8..93149744 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -30,10 +30,12 @@ #include "FinishedBookActivity.h" #include "GlobalBookmarkIndex.h" #include "KOReaderCredentialStore.h" +#include "KOReaderDocumentId.h" #include "MappedInputManager.h" #include "QrDisplayActivity.h" #include "ReaderActivity.h" #include "ReaderUtils.h" +#include "ReadingSessionTracker.h" #include "RecentBooksStore.h" #include "SdCardFontGlobals.h" #include "StarredPagesActivity.h" @@ -324,6 +326,13 @@ void EpubReaderActivity::onEnter() { bookParagraphAlignmentOverride = currentBook.paragraphAlignmentOverride; logReaderMemSnapshot("onEnter_after_recent_books"); + // Start a reading-stats session. We use the cheap filename-based hash here: + // computing the content hash would re-read the file on every reader open, + // and a renamed book getting a new stats entry is acceptable — it'll still + // accumulate going forward. + globalReadingSessionTracker().begin(KOReaderDocumentId::calculateFromFilename(epub->getPath()), epub->getTitle(), + epub->getAuthor()); + // Trigger first update logReaderMemSnapshot("onEnter_before_request_update"); requestUpdate(); @@ -334,6 +343,11 @@ void EpubReaderActivity::onExit() { Activity::onExit(); logReaderMemSnapshot("onExit_before_release"); + // Flush the reading-stats session before tearing down the epub: end() needs + // no live epub reference and persists the JSON. Sleep paths that bypass + // onExit() still end up here on resume because the activity is recreated. + globalReadingSessionTracker().end(); + // Save bookmarks before exit bookmarkStore.save(); if (epub) { @@ -1537,6 +1551,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { preRenderedPage.ready = false; usePreRenderedBuffer = true; sessionPagesAdvanced++; + globalReadingSessionTracker().onPageTurn(); lastPageTurnTime = millis(); requestUpdate(); return; @@ -1546,6 +1561,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { return; } sessionPagesAdvanced++; + globalReadingSessionTracker().onPageTurn(); preRenderedPage.ready = false; requestUpdate(); } @@ -1921,6 +1937,7 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC LOG_ERR("ERS", "Could not save progress!"); return; } + globalReadingSessionTracker().updateProgress(percent); LOG_DBG("ERS", "Progress saved: Chapter %d, Page %d (%d%%)", spineIndex, currentPage, percent); } void EpubReaderActivity::renderContents(std::unique_ptr page, const int orientedMarginTop, diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp new file mode 100644 index 00000000..a0f2671d --- /dev/null +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -0,0 +1,136 @@ +#include "ReadingStatsActivity.h" + +#include // millis() +#include +#include + +#include +#include +#include + +#include "MappedInputManager.h" +#include "ReadingSessionTracker.h" +#include "ReadingStats.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { + +// "1h 23m" / "23m 45s" / "12s" — Phase 1 keeps it compact so a long row fits +// the right column without truncation on the X3's narrow screen. +std::string formatDuration(uint32_t totalSeconds) { + const uint32_t h = totalSeconds / 3600; + const uint32_t m = (totalSeconds % 3600) / 60; + const uint32_t s = totalSeconds % 60; + char buf[24]; + if (h > 0) { + snprintf(buf, sizeof(buf), "%uh %02um", h, m); + } else if (m > 0) { + snprintf(buf, sizeof(buf), "%um %02us", m, s); + } else { + snprintf(buf, sizeof(buf), "%us", s); + } + return buf; +} + +} // namespace + +void ReadingStatsActivity::onEnter() { + Activity::onEnter(); + requestUpdate(); +} + +void ReadingStatsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + // If a reading session happens to be live (e.g. a future entry point lets + // the user pop this screen mid-read), tick at most once per second so + // "this session" moves visibly without hammering the e-ink panel. + if (globalReadingSessionTracker().isActive()) { + const uint32_t now = millis(); + if (now - lastLiveRefreshMs >= 1000) { + lastLiveRefreshMs = now; + requestUpdate(); + } + } +} + +void ReadingStatsActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, /*hasBottomHints=*/true, /*hasSideHints=*/false); + + renderer.clearScreen(); + + GUI.drawHeader(renderer, + Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, + tr(STR_READING_STATS), nullptr); + + const int leftX = contentRect.x + metrics.verticalSpacing * 3; + const int valueX = contentRect.x + contentRect.width / 2; + const int lineH = renderer.getLineHeight(UI_10_FONT_ID); + const int rowStep = lineH + 2; + const int subHeaderHeight = lineH + 6; + int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + + auto drawSection = [&](const char* title) { + GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title); + y += subHeaderHeight + 2; + }; + auto drawRow = [&](const char* label, const std::string& value) { + renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); + renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); + y += rowStep; + }; + + const auto& store = READING_STATS; + auto& tracker = globalReadingSessionTracker(); + + // ---- Live session (only if currently reading) ---- + if (tracker.isActive()) { + drawSection(tr(STR_READING_STATS_CURRENT_SESSION)); + drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds())); + drawRow(tr(STR_READING_STATS_PAGES), std::to_string(tracker.getLivePages())); + } + + // ---- All time ---- + drawSection(tr(STR_READING_STATS_TOTAL_TIME)); + if (store.getGlobalTotalSeconds() == 0) { + drawRow("", tr(STR_READING_STATS_NO_DATA)); + } else { + drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds())); + drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(store.getGlobalTotalSessions())); + drawRow(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned())); + drawRow(tr(STR_READING_STATS_BOOKS), std::to_string(store.getBookCount())); + } + + // ---- Top books (up to 3 by total time) ---- + if (!store.getBooks().empty()) { + std::vector sorted; + sorted.reserve(store.getBooks().size()); + for (const auto& b : store.getBooks()) sorted.push_back(&b); + std::sort(sorted.begin(), sorted.end(), + [](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; }); + + drawSection(tr(STR_READING_STATS_TOP_BOOKS)); + const size_t shown = std::min(sorted.size(), 3); + for (size_t i = 0; i < shown; ++i) { + const auto* b = sorted[i]; + // Use the title when known; fall back to docId so the row is never + // empty even before metadata is recorded. + std::string label = b->title.empty() ? b->docId : b->title; + // Trim to fit; 22 chars keeps it in the left column at UI_10. + if (label.size() > 22) { + label.resize(22); + label += "…"; + } + drawRow(label.c_str(), formatDuration(b->totalSeconds)); + } + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ReadingStatsActivity.h b/src/activities/settings/ReadingStatsActivity.h new file mode 100644 index 00000000..753a957e --- /dev/null +++ b/src/activities/settings/ReadingStatsActivity.h @@ -0,0 +1,23 @@ +#pragma once + +#include "activities/Activity.h" + +// Phase-1 reading-stats screen. Read-only summary of the per-book and global +// reading counters collected by ReadingSessionTracker. Mirrors the layout of +// SystemInformationActivity so it slots into the existing settings flow with +// no new theme work. Future phases will add per-book drill-in, day-by-day +// sparklines, and a web-frontend dashboard. +class ReadingStatsActivity final : public Activity { + public: + explicit ReadingStatsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("ReadingStats", renderer, mappedInput) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + // Last millis() at which we refreshed the live "this session" block. + // We only redraw once per second to avoid hammering the e-ink panel. + uint32_t lastLiveRefreshMs = 0; +}; diff --git a/src/activities/settings/SettingActionDispatch.cpp b/src/activities/settings/SettingActionDispatch.cpp index 9c7c2d2d..2871549f 100644 --- a/src/activities/settings/SettingActionDispatch.cpp +++ b/src/activities/settings/SettingActionDispatch.cpp @@ -10,6 +10,7 @@ #include "LanguageSelectActivity.h" #include "OpdsServerListActivity.h" #include "OtaUpdateActivity.h" +#include "ReadingStatsActivity.h" #include "SdFirmwareUpdateActivity.h" #include "StatusBarSettingsActivity.h" #include "SyncTimeActivity.h" @@ -52,6 +53,8 @@ std::unique_ptr createActivityForAction(SettingAction action, GfxRende return std::make_unique(renderer, mappedInput); case SettingAction::DetectTimezone: return std::make_unique(renderer, mappedInput); + case SettingAction::ReadingStats: + return std::make_unique(renderer, mappedInput); case SettingAction::Submenu: case SettingAction::None: return nullptr; diff --git a/src/activities/settings/SettingInfo.h b/src/activities/settings/SettingInfo.h index 5b61b6b2..1c86fc11 100644 --- a/src/activities/settings/SettingInfo.h +++ b/src/activities/settings/SettingInfo.h @@ -31,6 +31,7 @@ enum class SettingAction { DetectTimezone, SyncTime, Weather, + ReadingStats, Submenu, }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 3c160da5..ee8e5823 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -173,6 +173,9 @@ void SettingsActivity::onEnter() { addToMoved(systemSettings, lastSystemSub, std::move(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo) .withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); + addToMoved(systemSettings, lastSystemSub, + std::move(SettingInfo::Action(StrId::STR_READING_STATS, SettingAction::ReadingStats) + .withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); SettingInfo::prepareSubmenus(displaySettings, submenuData); SettingInfo::prepareSubmenus(readerSettings, submenuData); diff --git a/src/main.cpp b/src/main.cpp index 64b46191..84ea1135 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -28,6 +28,7 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "OpdsServerStore.h" +#include "ReadingStats.h" #include "RecentBooksStore.h" #include "SdCardFontSystem.h" #include "SilentRestart.h" @@ -384,6 +385,7 @@ void setup() { HalClock::restore(); RECENT_BOOKS.loadFromFile(); GLOBAL_BOOKMARKS.load(); + READING_STATS.loadFromFile(); if (recoveryFirmwareMode) { // Skip normal home/reader routing: jump straight into the SD firmware picker. From 991a283e05370801a89eb6ad7b464997395bd4b0 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:03:48 +0200 Subject: [PATCH 02/14] Phase 2 - Daily streaks --- lib/I18n/translations/english.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 6f1b76ae..f5884fa3 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -537,6 +537,15 @@ STR_READING_STATS_BOOKS: "Books tracked" STR_READING_STATS_CURRENT_SESSION: "This session" STR_READING_STATS_NO_DATA: "No reading recorded yet" STR_READING_STATS_TOP_BOOKS: "Top books" +STR_READING_STATS_STREAK: "Streak" +STR_READING_STATS_LAST_30D: "Last 30 days" +STR_READING_STATS_DAYS_UNIT: "d" +STR_READING_STATS_BOOK_LIST: "All books" +STR_READING_STATS_FIRST_READ: "First read" +STR_READING_STATS_AVG_SESSION: "Avg session" +STR_READING_STATS_PROGRESS: "Progress" +STR_READING_STATS_UNKNOWN: "—" +STR_READING_STATS_LAST_READ: "Last read" STR_LOAD_XTC_FAILED: "Failed to load XTC file" STR_LOAD_EPUB_FAILED: "Failed to load EPUB file" STR_FW_VERSION: "FW version" From 4b0c99f35dca973c185a6d3b07f0dfea624e7ab2 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:08:39 +0200 Subject: [PATCH 03/14] Phase 3 - local book stats --- src/JsonSettingsIO.cpp | 33 +++ src/ReadingStats.cpp | 103 +++++++++- src/ReadingStats.h | 55 ++++- .../settings/ReadingStatsActivity.cpp | 66 +++++- .../ReadingStatsBookDetailActivity.cpp | 190 ++++++++++++++++++ .../settings/ReadingStatsBookDetailActivity.h | 22 ++ .../settings/ReadingStatsBookListActivity.cpp | 118 +++++++++++ .../settings/ReadingStatsBookListActivity.h | 28 +++ 8 files changed, 600 insertions(+), 15 deletions(-) create mode 100644 src/activities/settings/ReadingStatsBookDetailActivity.cpp create mode 100644 src/activities/settings/ReadingStatsBookDetailActivity.h create mode 100644 src/activities/settings/ReadingStatsBookListActivity.cpp create mode 100644 src/activities/settings/ReadingStatsBookListActivity.h diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index d49d0f5a..18613f15 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -599,6 +599,19 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char doc["totalSessions"] = store.getGlobalTotalSessions(); doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned(); + // Day buckets are serialised as a flat array of [dayIndex, seconds] pairs + // to keep the file compact when many days are populated. The C++ side + // already keeps days sorted, so we preserve that on disk too. + auto writeDays = [](JsonArray out, const std::vector& days) { + for (const auto& d : days) { + JsonArray pair = out.add(); + pair.add(d.dayIndex); + pair.add(d.seconds); + } + }; + + writeDays(doc["globalDays"].to(), store.getGlobalDays()); + JsonArray arr = doc["books"].to(); for (const auto& book : store.getBooks()) { JsonObject obj = arr.add(); @@ -612,6 +625,7 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char obj["lastReadEpoch"] = static_cast(book.lastReadEpoch); obj["progress"] = book.progress; obj["finished"] = book.finished; + writeDays(obj["days"].to(), book.days); } String json; @@ -628,10 +642,28 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json } store.books.clear(); + store.globalDays.clear(); store.globalTotalSeconds = doc["totalSeconds"] | (uint32_t)0; store.globalTotalSessions = doc["totalSessions"] | (uint32_t)0; store.globalTotalPagesTurned = doc["totalPagesTurned"] | (uint32_t)0; + // Reads [dayIndex, seconds] pairs into a DayBucket vector, dropping + // malformed entries. We don't re-sort because saver writes in order; the + // result of accidentally hand-edited unsorted input is just degraded + // streak/sparkline accuracy, not a crash. + auto readDays = [](JsonArray in, std::vector& out) { + for (JsonArray pair : in) { + if (pair.size() < 2) continue; + DayBucket b; + b.dayIndex = pair[0] | (uint16_t)0; + b.seconds = pair[1] | (uint32_t)0; + if (b.dayIndex == 0 || b.seconds == 0) continue; + out.push_back(b); + } + }; + + readDays(doc["globalDays"].as(), store.globalDays); + JsonArray arr = doc["books"].as(); for (JsonObject obj : arr) { BookReadingStats book; @@ -646,6 +678,7 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json book.lastReadEpoch = static_cast(obj["lastReadEpoch"] | (int64_t)0); book.progress = obj["progress"] | (uint8_t)0; book.finished = obj["finished"] | false; + readDays(obj["days"].as(), book.days); store.books.push_back(std::move(book)); } diff --git a/src/ReadingStats.cpp b/src/ReadingStats.cpp index cbfb34d5..83b0563f 100644 --- a/src/ReadingStats.cpp +++ b/src/ReadingStats.cpp @@ -1,15 +1,64 @@ #include "ReadingStats.h" +#include #include #include #include #include +#include namespace { constexpr char READING_STATS_FILE[] = "/.crosspoint/reading-stats.json"; + +// Add `seconds` to the bucket for `dayIndex` in `days`, inserting in sorted +// position if absent. dayIndex == 0 ("unknown day") is silently skipped here — +// the caller decides whether to credit unknown-day reading to a sentinel +// bucket or drop it entirely. +void mergeDay(std::vector& days, uint16_t dayIndex, uint32_t seconds) { + if (dayIndex == 0 || seconds == 0) return; + auto it = std::lower_bound(days.begin(), days.end(), dayIndex, + [](const DayBucket& b, uint16_t v) { return b.dayIndex < v; }); + if (it != days.end() && it->dayIndex == dayIndex) { + it->seconds += seconds; + } else { + days.insert(it, {dayIndex, seconds}); + } +} + +uint16_t dayIndexFromLocaltime(const struct tm& t) { + // Days since 1970-01-01 by Y/M/D in local time. Uses the proleptic + // Gregorian calendar — close enough for a 65k-day uint16 range (≈179 + // years). We deliberately do NOT call mktime() to avoid DST round-trip + // surprises near transition midnights. + const int year = t.tm_year + 1900; + const int month = t.tm_mon + 1; + const int day = t.tm_mday; + // Howard Hinnant's days-from-civil, lightly inlined. + const int y = year - (month <= 2 ? 1 : 0); + const int era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = static_cast(y - era * 400); + const unsigned doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + const long days = era * 146097L + static_cast(doe) - 719468L; + if (days < 1 || days > 65535) return 0; // outside our uint16_t window + return static_cast(days); +} + } // namespace +uint16_t localDayIndexFromEpoch(time_t epoch) { + if (epoch == 0) return 0; + struct tm t{}; + localtime_r(&epoch, &t); + return dayIndexFromLocaltime(t); +} + +uint16_t currentLocalDayIndex() { + if (!HalClock::isSynced()) return 0; + return localDayIndexFromEpoch(HalClock::now()); +} + ReadingStatsStore ReadingStatsStore::instance; void ReadingStatsStore::recordSession(const std::string& docId, const std::string& title, const std::string& author, @@ -20,8 +69,7 @@ void ReadingStatsStore::recordSession(const std::string& docId, const std::strin return; } - auto it = std::find_if(books.begin(), books.end(), - [&docId](const BookReadingStats& b) { return b.docId == docId; }); + 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; @@ -43,6 +91,12 @@ void ReadingStatsStore::recordSession(const std::string& docId, const std::strin if (walltimeEpoch != 0) { if (it->firstReadEpoch == 0) it->firstReadEpoch = walltimeEpoch; it->lastReadEpoch = walltimeEpoch; + // Credit the local-day buckets on both the book and the global map. + // We only do this when the clock is trustworthy; unknown-day sessions + // still contribute to the running totals above but not to streaks. + const uint16_t day = localDayIndexFromEpoch(walltimeEpoch); + mergeDay(it->days, day, sessionSeconds); + mergeDay(globalDays, day, sessionSeconds); } globalTotalSeconds += sessionSeconds; @@ -50,9 +104,50 @@ void ReadingStatsStore::recordSession(const std::string& docId, const std::strin globalTotalPagesTurned += sessionPagesTurned; } +uint32_t ReadingStatsStore::getSecondsForDay(uint16_t dayIndex) const { + if (dayIndex == 0) return 0; + auto it = std::lower_bound(globalDays.begin(), globalDays.end(), dayIndex, + [](const DayBucket& b, uint16_t v) { return b.dayIndex < v; }); + if (it != globalDays.end() && it->dayIndex == dayIndex) return it->seconds; + return 0; +} + +uint16_t ReadingStatsStore::computeCurrentStreak(uint16_t today) const { + if (today == 0 || globalDays.empty()) return 0; + // 1-day grace: if there's no reading today, the streak may still end at + // yesterday. After that the chain is broken. + uint16_t anchor = today; + if (getSecondsForDay(anchor) == 0) { + if (anchor == 0) return 0; + anchor -= 1; + if (getSecondsForDay(anchor) == 0) return 0; + } + uint16_t streak = 0; + while (anchor > 0 && getSecondsForDay(anchor) > 0) { + streak += 1; + if (anchor == 1) break; + anchor -= 1; + } + return streak; +} + +uint16_t ReadingStatsStore::computeLongestStreak() const { + if (globalDays.empty()) return 0; + uint16_t longest = 1; + uint16_t run = 1; + for (size_t i = 1; i < globalDays.size(); ++i) { + if (globalDays[i].dayIndex == globalDays[i - 1].dayIndex + 1) { + run += 1; + if (run > longest) longest = run; + } else { + run = 1; + } + } + return longest; +} + 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; }); + auto it = std::find_if(books.begin(), books.end(), [&docId](const BookReadingStats& b) { return b.docId == docId; }); return it == books.end() ? nullptr : &*it; } diff --git a/src/ReadingStats.h b/src/ReadingStats.h index 476a0630..c9328d38 100644 --- a/src/ReadingStats.h +++ b/src/ReadingStats.h @@ -4,23 +4,38 @@ #include #include +// Day buckets are keyed by an ordinal day count (days since 1970-01-01 in +// LOCAL time, computed by localDayIndex() below). A "reading day" is the +// calendar day the session ENDED in — phase 2 keeps this simple and doesn't +// model the KOReader "day shift" / hour cutoff setting yet. +struct DayBucket { + uint16_t dayIndex = 0; + uint32_t seconds = 0; +}; + +// Helpers — both return 0 when HalClock is unsynced (caller should skip). +uint16_t localDayIndexFromEpoch(time_t epoch); +uint16_t currentLocalDayIndex(); + // Per-book reading statistics. Keyed by KOReader document hash (or filename // hash fallback) so a renamed/moved file keeps its history. -// -// Phase-1 schema is intentionally narrow: aggregate counters plus first/last -// timestamps. Day-bucket history for sparklines/heatmaps lands in phase 2. struct BookReadingStats { std::string docId; std::string title; std::string author; - uint32_t totalSeconds = 0; // idle-clamped, sum across all sessions - uint32_t pagesTurned = 0; // forward + backward - uint32_t sessions = 0; // session-open count + uint32_t totalSeconds = 0; // idle-clamped, sum across all sessions + uint32_t pagesTurned = 0; // forward + backward + uint32_t sessions = 0; // session-open count // 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 + bool finished = false; // user-marked finished (Phase 1: always false) + // 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 + // from sparklines/streaks but kept so totals remain consistent. + std::vector days; }; class ReadingStatsStore; @@ -45,6 +60,9 @@ class ReadingStatsStore { uint32_t globalTotalSeconds = 0; uint32_t globalTotalSessions = 0; uint32_t globalTotalPagesTurned = 0; + // Global per-day reading time, sorted ascending. Same shape as per-book. + // Used to compute streaks and the sparkline on the stats screen. + std::vector globalDays; friend bool JsonSettingsIO::loadReadingStats(ReadingStatsStore&, const char*); @@ -53,8 +71,10 @@ class ReadingStatsStore { // Apply a finished session to the store. Creates a per-book entry on first // use. Increments aggregate counters. Updates first/last epoch when - // walltimeEpoch != 0 (HalClock was synced). Caller is responsible for - // calling saveToFile() — we don't auto-persist on every page turn. + // walltimeEpoch != 0 (HalClock was synced). When walltimeEpoch != 0, also + // credits the session into a local-day bucket on both the book and the + // global map. Caller is responsible for calling saveToFile() — we don't + // auto-persist on every page turn. 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); @@ -67,6 +87,21 @@ class ReadingStatsStore { uint32_t getGlobalTotalPagesTurned() const { return globalTotalPagesTurned; } size_t getBookCount() const { return books.size(); } + // Read-only view of the global day map. + const std::vector& getGlobalDays() const { return globalDays; } + + // Seconds read on a specific local-day index. 0 if unknown. + uint32_t getSecondsForDay(uint16_t dayIndex) const; + + // Current streak in days, ending at `today` (or `today-1` for a 1-day grace + // so a session that ended just past midnight still extends yesterday's + // streak when you check this morning). Returns 0 if globalDays is empty or + // if HalClock is unsynced (so `today == 0`). + uint16_t computeCurrentStreak(uint16_t today) const; + + // Longest run of consecutive days with any reading. + uint16_t computeLongestStreak() const; + bool saveToFile() const; bool loadFromFile(); }; diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp index a0f2671d..ee102288 100644 --- a/src/activities/settings/ReadingStatsActivity.cpp +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -11,6 +11,7 @@ #include "MappedInputManager.h" #include "ReadingSessionTracker.h" #include "ReadingStats.h" +#include "ReadingStatsBookListActivity.h" #include "components/UITheme.h" #include "fontIds.h" @@ -45,6 +46,13 @@ void ReadingStatsActivity::loop() { finish(); return; } + // Confirm opens the all-books list when there's anything to drill into. + // Suppressed when the store is empty so the button hint never lies. + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm) && !READING_STATS.getBooks().empty()) { + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult&) { requestUpdate(); }); + return; + } // If a reading session happens to be live (e.g. a future entry point lets // the user pop this screen mid-read), tick at most once per second so // "this session" moves visibly without hammering the e-ink panel. @@ -103,6 +111,61 @@ void ReadingStatsActivity::render(RenderLock&&) { drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(store.getGlobalTotalSessions())); drawRow(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned())); drawRow(tr(STR_READING_STATS_BOOKS), std::to_string(store.getBookCount())); + + // Streaks: only meaningful when at least one wall-clocked session exists. + if (!store.getGlobalDays().empty()) { + const uint16_t today = currentLocalDayIndex(); + const uint16_t current = store.computeCurrentStreak(today); + const uint16_t longest = store.computeLongestStreak(); + char buf[24]; + snprintf(buf, sizeof(buf), "%u%s / %u%s", current, tr(STR_READING_STATS_DAYS_UNIT), longest, + tr(STR_READING_STATS_DAYS_UNIT)); + drawRow(tr(STR_READING_STATS_STREAK), buf); + } + } + + // ---- 30-day sparkline ---- + // Renders one bar per day for the last 30 local days ending at "today". + // Height of each bar is proportional to that day's seconds vs. the maximum + // seen in the window. Days with no reading get a flat 1px baseline so the + // gap pattern stays visible. Drawn only when the clock is synced — without + // it we have no "today" to anchor the window against. + const uint16_t today = currentLocalDayIndex(); + if (today != 0 && !store.getGlobalDays().empty()) { + drawSection(tr(STR_READING_STATS_LAST_30D)); + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 38; + constexpr int kBarGap = 1; + const int sparkLeft = leftX; + const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; + const int sparkWidth = std::max(0, sparkRight - sparkLeft); + const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; + const int sparkOriginY = y; + + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = + (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; + const uint32_t s = store.getSecondsForDay(d); + if (s > maxSeconds) maxSeconds = s; + } + + // Baseline (axis) — 1px line under the bars so the visual grouping reads + // as a chart even when most days are empty. + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, sparkOriginY + kSparkHeight, + true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = + (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; + const uint32_t s = store.getSecondsForDay(d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + // 1px minimum so empty days still tick on the axis. + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + y += kSparkHeight + 6; } // ---- Top books (up to 3 by total time) ---- @@ -129,7 +192,8 @@ void ReadingStatsActivity::render(RenderLock&&) { } } - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + const char* btn2 = store.getBooks().empty() ? "" : tr(STR_READING_STATS_BOOK_LIST); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), btn2, "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp new file mode 100644 index 00000000..7daea1ad --- /dev/null +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -0,0 +1,190 @@ +#include "ReadingStatsBookDetailActivity.h" + +#include +#include +#include + +#include +#include +#include + +#include "MappedInputManager.h" +#include "ReadingStats.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { + +std::string formatDuration(uint32_t totalSeconds) { + const uint32_t h = totalSeconds / 3600; + const uint32_t m = (totalSeconds % 3600) / 60; + const uint32_t s = totalSeconds % 60; + char buf[24]; + if (h > 0) { + snprintf(buf, sizeof(buf), "%uh %02um", h, m); + } else if (m > 0) { + snprintf(buf, sizeof(buf), "%um %02us", m, s); + } else { + snprintf(buf, sizeof(buf), "%us", s); + } + return buf; +} + +// "today", "yesterday", "N days ago", or "YYYY-MM-DD" beyond a month. +// Returns "—" when epoch is 0 or the clock isn't synced. +std::string formatDateOrRelative(time_t epoch) { + if (epoch == 0 || !HalClock::isSynced()) { + return tr(STR_READING_STATS_UNKNOWN); + } + const time_t now = HalClock::now(); + if (now <= epoch) return "just now"; + const uint32_t delta = static_cast(now - epoch); + char buf[24]; + if (delta < 60) return "just now"; + if (delta < 3600) { + snprintf(buf, sizeof(buf), "%um ago", delta / 60); + return buf; + } + if (delta < 86400) { + snprintf(buf, sizeof(buf), "%uh ago", delta / 3600); + return buf; + } + const uint32_t days = delta / 86400; + if (days < 30) { + snprintf(buf, sizeof(buf), "%ud ago", days); + return buf; + } + // Past a month, the relative form ("60d ago") is noisier than a date. + struct tm t {}; + localtime_r(&epoch, &t); + snprintf(buf, sizeof(buf), "%04d-%02d-%02d", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday); + return buf; +} + +uint32_t secondsForDayIn(const std::vector& days, uint16_t dayIndex) { + if (dayIndex == 0) return 0; + auto it = std::lower_bound(days.begin(), days.end(), dayIndex, + [](const DayBucket& b, uint16_t v) { return b.dayIndex < v; }); + if (it != days.end() && it->dayIndex == dayIndex) return it->seconds; + return 0; +} + +} // namespace + +void ReadingStatsBookDetailActivity::onEnter() { + Activity::onEnter(); + requestUpdate(); +} + +void ReadingStatsBookDetailActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } +} + +void ReadingStatsBookDetailActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, /*hasBottomHints=*/true, /*hasSideHints=*/false); + const auto& store = READING_STATS; + const BookReadingStats* book = store.findBook(docId); + + renderer.clearScreen(); + + // Header: title up to ~28 chars (theme will further clip if the screen is + // narrow). Fallback to docId so we still produce a usable screen if the + // book's metadata was never recorded. + std::string headerTitle = (book && !book->title.empty()) ? book->title : docId; + if (headerTitle.size() > 28) { + headerTitle.resize(28); + headerTitle += "…"; + } + GUI.drawHeader(renderer, + Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, + headerTitle.c_str(), book && !book->author.empty() ? book->author.c_str() : nullptr); + + const int leftX = contentRect.x + metrics.verticalSpacing * 3; + const int valueX = contentRect.x + contentRect.width / 2; + const int lineH = renderer.getLineHeight(UI_10_FONT_ID); + const int rowStep = lineH + 2; + const int subHeaderHeight = lineH + 6; + int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + + auto drawSection = [&](const char* title) { + GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title); + y += subHeaderHeight + 2; + }; + auto drawRow = [&](const char* label, const std::string& value) { + renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); + renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); + y += rowStep; + }; + + if (!book) { + // The book may have been removed from the store between the list and + // the detail screen (e.g. a future "clear stats for this book" action). + // Show a placeholder rather than crash on a null deref. + drawRow("", tr(STR_READING_STATS_NO_DATA)); + } else { + drawSection(tr(STR_READING_STATS_TOTAL_TIME)); + drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(book->totalSeconds)); + drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(book->sessions)); + drawRow(tr(STR_READING_STATS_PAGES), std::to_string(book->pagesTurned)); + if (book->sessions > 0) { + drawRow(tr(STR_READING_STATS_AVG_SESSION), formatDuration(book->totalSeconds / book->sessions)); + } + char pctBuf[8]; + snprintf(pctBuf, sizeof(pctBuf), "%u%%", book->progress); + drawRow(tr(STR_READING_STATS_PROGRESS), pctBuf); + + drawSection(tr(STR_READING_STATS_FIRST_READ)); + drawRow(tr(STR_READING_STATS_FIRST_READ), formatDateOrRelative(book->firstReadEpoch)); + drawRow(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch)); + + // Per-book 30-day sparkline. Identical algorithm to the main screen but + // reads from this book's own day vector. Hidden when no wall-clocked day + // exists or the clock isn't synced — otherwise the bars would be + // meaningless ("we don't know what day this was"). + const uint16_t today = currentLocalDayIndex(); + if (today != 0 && !book->days.empty()) { + drawSection(tr(STR_READING_STATS_LAST_30D)); + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 38; + constexpr int kBarGap = 1; + const int sparkLeft = leftX; + const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; + const int sparkWidth = std::max(0, sparkRight - sparkLeft); + const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; + const int sparkOriginY = y; + + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + if (s > maxSeconds) maxSeconds = s; + } + + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, + sparkOriginY + kSparkHeight, true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + y += kSparkHeight + 6; + } + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.h b/src/activities/settings/ReadingStatsBookDetailActivity.h new file mode 100644 index 00000000..4309f3a0 --- /dev/null +++ b/src/activities/settings/ReadingStatsBookDetailActivity.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "activities/Activity.h" + +// Phase-2 sub-screen: per-book reading stats with a 30-day sparkline keyed on +// that book's own day buckets. Constructed with the docId of the book to +// display; resolved against ReadingStatsStore on each render so a session +// that finishes while this screen is open updates the next time we redraw. +class ReadingStatsBookDetailActivity final : public Activity { + public: + ReadingStatsBookDetailActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string docId) + : Activity("ReadingStatsBookDetail", renderer, mappedInput), docId(std::move(docId)) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + std::string docId; +}; diff --git a/src/activities/settings/ReadingStatsBookListActivity.cpp b/src/activities/settings/ReadingStatsBookListActivity.cpp new file mode 100644 index 00000000..161df141 --- /dev/null +++ b/src/activities/settings/ReadingStatsBookListActivity.cpp @@ -0,0 +1,118 @@ +#include "ReadingStatsBookListActivity.h" + +#include +#include + +#include +#include + +#include "MappedInputManager.h" +#include "ReadingStatsBookDetailActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { + +// Same compact format as the main stats screen — kept inline rather than +// shared via a header to keep this slice's footprint small. If a third +// caller appears we'll lift it into a util. +std::string formatDuration(uint32_t totalSeconds) { + const uint32_t h = totalSeconds / 3600; + const uint32_t m = (totalSeconds % 3600) / 60; + const uint32_t s = totalSeconds % 60; + char buf[24]; + if (h > 0) { + snprintf(buf, sizeof(buf), "%uh %02um", h, m); + } else if (m > 0) { + snprintf(buf, sizeof(buf), "%um %02us", m, s); + } else { + snprintf(buf, sizeof(buf), "%us", s); + } + return buf; +} + +} // namespace + +void ReadingStatsBookListActivity::rebuildSortedBooks() { + sortedBooks.clear(); + for (const auto& b : READING_STATS.getBooks()) { + sortedBooks.push_back(&b); + } + std::sort(sortedBooks.begin(), sortedBooks.end(), + [](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; }); +} + +void ReadingStatsBookListActivity::onEnter() { + Activity::onEnter(); + rebuildSortedBooks(); + if (selectedIndex >= static_cast(sortedBooks.size())) { + selectedIndex = 0; + } + requestUpdate(); +} + +void ReadingStatsBookListActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (!sortedBooks.empty()) { + buttonNavigator.onNextList(selectedIndex, static_cast(sortedBooks.size()), + [this]() { requestUpdate(); }); + buttonNavigator.onPreviousList(selectedIndex, static_cast(sortedBooks.size()), + [this]() { requestUpdate(); }); + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + const BookReadingStats* book = sortedBooks[selectedIndex]; + startActivityForResult( + std::make_unique(renderer, mappedInput, book->docId), + // After detail closes the underlying store hasn't changed (it's + // read-only), so just redraw — selectedIndex is preserved. + [this](const ActivityResult&) { requestUpdate(); }); + } + } +} + +void ReadingStatsBookListActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, /*hasBottomHints=*/true, /*hasSideHints=*/false); + + renderer.clearScreen(); + + GUI.drawHeader(renderer, + Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, + tr(STR_READING_STATS_BOOK_LIST), nullptr); + + const int contentTop = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = + contentRect.height - (metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 2); + + if (sortedBooks.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, contentTop + contentHeight / 2, tr(STR_READING_STATS_NO_DATA)); + } else { + GUI.drawList( + renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, + static_cast(sortedBooks.size()), selectedIndex, + [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; + }, + [this](int index) { + // Subtitle row: author, when known. Empty string is treated by the + // theme as "no subtitle" and the row collapses to a single line. + return sortedBooks[index]->author; + }, + nullptr, + [this](int index) { return formatDuration(sortedBooks[index]->totalSeconds); }, true); + } + + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), sortedBooks.empty() ? "" : tr(STR_SELECT), + sortedBooks.empty() ? "" : tr(STR_DIR_UP), sortedBooks.empty() ? "" : tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ReadingStatsBookListActivity.h b/src/activities/settings/ReadingStatsBookListActivity.h new file mode 100644 index 00000000..60c9072d --- /dev/null +++ b/src/activities/settings/ReadingStatsBookListActivity.h @@ -0,0 +1,28 @@ +#pragma once + +#include "ReadingStats.h" +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +// Phase-2 sub-screen: scrollable list of all books with recorded reading time. +// Sorted by total time descending so the most-read books are easiest to reach. +// Selecting a row pushes ReadingStatsBookDetailActivity. +class ReadingStatsBookListActivity final : public Activity { + public: + explicit ReadingStatsBookListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("ReadingStatsBookList", renderer, mappedInput) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + // Cached pointers into ReadingStatsStore::books, sorted by totalSeconds desc. + // Rebuilt on onEnter() so the list reflects the latest state even after a + // session has been recorded between visits. + std::vector sortedBooks; + ButtonNavigator buttonNavigator; + int selectedIndex = 0; + + void rebuildSortedBooks(); +}; From dddb3c1bcbb1db1a1034fd3f3d8f359d9ca5569d Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:14:51 +0200 Subject: [PATCH 04/14] clf --- .../ReadingStatsBookDetailActivity.cpp | 2 +- .../settings/ReadingStatsBookListActivity.cpp | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp index 7daea1ad..ef0eb50a 100644 --- a/src/activities/settings/ReadingStatsBookDetailActivity.cpp +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -55,7 +55,7 @@ std::string formatDateOrRelative(time_t epoch) { return buf; } // Past a month, the relative form ("60d ago") is noisier than a date. - struct tm t {}; + struct tm t{}; localtime_r(&epoch, &t); snprintf(buf, sizeof(buf), "%04d-%02d-%02d", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday); return buf; diff --git a/src/activities/settings/ReadingStatsBookListActivity.cpp b/src/activities/settings/ReadingStatsBookListActivity.cpp index 161df141..dcbef8f0 100644 --- a/src/activities/settings/ReadingStatsBookListActivity.cpp +++ b/src/activities/settings/ReadingStatsBookListActivity.cpp @@ -58,18 +58,15 @@ void ReadingStatsBookListActivity::loop() { } if (!sortedBooks.empty()) { - buttonNavigator.onNextList(selectedIndex, static_cast(sortedBooks.size()), - [this]() { requestUpdate(); }); - buttonNavigator.onPreviousList(selectedIndex, static_cast(sortedBooks.size()), - [this]() { requestUpdate(); }); + buttonNavigator.onNextList(selectedIndex, static_cast(sortedBooks.size()), [this]() { requestUpdate(); }); + buttonNavigator.onPreviousList(selectedIndex, static_cast(sortedBooks.size()), [this]() { requestUpdate(); }); if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { const BookReadingStats* book = sortedBooks[selectedIndex]; - startActivityForResult( - std::make_unique(renderer, mappedInput, book->docId), - // After detail closes the underlying store hasn't changed (it's - // read-only), so just redraw — selectedIndex is preserved. - [this](const ActivityResult&) { requestUpdate(); }); + startActivityForResult(std::make_unique(renderer, mappedInput, book->docId), + // After detail closes the underlying store hasn't changed (it's + // read-only), so just redraw — selectedIndex is preserved. + [this](const ActivityResult&) { requestUpdate(); }); } } } @@ -105,8 +102,7 @@ void ReadingStatsBookListActivity::render(RenderLock&&) { // theme as "no subtitle" and the row collapses to a single line. return sortedBooks[index]->author; }, - nullptr, - [this](int index) { return formatDuration(sortedBooks[index]->totalSeconds); }, true); + nullptr, [this](int index) { return formatDuration(sortedBooks[index]->totalSeconds); }, true); } const auto labels = From dcbe79aab2d95a09783512ffabcc91da8f80f172 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:25:06 +0200 Subject: [PATCH 05/14] Phase 4- Full format support --- src/activities/reader/MdReaderActivity.cpp | 16 +++++++++++++++- src/activities/reader/TxtReaderActivity.cpp | 18 +++++++++++++++++- src/activities/reader/XtcReaderActivity.cpp | 18 ++++++++++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 4cf07319..330f8958 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -16,10 +16,12 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "FinishedBookActivity.h" +#include "KOReaderDocumentId.h" #include "MappedInputManager.h" #include "MdReaderTocSelectionActivity.h" #include "ReaderActivity.h" #include "ReaderUtils.h" +#include "ReadingSessionTracker.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" @@ -60,6 +62,9 @@ void MdReaderActivity::onEnter() { const std::string txtCover = txtSidecar.empty() ? txt->getThumbBmpPath() : txtSidecar; RECENT_BOOKS.addBook(filePath, fileName, "", "", txtCover); + // Start the stats session. + globalReadingSessionTracker().begin(KOReaderDocumentId::calculateFromFilename(filePath), fileName, ""); + requestUpdate(); } @@ -218,6 +223,9 @@ void MdReaderActivity::scanHeadings() { void MdReaderActivity::onExit() { Activity::onExit(); + // Flush the stats session before tearing down the reader. + globalReadingSessionTracker().end(); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); pageOffsets.clear(); @@ -269,6 +277,7 @@ void MdReaderActivity::loop() { if (currentPage > 0) { currentPage--; currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]); + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } return; @@ -279,6 +288,7 @@ void MdReaderActivity::loop() { if (currentPage < totalPages - 1) { currentPage++; currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]); + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } else { saveProgress(); @@ -914,6 +924,7 @@ void MdReaderActivity::saveProgress() const { // 7-byte format matching TxtReaderActivity: page(2 bytes LE) + file offset(4 bytes LE) + overallPercent(1 byte) const size_t offset = (currentPage >= 0 && currentPage < static_cast(pageOffsets.size())) ? pageOffsets[currentPage] : 0; + const uint8_t percent = ReaderUtils::pageProgressPercentByte(currentPage, totalPages); uint8_t data[7]; data[0] = currentPage & 0xFF; data[1] = (currentPage >> 8) & 0xFF; @@ -921,9 +932,10 @@ void MdReaderActivity::saveProgress() const { data[3] = (offset >> 8) & 0xFF; data[4] = (offset >> 16) & 0xFF; data[5] = (offset >> 24) & 0xFF; - data[6] = ReaderUtils::pageProgressPercentByte(currentPage, totalPages); + data[6] = percent; f.write(data, 7); f.close(); + globalReadingSessionTracker().updateProgress(percent); } } @@ -1082,6 +1094,7 @@ void MdReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION ac if (currentPage < totalPages - 1) { currentPage++; currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]); + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } break; @@ -1089,6 +1102,7 @@ void MdReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION ac if (currentPage > 0) { currentPage--; currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]); + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } break; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 30c038fc..08fcc502 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -15,9 +15,11 @@ #include "CrossPointState.h" #include "FinishedBookActivity.h" #include "GlobalBookmarkIndex.h" +#include "KOReaderDocumentId.h" #include "MappedInputManager.h" #include "ReaderActivity.h" #include "ReaderUtils.h" +#include "ReadingSessionTracker.h" #include "RecentBooksStore.h" #include "StarredPagesActivity.h" #include "components/UITheme.h" @@ -124,6 +126,10 @@ void TxtReaderActivity::onEnter() { const std::string txtCover = txtSidecar.empty() ? txt->getThumbBmpPath() : txtSidecar; RECENT_BOOKS.addBook(filePath, fileName, "", "", txtCover); + // Start reading-stats session. Same filename-hash policy as EPUB so renamed + // files start fresh; author is unknown for plain TXT. + globalReadingSessionTracker().begin(KOReaderDocumentId::calculateFromFilename(filePath), fileName, ""); + // Trigger first update requestUpdate(); } @@ -131,6 +137,10 @@ void TxtReaderActivity::onEnter() { void TxtReaderActivity::onExit() { Activity::onExit(); + // Flush the stats session before tearing down the txt — same pattern as + // EpubReaderActivity::onExit(). + globalReadingSessionTracker().end(); + // Save bookmarks before exit bookmarkStore.save(); if (txt) { @@ -195,6 +205,7 @@ void TxtReaderActivity::loop() { ev.type == ButtonEventManager::PressType::Short) { if (currentPage > 0) { currentPage--; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } return; @@ -204,6 +215,7 @@ void TxtReaderActivity::loop() { ev.type == ButtonEventManager::PressType::Short) { if (currentPage < totalPages - 1) { currentPage++; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } else { launchFinishedBookFlow(); @@ -220,11 +232,13 @@ void TxtReaderActivity::loop() { if (prevTriggered) { if (currentPage > 0) { currentPage--; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } } else if (nextTriggered) { if (currentPage < totalPages - 1) { currentPage++; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } else { launchFinishedBookFlow(); @@ -515,6 +529,7 @@ void TxtReaderActivity::saveProgress() const { // 7-byte format: page(2 bytes LE) + file offset(4 bytes LE) + overallPercent(1 byte) // The offset lets drawCurrentPageToBuffer render without requiring index.bin. const size_t offset = (currentPage < static_cast(pageOffsets.size())) ? pageOffsets[currentPage] : 0; + const uint8_t percent = ReaderUtils::pageProgressPercentByte(currentPage, totalPages); uint8_t data[7]; data[0] = currentPage & 0xFF; data[1] = (currentPage >> 8) & 0xFF; @@ -522,9 +537,10 @@ void TxtReaderActivity::saveProgress() const { data[3] = (offset >> 8) & 0xFF; data[4] = (offset >> 16) & 0xFF; data[5] = (offset >> 24) & 0xFF; - data[6] = ReaderUtils::pageProgressPercentByte(currentPage, totalPages); + data[6] = percent; f.write(data, 7); f.close(); + globalReadingSessionTracker().updateProgress(percent); } } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 224596dc..a27840b4 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -17,9 +17,11 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "FinishedBookActivity.h" +#include "KOReaderDocumentId.h" #include "MappedInputManager.h" #include "ReaderActivity.h" #include "ReaderUtils.h" +#include "ReadingSessionTracker.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" #include "components/UITheme.h" @@ -48,6 +50,11 @@ void XtcReaderActivity::onEnter() { const std::string xtcCover = xtcSidecar.empty() ? xtc->getThumbBmpPath() : xtcSidecar; RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), "", xtcCover); + // Start the reading-stats session. XTC has real title/author from the + // file header so the per-book screen will look nicer than TXT/MD. + globalReadingSessionTracker().begin(KOReaderDocumentId::calculateFromFilename(xtc->getPath()), xtc->getTitle(), + xtc->getAuthor()); + // Trigger first update requestUpdate(); } @@ -55,6 +62,9 @@ void XtcReaderActivity::onEnter() { void XtcReaderActivity::onExit() { Activity::onExit(); + // Flush stats session before tearing down the XTC reader. + globalReadingSessionTracker().end(); + APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -182,10 +192,12 @@ void XtcReaderActivity::loop() { if (prevTriggered) { if (currentPage > 0) { currentPage--; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } } else if (nextTriggered) { currentPage++; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } } @@ -393,15 +405,17 @@ void XtcReaderActivity::renderPage() { void XtcReaderActivity::saveProgress() const { FsFile f; if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) { + const uint8_t percent = + ReaderUtils::pageProgressPercentByte(static_cast(currentPage), static_cast(xtc->getPageCount())); uint8_t data[5]; data[0] = currentPage & 0xFF; data[1] = (currentPage >> 8) & 0xFF; data[2] = (currentPage >> 16) & 0xFF; data[3] = (currentPage >> 24) & 0xFF; - data[4] = - ReaderUtils::pageProgressPercentByte(static_cast(currentPage), static_cast(xtc->getPageCount())); + data[4] = percent; f.write(data, 5); f.close(); + globalReadingSessionTracker().updateProgress(percent); } } From 34d977b50e3c2d2c58bc5293baf41fb100b42686 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:48:09 +0200 Subject: [PATCH 06/14] Phase 5 - card based layout --- lib/I18n/translations/english.yaml | 5 +- src/activities/reader/EpubReaderActivity.cpp | 13 ++ .../reader/EpubReaderMenuActivity.cpp | 4 + .../reader/EpubReaderMenuActivity.h | 3 +- .../settings/ReadingStatsActivity.cpp | 204 +++++++++--------- .../ReadingStatsBookDetailActivity.cpp | 139 ++++++------ src/components/CardLayout.cpp | 69 ++++++ src/components/CardLayout.h | 109 ++++++++++ 8 files changed, 382 insertions(+), 164 deletions(-) create mode 100644 src/components/CardLayout.cpp create mode 100644 src/components/CardLayout.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index f5884fa3..a797e8f9 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -539,13 +539,16 @@ STR_READING_STATS_NO_DATA: "No reading recorded yet" STR_READING_STATS_TOP_BOOKS: "Top books" STR_READING_STATS_STREAK: "Streak" STR_READING_STATS_LAST_30D: "Last 30 days" -STR_READING_STATS_DAYS_UNIT: "d" STR_READING_STATS_BOOK_LIST: "All books" STR_READING_STATS_FIRST_READ: "First read" STR_READING_STATS_AVG_SESSION: "Avg session" STR_READING_STATS_PROGRESS: "Progress" STR_READING_STATS_UNKNOWN: "—" STR_READING_STATS_LAST_READ: "Last read" +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_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/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 93149744..f6a4b84e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -39,6 +39,7 @@ #include "RecentBooksStore.h" #include "SdCardFontGlobals.h" #include "StarredPagesActivity.h" +#include "activities/settings/ReadingStatsBookDetailActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/ScreenshotUtil.h" @@ -723,6 +724,18 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction onGoHome(); return; } + case EpubReaderMenuActivity::MenuAction::READING_STATS: { + // Jump to this book's detail screen using the same filename-hash docId + // the session was opened with. The in-flight session's time isn't + // visible here — it lands in the store only when end() runs on reader + // exit. For a brand-new book that's never been finished a session yet + // the screen will show "no data"; that's accurate. + if (!epub) break; + startActivityForResult(std::make_unique( + renderer, mappedInput, KOReaderDocumentId::calculateFromFilename(epub->getPath())), + [this](const ActivityResult&) { requestUpdate(); }); + break; + } case EpubReaderMenuActivity::MenuAction::MARK_AS_READ: { if (!epub) { break; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 8d763cb3..0ce18305 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -252,6 +252,8 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa // --- Tools --- menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_TOOLS)); + menuItems.push_back(SettingInfo::Action(StrId::STR_READING_STATS_FOR_THIS_BOOK, SettingAction::None) + .withSubmenu(StrId::STR_READER_TOOLS)); menuItems.push_back( SettingInfo::Action(StrId::STR_MARK_AS_READ, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS)); menuItems.push_back( @@ -307,6 +309,8 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId return MenuAction::RENDER_BENCHMARK; case StrId::STR_GO_HOME_BUTTON: return MenuAction::GO_HOME; + case StrId::STR_READING_STATS_FOR_THIS_BOOK: + return MenuAction::READING_STATS; default: return MenuAction::NONE; } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 23fd41be..ed713e0a 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -30,7 +30,8 @@ class EpubReaderMenuActivity final : public MenuListActivity { STAR_PAGE, MARK_AS_READ, DELETE_CACHE, - RENDER_BENCHMARK + RENDER_BENCHMARK, + READING_STATS, }; explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp index ee102288..00115b42 100644 --- a/src/activities/settings/ReadingStatsActivity.cpp +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -5,20 +5,22 @@ #include #include +#include #include +#include #include #include "MappedInputManager.h" #include "ReadingSessionTracker.h" #include "ReadingStats.h" #include "ReadingStatsBookListActivity.h" +#include "components/CardLayout.h" #include "components/UITheme.h" #include "fontIds.h" namespace { -// "1h 23m" / "23m 45s" / "12s" — Phase 1 keeps it compact so a long row fits -// the right column without truncation on the X3's narrow screen. +// "1h 23m" / "23m 45s" / "12s" — compact so a long row fits on the X3. std::string formatDuration(uint32_t totalSeconds) { const uint32_t h = totalSeconds / 3600; const uint32_t m = (totalSeconds % 3600) / 60; @@ -34,6 +36,19 @@ std::string formatDuration(uint32_t totalSeconds) { return buf; } +// Pages per minute, rounded to 1 decimal. Returns "—" when there's not enough +// data (fewer than a minute total) so we don't display "120.0 ppm" when only +// a handful of seconds have been recorded. +std::string formatPagesPerMin(uint32_t pages, uint32_t seconds) { + if (seconds < 60 || pages == 0) { + return tr(STR_READING_STATS_UNKNOWN); + } + const float ppm = (pages * 60.0f) / seconds; + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f", ppm); + return buf; +} + } // namespace void ReadingStatsActivity::onEnter() { @@ -53,8 +68,7 @@ void ReadingStatsActivity::loop() { [this](const ActivityResult&) { requestUpdate(); }); return; } - // If a reading session happens to be live (e.g. a future entry point lets - // the user pop this screen mid-read), tick at most once per second so + // If a reading session happens to be live, tick at most once per second so // "this session" moves visibly without hammering the e-ink panel. if (globalReadingSessionTracker().isActive()) { const uint32_t now = millis(); @@ -75,100 +89,87 @@ void ReadingStatsActivity::render(RenderLock&&) { Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, tr(STR_READING_STATS), nullptr); - const int leftX = contentRect.x + metrics.verticalSpacing * 3; - const int valueX = contentRect.x + contentRect.width / 2; - const int lineH = renderer.getLineHeight(UI_10_FONT_ID); - const int rowStep = lineH + 2; - const int subHeaderHeight = lineH + 6; - int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - - auto drawSection = [&](const char* title) { - GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title); - y += subHeaderHeight + 2; - }; - auto drawRow = [&](const char* label, const std::string& value) { - renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); - renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); - y += rowStep; - }; + const int startY = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + CardLayout::Config cfg; + cfg.outerMarginX = metrics.verticalSpacing * 2; + CardLayout layout(renderer, contentRect, startY, cfg); const auto& store = READING_STATS; auto& tracker = globalReadingSessionTracker(); - // ---- Live session (only if currently reading) ---- + // ---- Live session card ---- if (tracker.isActive()) { - drawSection(tr(STR_READING_STATS_CURRENT_SESSION)); - drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds())); - drawRow(tr(STR_READING_STATS_PAGES), std::to_string(tracker.getLivePages())); + layout.card(tr(STR_READING_STATS_CURRENT_SESSION), [&](CardLayout::Body& b) { + b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds())); + b.rowLR(tr(STR_READING_STATS_PAGES), std::to_string(tracker.getLivePages())); + }); } - // ---- All time ---- - drawSection(tr(STR_READING_STATS_TOTAL_TIME)); - if (store.getGlobalTotalSeconds() == 0) { - drawRow("", tr(STR_READING_STATS_NO_DATA)); - } else { - drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds())); - drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(store.getGlobalTotalSessions())); - drawRow(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned())); - drawRow(tr(STR_READING_STATS_BOOKS), std::to_string(store.getBookCount())); - - // Streaks: only meaningful when at least one wall-clocked session exists. - if (!store.getGlobalDays().empty()) { - const uint16_t today = currentLocalDayIndex(); - const uint16_t current = store.computeCurrentStreak(today); - const uint16_t longest = store.computeLongestStreak(); - char buf[24]; - snprintf(buf, sizeof(buf), "%u%s / %u%s", current, tr(STR_READING_STATS_DAYS_UNIT), longest, - tr(STR_READING_STATS_DAYS_UNIT)); - drawRow(tr(STR_READING_STATS_STREAK), buf); + // ---- All-time card ---- + layout.card(tr(STR_READING_STATS_TOTAL_TIME), [&](CardLayout::Body& b) { + if (store.getGlobalTotalSeconds() == 0) { + b.centeredMessage(tr(STR_READING_STATS_NO_DATA)); + return; } - } - // ---- 30-day sparkline ---- - // Renders one bar per day for the last 30 local days ending at "today". - // Height of each bar is proportional to that day's seconds vs. the maximum - // seen in the window. Days with no reading get a flat 1px baseline so the - // gap pattern stays visible. Drawn only when the clock is synced — without - // it we have no "today" to anchor the window against. + // 4-cell stat grid: sessions / books / current streak / longest streak. + // Streaks read "—" when the clock has never been wall-anchored. + const uint16_t today = currentLocalDayIndex(); + const bool haveStreak = today != 0 && !store.getGlobalDays().empty(); + const std::string curStreak = + haveStreak ? std::to_string(store.computeCurrentStreak(today)) : std::string(tr(STR_READING_STATS_UNKNOWN)); + const std::string maxStreak = + haveStreak ? std::to_string(store.computeLongestStreak()) : std::string(tr(STR_READING_STATS_UNKNOWN)); + b.statGrid({{{std::to_string(store.getGlobalTotalSessions()), tr(STR_READING_STATS_SESSIONS)}, + {std::to_string(store.getBookCount()), tr(STR_READING_STATS_BOOKS)}, + {curStreak, tr(STR_READING_STATS_STREAK)}, + {maxStreak, tr(STR_READING_STATS_LONGEST)}}}); + + b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds())); + 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())); + }); + + // ---- 30-day sparkline card ---- const uint16_t today = currentLocalDayIndex(); if (today != 0 && !store.getGlobalDays().empty()) { - drawSection(tr(STR_READING_STATS_LAST_30D)); - constexpr int kSparkDays = 30; - constexpr int kSparkHeight = 38; - constexpr int kBarGap = 1; - const int sparkLeft = leftX; - const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; - const int sparkWidth = std::max(0, sparkRight - sparkLeft); - const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); - const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); - const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; - const int sparkOriginY = y; + layout.card(tr(STR_READING_STATS_LAST_30D), [&](CardLayout::Body& b) { + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 32; + constexpr int kBarGap = 1; + const int innerWidth = b.innerWidth(); + const int innerLeft = b.innerLeft(); + const int barWidth = std::max(2, (innerWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = innerLeft + (innerWidth - totalSpan) / 2; + const int sparkOriginY = b.currentY(); - uint32_t maxSeconds = 1; - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = - (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; - const uint32_t s = store.getSecondsForDay(d); - if (s > maxSeconds) maxSeconds = s; - } + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = store.getSecondsForDay(d); + if (s > maxSeconds) maxSeconds = s; + } - // Baseline (axis) — 1px line under the bars so the visual grouping reads - // as a chart even when most days are empty. - renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, sparkOriginY + kSparkHeight, - true); - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = - (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; - const uint32_t s = store.getSecondsForDay(d); - const int barX = sparkOriginX + i * (barWidth + kBarGap); - // 1px minimum so empty days still tick on the axis. - const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); - renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); - } - y += kSparkHeight + 6; + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, + sparkOriginY + kSparkHeight, true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = store.getSecondsForDay(d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + b.advance(kSparkHeight + 2); + }); } - // ---- Top books (up to 3 by total time) ---- + // ---- Top books card ---- if (!store.getBooks().empty()) { std::vector sorted; sorted.reserve(store.getBooks().size()); @@ -176,20 +177,31 @@ void ReadingStatsActivity::render(RenderLock&&) { std::sort(sorted.begin(), sorted.end(), [](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; }); - drawSection(tr(STR_READING_STATS_TOP_BOOKS)); - const size_t shown = std::min(sorted.size(), 3); - for (size_t i = 0; i < shown; ++i) { - const auto* b = sorted[i]; - // Use the title when known; fall back to docId so the row is never - // empty even before metadata is recorded. - std::string label = b->title.empty() ? b->docId : b->title; - // Trim to fit; 22 chars keeps it in the left column at UI_10. - if (label.size() > 22) { - label.resize(22); - label += "…"; + layout.card(tr(STR_READING_STATS_TOP_BOOKS), [&](CardLayout::Body& b) { + const int ellipsisWidth = renderer.getTextWidth(UI_10_FONT_ID, "…"); + constexpr int kTitleGap = 8; + const size_t shown = std::min(sorted.size(), 3); + const int innerLeft = b.innerLeft(); + const int innerRight = b.innerRight(); + for (size_t i = 0; i < shown; ++i) { + const auto* bk = sorted[i]; + const std::string time = formatDuration(bk->totalSeconds); + const int timeWidth = renderer.getTextWidth(UI_10_FONT_ID, time.c_str()); + renderer.drawText(UI_10_FONT_ID, innerRight - timeWidth, b.currentY(), time.c_str()); + + std::string label = bk->title.empty() ? bk->docId : bk->title; + const int maxLabelWidth = (innerRight - timeWidth - kTitleGap) - innerLeft; + if (maxLabelWidth > 0 && renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) > maxLabelWidth) { + while (!label.empty() && + renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + ellipsisWidth > maxLabelWidth) { + label.pop_back(); + } + label += "…"; + } + renderer.drawText(UI_10_FONT_ID, innerLeft, b.currentY(), label.c_str(), true, EpdFontFamily::BOLD); + b.advance(b.rowStep()); } - drawRow(label.c_str(), formatDuration(b->totalSeconds)); - } + }); } const char* btn2 = store.getBooks().empty() ? "" : tr(STR_READING_STATS_BOOK_LIST); diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp index ef0eb50a..fad0d9d8 100644 --- a/src/activities/settings/ReadingStatsBookDetailActivity.cpp +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -5,11 +5,14 @@ #include #include +#include #include #include +#include #include "MappedInputManager.h" #include "ReadingStats.h" +#include "components/CardLayout.h" #include "components/UITheme.h" #include "fontIds.h" @@ -61,6 +64,16 @@ std::string formatDateOrRelative(time_t epoch) { return buf; } +std::string formatPagesPerMin(uint32_t pages, uint32_t seconds) { + if (seconds < 60 || pages == 0) { + return tr(STR_READING_STATS_UNKNOWN); + } + const float ppm = (pages * 60.0f) / seconds; + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f", ppm); + return buf; +} + uint32_t secondsForDayIn(const std::vector& days, uint16_t dayIndex) { if (dayIndex == 0) return 0; auto it = std::lower_bound(days.begin(), days.end(), dayIndex, @@ -91,9 +104,8 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) { renderer.clearScreen(); - // Header: title up to ~28 chars (theme will further clip if the screen is - // narrow). Fallback to docId so we still produce a usable screen if the - // book's metadata was never recorded. + // Header — title (truncated) and author. Title fallback to docId so we + // still produce a usable screen if the book's metadata was never recorded. std::string headerTitle = (book && !book->title.empty()) ? book->title : docId; if (headerTitle.size() > 28) { headerTitle.resize(28); @@ -103,83 +115,78 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) { Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, headerTitle.c_str(), book && !book->author.empty() ? book->author.c_str() : nullptr); - const int leftX = contentRect.x + metrics.verticalSpacing * 3; - const int valueX = contentRect.x + contentRect.width / 2; - const int lineH = renderer.getLineHeight(UI_10_FONT_ID); - const int rowStep = lineH + 2; - const int subHeaderHeight = lineH + 6; - int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - - auto drawSection = [&](const char* title) { - GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title); - y += subHeaderHeight + 2; - }; - auto drawRow = [&](const char* label, const std::string& value) { - renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); - renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); - y += rowStep; - }; + const int startY = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + CardLayout::Config cfg; + cfg.outerMarginX = metrics.verticalSpacing * 2; + CardLayout layout(renderer, contentRect, startY, cfg); if (!book) { // The book may have been removed from the store between the list and // the detail screen (e.g. a future "clear stats for this book" action). // Show a placeholder rather than crash on a null deref. - drawRow("", tr(STR_READING_STATS_NO_DATA)); + layout.card(nullptr, [](CardLayout::Body& b) { b.centeredMessage(tr(STR_READING_STATS_NO_DATA)); }); } else { - drawSection(tr(STR_READING_STATS_TOTAL_TIME)); - drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(book->totalSeconds)); - drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(book->sessions)); - drawRow(tr(STR_READING_STATS_PAGES), std::to_string(book->pagesTurned)); - if (book->sessions > 0) { - drawRow(tr(STR_READING_STATS_AVG_SESSION), formatDuration(book->totalSeconds / book->sessions)); - } + // ---- Summary card: 4-cell grid (sessions / pages / avg / progress) ---- + const std::string avgValue = book->sessions > 0 ? formatDuration(book->totalSeconds / book->sessions) + : std::string(tr(STR_READING_STATS_UNKNOWN)); char pctBuf[8]; snprintf(pctBuf, sizeof(pctBuf), "%u%%", book->progress); - drawRow(tr(STR_READING_STATS_PROGRESS), pctBuf); + const std::string pctStr(pctBuf); + layout.card(nullptr, [&](CardLayout::Body& b) { + b.statGrid({{{std::to_string(book->sessions), tr(STR_READING_STATS_SESSIONS)}, + {std::to_string(book->pagesTurned), tr(STR_READING_STATS_PAGES)}, + {avgValue, tr(STR_READING_STATS_AVG_SESSION)}, + {pctStr, tr(STR_READING_STATS_PROGRESS)}}}); + }); - drawSection(tr(STR_READING_STATS_FIRST_READ)); - drawRow(tr(STR_READING_STATS_FIRST_READ), formatDateOrRelative(book->firstReadEpoch)); - drawRow(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch)); + // ---- Time card ---- + 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)); + }); - // Per-book 30-day sparkline. Identical algorithm to the main screen but - // reads from this book's own day vector. Hidden when no wall-clocked day - // exists or the clock isn't synced — otherwise the bars would be - // meaningless ("we don't know what day this was"). + // ---- 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)); + }); + + // ---- Per-book sparkline (only when clock-anchored data exists) ---- const uint16_t today = currentLocalDayIndex(); if (today != 0 && !book->days.empty()) { - drawSection(tr(STR_READING_STATS_LAST_30D)); - constexpr int kSparkDays = 30; - constexpr int kSparkHeight = 38; - constexpr int kBarGap = 1; - const int sparkLeft = leftX; - const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; - const int sparkWidth = std::max(0, sparkRight - sparkLeft); - const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); - const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); - const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; - const int sparkOriginY = y; + layout.card(tr(STR_READING_STATS_LAST_30D), [&](CardLayout::Body& b) { + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 32; + constexpr int kBarGap = 1; + const int innerWidth = b.innerWidth(); + const int innerLeft = b.innerLeft(); + const int barWidth = std::max(2, (innerWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = innerLeft + (innerWidth - totalSpan) / 2; + const int sparkOriginY = b.currentY(); - uint32_t maxSeconds = 1; - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) - ? static_cast(today - (kSparkDays - 1 - i)) - : 0; - const uint32_t s = secondsForDayIn(book->days, d); - if (s > maxSeconds) maxSeconds = s; - } + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + if (s > maxSeconds) maxSeconds = s; + } - renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, - sparkOriginY + kSparkHeight, true); - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) - ? static_cast(today - (kSparkDays - 1 - i)) - : 0; - const uint32_t s = secondsForDayIn(book->days, d); - const int barX = sparkOriginX + i * (barWidth + kBarGap); - const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); - renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); - } - y += kSparkHeight + 6; + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, + sparkOriginY + kSparkHeight, true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + b.advance(kSparkHeight + 2); + }); } } diff --git a/src/components/CardLayout.cpp b/src/components/CardLayout.cpp new file mode 100644 index 00000000..0f3a2fc8 --- /dev/null +++ b/src/components/CardLayout.cpp @@ -0,0 +1,69 @@ +#include "CardLayout.h" + +#include + +#include "fontIds.h" + +CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg) + : renderer_(renderer), contentRect_(contentRect), cfg_(cfg), y_(startY) { + cardLeft_ = contentRect.x + cfg_.outerMarginX; + cardWidth_ = contentRect.width - cfg_.outerMarginX * 2; + innerLeft_ = cardLeft_ + cfg_.innerPadX; + innerRight_ = cardLeft_ + cardWidth_ - cfg_.innerPadX; + innerWidth_ = innerRight_ - innerLeft_; + lineH_ = renderer_.getLineHeight(UI_10_FONT_ID); + rowStep_ = lineH_ + 2; + titleH_ = lineH_ + 2; +} + +void CardLayout::card(const char* title, const std::function& bodyFn) { + const int top = y_; + const int titleBlock = title ? titleH_ + cfg_.titleGap : 0; + const int bodyTop = top + cfg_.innerPadY + titleBlock; + int innerY = bodyTop; + + // Body draws directly into the frame buffer; we measure how far it + // advanced so the rounded border can be sized exactly to the content. + Body body(*this, innerY); + bodyFn(body); + + const int bodyHeight = innerY - bodyTop; + const int cardHeight = cfg_.innerPadY + titleBlock + bodyHeight + cfg_.innerPadY; + renderer_.drawRoundedRect(cardLeft_, top, cardWidth_, cardHeight, 1, cfg_.radius, true); + if (title) { + renderer_.drawCenteredText(UI_10_FONT_ID, top + cfg_.innerPadY, title, true, EpdFontFamily::BOLD); + } + y_ = top + cardHeight + cfg_.cardSpacing; +} + +void CardLayout::Body::rowLR(const char* label, const std::string& value) { + layout.renderer_.drawText(UI_10_FONT_ID, layout.innerLeft_, innerY, label, true, EpdFontFamily::BOLD); + const int vw = layout.renderer_.getTextWidth(UI_10_FONT_ID, value.c_str()); + layout.renderer_.drawText(UI_10_FONT_ID, layout.innerRight_ - vw, innerY, value.c_str()); + innerY += layout.rowStep_; +} + +void CardLayout::Body::statGrid(const std::array, 4>& cells) { + const int cellW = layout.innerWidth_ / 4; + const int valueY = innerY; + const int labelY = innerY + layout.lineH_ + 2; + for (int i = 0; i < 4; ++i) { + const auto& [value, label] = cells[i]; + const int cellCenterX = layout.innerLeft_ + cellW * i + cellW / 2; + const int vw = layout.renderer_.getTextWidth(UI_12_FONT_ID, value.c_str(), EpdFontFamily::BOLD); + const int lw = layout.renderer_.getTextWidth(UI_10_FONT_ID, label); + layout.renderer_.drawText(UI_12_FONT_ID, cellCenterX - vw / 2, valueY, value.c_str(), true, EpdFontFamily::BOLD); + layout.renderer_.drawText(UI_10_FONT_ID, cellCenterX - lw / 2, labelY, label); + if (i < 3) { + const int divX = layout.innerLeft_ + cellW * (i + 1); + layout.renderer_.drawLine(divX, valueY - 2, divX, labelY + layout.lineH_, true); + } + } + innerY = labelY + layout.lineH_ + 4; +} + +void CardLayout::Body::centeredMessage(const char* msg) { + const int mw = layout.renderer_.getTextWidth(UI_10_FONT_ID, msg); + layout.renderer_.drawText(UI_10_FONT_ID, layout.innerLeft_ + (layout.innerWidth_ - mw) / 2, innerY, msg); + innerY += layout.rowStep_; +} diff --git a/src/components/CardLayout.h b/src/components/CardLayout.h new file mode 100644 index 00000000..44ca88bc --- /dev/null +++ b/src/components/CardLayout.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include + +#include "components/themes/BaseTheme.h" // for Rect + +class GfxRenderer; + +// Reusable "boxed card" layout primitive for stat-style screens. +// +// A CardLayout owns the vertical cursor on a content rect and renders a +// stack of rounded-border cards. Each card has an optional title and a body +// composed by the caller via a lambda; the body lambda can call helpers on +// the CardBody it receives to draw label/value rows or a 4-cell stat grid. +// +// Typical usage (Reading Stats screen): +// +// CardLayout layout(renderer, contentRect, startY); +// layout.card("Total time", [&](CardLayout::Body& b) { +// b.rowLR("Sessions", "12"); +// b.rowLR("Pages", "248"); +// }); +// layout.card(nullptr, [&](CardLayout::Body& b) { +// b.statGrid({{ {"3", "Sess"}, {"7", "Books"}, {"4", "Streak"}, {"9", "Best"} }}); +// }); +// +// Design notes +// - Title-less cards (title == nullptr) are used as "hero" panels where +// the grid is itself the headline. +// - Card height is computed automatically by tracking how far the body +// lambda advanced its internal y cursor. The rounded rect is drawn +// LAST so it sits cleanly around the content (it can't clip text +// because content padding is fixed at construction time). +// - All values use UI_10 font; grid values use UI_12 bold. This is +// consistent across stats screens; if a future caller needs another +// font scale we'll add an optional config struct. +class CardLayout { + public: + struct Config { + int radius = 4; + int innerPadX = 10; + int innerPadY = 6; + int titleGap = 4; + int cardSpacing = 6; + // Horizontal margin between the content rect and the card's outer + // bounds. Defaults to 0; pass theme.verticalSpacing * 2 to line up + // with the rest of the system UI on a given theme. + int outerMarginX = 0; + }; + + // Inner-card drawing context handed to card body lambdas. Tracks the + // running y cursor inside the card and exposes the same content geometry + // (innerLeft / innerRight / innerWidth) needed for custom layouts. + class Body { + friend class CardLayout; + const CardLayout& layout; + int& innerY; + Body(const CardLayout& layout, int& innerY) : layout(layout), innerY(innerY) {} + + public: + int currentY() const { return innerY; } + void advance(int dy) { innerY += dy; } + int innerLeft() const { return layout.innerLeft_; } + int innerRight() const { return layout.innerRight_; } + int innerWidth() const { return layout.innerWidth_; } + int lineHeight() const { return layout.lineH_; } + int rowStep() const { return layout.rowStep_; } + + // Label (bold) left, value right-aligned at innerRight. + void rowLR(const char* label, const std::string& value); + + // 4-cell stat grid: large bold value above small label, three 1-px + // dividers between cells. Advances y by ~2.5 line heights. + void statGrid(const std::array, 4>& cells); + + // Centered single-line message (e.g. "No data yet" placeholder). + void centeredMessage(const char* msg); + }; + + CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg = {}); + + // Render a single card. `bodyFn` receives a `Body&` and may call its + // helpers in any order; the card auto-sizes to whatever the body draws. + // Pass `title == nullptr` for an untitled hero card. + void card(const char* title, const std::function& bodyFn); + + // Where the next card would start. Useful for callers that want to know + // how much vertical space they've consumed (e.g. to decide whether to + // skip a later section that wouldn't fit). + int cursorY() const { return y_; } + + private: + GfxRenderer& renderer_; + Rect contentRect_; + Config cfg_; + + int cardLeft_; + int cardWidth_; + int innerLeft_; + int innerRight_; + int innerWidth_; + int lineH_; + int rowStep_; + int titleH_; + int y_; +}; From b074350d3049addc41c4f9edb02238d489fb9695 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:51:31 +0200 Subject: [PATCH 07/14] Phase 5 - card based layout --- lib/I18n/translations/english.yaml | 5 +- src/activities/reader/EpubReaderActivity.cpp | 13 ++ .../reader/EpubReaderMenuActivity.cpp | 4 + .../reader/EpubReaderMenuActivity.h | 3 +- .../settings/ReadingStatsActivity.cpp | 204 +++++++++--------- .../ReadingStatsBookDetailActivity.cpp | 139 ++++++------ src/components/CardLayout.cpp | 69 ++++++ src/components/CardLayout.h | 109 ++++++++++ 8 files changed, 382 insertions(+), 164 deletions(-) create mode 100644 src/components/CardLayout.cpp create mode 100644 src/components/CardLayout.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index f5884fa3..a797e8f9 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -539,13 +539,16 @@ STR_READING_STATS_NO_DATA: "No reading recorded yet" STR_READING_STATS_TOP_BOOKS: "Top books" STR_READING_STATS_STREAK: "Streak" STR_READING_STATS_LAST_30D: "Last 30 days" -STR_READING_STATS_DAYS_UNIT: "d" STR_READING_STATS_BOOK_LIST: "All books" STR_READING_STATS_FIRST_READ: "First read" STR_READING_STATS_AVG_SESSION: "Avg session" STR_READING_STATS_PROGRESS: "Progress" STR_READING_STATS_UNKNOWN: "—" STR_READING_STATS_LAST_READ: "Last read" +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_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/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 93149744..f6a4b84e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -39,6 +39,7 @@ #include "RecentBooksStore.h" #include "SdCardFontGlobals.h" #include "StarredPagesActivity.h" +#include "activities/settings/ReadingStatsBookDetailActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/ScreenshotUtil.h" @@ -723,6 +724,18 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction onGoHome(); return; } + case EpubReaderMenuActivity::MenuAction::READING_STATS: { + // Jump to this book's detail screen using the same filename-hash docId + // the session was opened with. The in-flight session's time isn't + // visible here — it lands in the store only when end() runs on reader + // exit. For a brand-new book that's never been finished a session yet + // the screen will show "no data"; that's accurate. + if (!epub) break; + startActivityForResult(std::make_unique( + renderer, mappedInput, KOReaderDocumentId::calculateFromFilename(epub->getPath())), + [this](const ActivityResult&) { requestUpdate(); }); + break; + } case EpubReaderMenuActivity::MenuAction::MARK_AS_READ: { if (!epub) { break; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 8d763cb3..0ce18305 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -252,6 +252,8 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa // --- Tools --- menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_TOOLS)); + menuItems.push_back(SettingInfo::Action(StrId::STR_READING_STATS_FOR_THIS_BOOK, SettingAction::None) + .withSubmenu(StrId::STR_READER_TOOLS)); menuItems.push_back( SettingInfo::Action(StrId::STR_MARK_AS_READ, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS)); menuItems.push_back( @@ -307,6 +309,8 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId return MenuAction::RENDER_BENCHMARK; case StrId::STR_GO_HOME_BUTTON: return MenuAction::GO_HOME; + case StrId::STR_READING_STATS_FOR_THIS_BOOK: + return MenuAction::READING_STATS; default: return MenuAction::NONE; } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 23fd41be..ed713e0a 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -30,7 +30,8 @@ class EpubReaderMenuActivity final : public MenuListActivity { STAR_PAGE, MARK_AS_READ, DELETE_CACHE, - RENDER_BENCHMARK + RENDER_BENCHMARK, + READING_STATS, }; explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp index ee102288..00115b42 100644 --- a/src/activities/settings/ReadingStatsActivity.cpp +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -5,20 +5,22 @@ #include #include +#include #include +#include #include #include "MappedInputManager.h" #include "ReadingSessionTracker.h" #include "ReadingStats.h" #include "ReadingStatsBookListActivity.h" +#include "components/CardLayout.h" #include "components/UITheme.h" #include "fontIds.h" namespace { -// "1h 23m" / "23m 45s" / "12s" — Phase 1 keeps it compact so a long row fits -// the right column without truncation on the X3's narrow screen. +// "1h 23m" / "23m 45s" / "12s" — compact so a long row fits on the X3. std::string formatDuration(uint32_t totalSeconds) { const uint32_t h = totalSeconds / 3600; const uint32_t m = (totalSeconds % 3600) / 60; @@ -34,6 +36,19 @@ std::string formatDuration(uint32_t totalSeconds) { return buf; } +// Pages per minute, rounded to 1 decimal. Returns "—" when there's not enough +// data (fewer than a minute total) so we don't display "120.0 ppm" when only +// a handful of seconds have been recorded. +std::string formatPagesPerMin(uint32_t pages, uint32_t seconds) { + if (seconds < 60 || pages == 0) { + return tr(STR_READING_STATS_UNKNOWN); + } + const float ppm = (pages * 60.0f) / seconds; + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f", ppm); + return buf; +} + } // namespace void ReadingStatsActivity::onEnter() { @@ -53,8 +68,7 @@ void ReadingStatsActivity::loop() { [this](const ActivityResult&) { requestUpdate(); }); return; } - // If a reading session happens to be live (e.g. a future entry point lets - // the user pop this screen mid-read), tick at most once per second so + // If a reading session happens to be live, tick at most once per second so // "this session" moves visibly without hammering the e-ink panel. if (globalReadingSessionTracker().isActive()) { const uint32_t now = millis(); @@ -75,100 +89,87 @@ void ReadingStatsActivity::render(RenderLock&&) { Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, tr(STR_READING_STATS), nullptr); - const int leftX = contentRect.x + metrics.verticalSpacing * 3; - const int valueX = contentRect.x + contentRect.width / 2; - const int lineH = renderer.getLineHeight(UI_10_FONT_ID); - const int rowStep = lineH + 2; - const int subHeaderHeight = lineH + 6; - int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - - auto drawSection = [&](const char* title) { - GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title); - y += subHeaderHeight + 2; - }; - auto drawRow = [&](const char* label, const std::string& value) { - renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); - renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); - y += rowStep; - }; + const int startY = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + CardLayout::Config cfg; + cfg.outerMarginX = metrics.verticalSpacing * 2; + CardLayout layout(renderer, contentRect, startY, cfg); const auto& store = READING_STATS; auto& tracker = globalReadingSessionTracker(); - // ---- Live session (only if currently reading) ---- + // ---- Live session card ---- if (tracker.isActive()) { - drawSection(tr(STR_READING_STATS_CURRENT_SESSION)); - drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds())); - drawRow(tr(STR_READING_STATS_PAGES), std::to_string(tracker.getLivePages())); + layout.card(tr(STR_READING_STATS_CURRENT_SESSION), [&](CardLayout::Body& b) { + b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds())); + b.rowLR(tr(STR_READING_STATS_PAGES), std::to_string(tracker.getLivePages())); + }); } - // ---- All time ---- - drawSection(tr(STR_READING_STATS_TOTAL_TIME)); - if (store.getGlobalTotalSeconds() == 0) { - drawRow("", tr(STR_READING_STATS_NO_DATA)); - } else { - drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds())); - drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(store.getGlobalTotalSessions())); - drawRow(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned())); - drawRow(tr(STR_READING_STATS_BOOKS), std::to_string(store.getBookCount())); - - // Streaks: only meaningful when at least one wall-clocked session exists. - if (!store.getGlobalDays().empty()) { - const uint16_t today = currentLocalDayIndex(); - const uint16_t current = store.computeCurrentStreak(today); - const uint16_t longest = store.computeLongestStreak(); - char buf[24]; - snprintf(buf, sizeof(buf), "%u%s / %u%s", current, tr(STR_READING_STATS_DAYS_UNIT), longest, - tr(STR_READING_STATS_DAYS_UNIT)); - drawRow(tr(STR_READING_STATS_STREAK), buf); + // ---- All-time card ---- + layout.card(tr(STR_READING_STATS_TOTAL_TIME), [&](CardLayout::Body& b) { + if (store.getGlobalTotalSeconds() == 0) { + b.centeredMessage(tr(STR_READING_STATS_NO_DATA)); + return; } - } - // ---- 30-day sparkline ---- - // Renders one bar per day for the last 30 local days ending at "today". - // Height of each bar is proportional to that day's seconds vs. the maximum - // seen in the window. Days with no reading get a flat 1px baseline so the - // gap pattern stays visible. Drawn only when the clock is synced — without - // it we have no "today" to anchor the window against. + // 4-cell stat grid: sessions / books / current streak / longest streak. + // Streaks read "—" when the clock has never been wall-anchored. + const uint16_t today = currentLocalDayIndex(); + const bool haveStreak = today != 0 && !store.getGlobalDays().empty(); + const std::string curStreak = + haveStreak ? std::to_string(store.computeCurrentStreak(today)) : std::string(tr(STR_READING_STATS_UNKNOWN)); + const std::string maxStreak = + haveStreak ? std::to_string(store.computeLongestStreak()) : std::string(tr(STR_READING_STATS_UNKNOWN)); + b.statGrid({{{std::to_string(store.getGlobalTotalSessions()), tr(STR_READING_STATS_SESSIONS)}, + {std::to_string(store.getBookCount()), tr(STR_READING_STATS_BOOKS)}, + {curStreak, tr(STR_READING_STATS_STREAK)}, + {maxStreak, tr(STR_READING_STATS_LONGEST)}}}); + + b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds())); + 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())); + }); + + // ---- 30-day sparkline card ---- const uint16_t today = currentLocalDayIndex(); if (today != 0 && !store.getGlobalDays().empty()) { - drawSection(tr(STR_READING_STATS_LAST_30D)); - constexpr int kSparkDays = 30; - constexpr int kSparkHeight = 38; - constexpr int kBarGap = 1; - const int sparkLeft = leftX; - const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; - const int sparkWidth = std::max(0, sparkRight - sparkLeft); - const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); - const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); - const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; - const int sparkOriginY = y; + layout.card(tr(STR_READING_STATS_LAST_30D), [&](CardLayout::Body& b) { + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 32; + constexpr int kBarGap = 1; + const int innerWidth = b.innerWidth(); + const int innerLeft = b.innerLeft(); + const int barWidth = std::max(2, (innerWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = innerLeft + (innerWidth - totalSpan) / 2; + const int sparkOriginY = b.currentY(); - uint32_t maxSeconds = 1; - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = - (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; - const uint32_t s = store.getSecondsForDay(d); - if (s > maxSeconds) maxSeconds = s; - } + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = store.getSecondsForDay(d); + if (s > maxSeconds) maxSeconds = s; + } - // Baseline (axis) — 1px line under the bars so the visual grouping reads - // as a chart even when most days are empty. - renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, sparkOriginY + kSparkHeight, - true); - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = - (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; - const uint32_t s = store.getSecondsForDay(d); - const int barX = sparkOriginX + i * (barWidth + kBarGap); - // 1px minimum so empty days still tick on the axis. - const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); - renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); - } - y += kSparkHeight + 6; + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, + sparkOriginY + kSparkHeight, true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = store.getSecondsForDay(d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + b.advance(kSparkHeight + 2); + }); } - // ---- Top books (up to 3 by total time) ---- + // ---- Top books card ---- if (!store.getBooks().empty()) { std::vector sorted; sorted.reserve(store.getBooks().size()); @@ -176,20 +177,31 @@ void ReadingStatsActivity::render(RenderLock&&) { std::sort(sorted.begin(), sorted.end(), [](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; }); - drawSection(tr(STR_READING_STATS_TOP_BOOKS)); - const size_t shown = std::min(sorted.size(), 3); - for (size_t i = 0; i < shown; ++i) { - const auto* b = sorted[i]; - // Use the title when known; fall back to docId so the row is never - // empty even before metadata is recorded. - std::string label = b->title.empty() ? b->docId : b->title; - // Trim to fit; 22 chars keeps it in the left column at UI_10. - if (label.size() > 22) { - label.resize(22); - label += "…"; + layout.card(tr(STR_READING_STATS_TOP_BOOKS), [&](CardLayout::Body& b) { + const int ellipsisWidth = renderer.getTextWidth(UI_10_FONT_ID, "…"); + constexpr int kTitleGap = 8; + const size_t shown = std::min(sorted.size(), 3); + const int innerLeft = b.innerLeft(); + const int innerRight = b.innerRight(); + for (size_t i = 0; i < shown; ++i) { + const auto* bk = sorted[i]; + const std::string time = formatDuration(bk->totalSeconds); + const int timeWidth = renderer.getTextWidth(UI_10_FONT_ID, time.c_str()); + renderer.drawText(UI_10_FONT_ID, innerRight - timeWidth, b.currentY(), time.c_str()); + + std::string label = bk->title.empty() ? bk->docId : bk->title; + const int maxLabelWidth = (innerRight - timeWidth - kTitleGap) - innerLeft; + if (maxLabelWidth > 0 && renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) > maxLabelWidth) { + while (!label.empty() && + renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + ellipsisWidth > maxLabelWidth) { + label.pop_back(); + } + label += "…"; + } + renderer.drawText(UI_10_FONT_ID, innerLeft, b.currentY(), label.c_str(), true, EpdFontFamily::BOLD); + b.advance(b.rowStep()); } - drawRow(label.c_str(), formatDuration(b->totalSeconds)); - } + }); } const char* btn2 = store.getBooks().empty() ? "" : tr(STR_READING_STATS_BOOK_LIST); diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp index ef0eb50a..fad0d9d8 100644 --- a/src/activities/settings/ReadingStatsBookDetailActivity.cpp +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -5,11 +5,14 @@ #include #include +#include #include #include +#include #include "MappedInputManager.h" #include "ReadingStats.h" +#include "components/CardLayout.h" #include "components/UITheme.h" #include "fontIds.h" @@ -61,6 +64,16 @@ std::string formatDateOrRelative(time_t epoch) { return buf; } +std::string formatPagesPerMin(uint32_t pages, uint32_t seconds) { + if (seconds < 60 || pages == 0) { + return tr(STR_READING_STATS_UNKNOWN); + } + const float ppm = (pages * 60.0f) / seconds; + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f", ppm); + return buf; +} + uint32_t secondsForDayIn(const std::vector& days, uint16_t dayIndex) { if (dayIndex == 0) return 0; auto it = std::lower_bound(days.begin(), days.end(), dayIndex, @@ -91,9 +104,8 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) { renderer.clearScreen(); - // Header: title up to ~28 chars (theme will further clip if the screen is - // narrow). Fallback to docId so we still produce a usable screen if the - // book's metadata was never recorded. + // Header — title (truncated) and author. Title fallback to docId so we + // still produce a usable screen if the book's metadata was never recorded. std::string headerTitle = (book && !book->title.empty()) ? book->title : docId; if (headerTitle.size() > 28) { headerTitle.resize(28); @@ -103,83 +115,78 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) { Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, headerTitle.c_str(), book && !book->author.empty() ? book->author.c_str() : nullptr); - const int leftX = contentRect.x + metrics.verticalSpacing * 3; - const int valueX = contentRect.x + contentRect.width / 2; - const int lineH = renderer.getLineHeight(UI_10_FONT_ID); - const int rowStep = lineH + 2; - const int subHeaderHeight = lineH + 6; - int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - - auto drawSection = [&](const char* title) { - GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title); - y += subHeaderHeight + 2; - }; - auto drawRow = [&](const char* label, const std::string& value) { - renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); - renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); - y += rowStep; - }; + const int startY = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + CardLayout::Config cfg; + cfg.outerMarginX = metrics.verticalSpacing * 2; + CardLayout layout(renderer, contentRect, startY, cfg); if (!book) { // The book may have been removed from the store between the list and // the detail screen (e.g. a future "clear stats for this book" action). // Show a placeholder rather than crash on a null deref. - drawRow("", tr(STR_READING_STATS_NO_DATA)); + layout.card(nullptr, [](CardLayout::Body& b) { b.centeredMessage(tr(STR_READING_STATS_NO_DATA)); }); } else { - drawSection(tr(STR_READING_STATS_TOTAL_TIME)); - drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(book->totalSeconds)); - drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(book->sessions)); - drawRow(tr(STR_READING_STATS_PAGES), std::to_string(book->pagesTurned)); - if (book->sessions > 0) { - drawRow(tr(STR_READING_STATS_AVG_SESSION), formatDuration(book->totalSeconds / book->sessions)); - } + // ---- Summary card: 4-cell grid (sessions / pages / avg / progress) ---- + const std::string avgValue = book->sessions > 0 ? formatDuration(book->totalSeconds / book->sessions) + : std::string(tr(STR_READING_STATS_UNKNOWN)); char pctBuf[8]; snprintf(pctBuf, sizeof(pctBuf), "%u%%", book->progress); - drawRow(tr(STR_READING_STATS_PROGRESS), pctBuf); + const std::string pctStr(pctBuf); + layout.card(nullptr, [&](CardLayout::Body& b) { + b.statGrid({{{std::to_string(book->sessions), tr(STR_READING_STATS_SESSIONS)}, + {std::to_string(book->pagesTurned), tr(STR_READING_STATS_PAGES)}, + {avgValue, tr(STR_READING_STATS_AVG_SESSION)}, + {pctStr, tr(STR_READING_STATS_PROGRESS)}}}); + }); - drawSection(tr(STR_READING_STATS_FIRST_READ)); - drawRow(tr(STR_READING_STATS_FIRST_READ), formatDateOrRelative(book->firstReadEpoch)); - drawRow(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch)); + // ---- Time card ---- + 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)); + }); - // Per-book 30-day sparkline. Identical algorithm to the main screen but - // reads from this book's own day vector. Hidden when no wall-clocked day - // exists or the clock isn't synced — otherwise the bars would be - // meaningless ("we don't know what day this was"). + // ---- 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)); + }); + + // ---- Per-book sparkline (only when clock-anchored data exists) ---- const uint16_t today = currentLocalDayIndex(); if (today != 0 && !book->days.empty()) { - drawSection(tr(STR_READING_STATS_LAST_30D)); - constexpr int kSparkDays = 30; - constexpr int kSparkHeight = 38; - constexpr int kBarGap = 1; - const int sparkLeft = leftX; - const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; - const int sparkWidth = std::max(0, sparkRight - sparkLeft); - const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); - const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); - const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; - const int sparkOriginY = y; + layout.card(tr(STR_READING_STATS_LAST_30D), [&](CardLayout::Body& b) { + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 32; + constexpr int kBarGap = 1; + const int innerWidth = b.innerWidth(); + const int innerLeft = b.innerLeft(); + const int barWidth = std::max(2, (innerWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = innerLeft + (innerWidth - totalSpan) / 2; + const int sparkOriginY = b.currentY(); - uint32_t maxSeconds = 1; - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) - ? static_cast(today - (kSparkDays - 1 - i)) - : 0; - const uint32_t s = secondsForDayIn(book->days, d); - if (s > maxSeconds) maxSeconds = s; - } + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + if (s > maxSeconds) maxSeconds = s; + } - renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, - sparkOriginY + kSparkHeight, true); - for (int i = 0; i < kSparkDays; ++i) { - const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) - ? static_cast(today - (kSparkDays - 1 - i)) - : 0; - const uint32_t s = secondsForDayIn(book->days, d); - const int barX = sparkOriginX + i * (barWidth + kBarGap); - const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); - renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); - } - y += kSparkHeight + 6; + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, + sparkOriginY + kSparkHeight, true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + b.advance(kSparkHeight + 2); + }); } } diff --git a/src/components/CardLayout.cpp b/src/components/CardLayout.cpp new file mode 100644 index 00000000..0f3a2fc8 --- /dev/null +++ b/src/components/CardLayout.cpp @@ -0,0 +1,69 @@ +#include "CardLayout.h" + +#include + +#include "fontIds.h" + +CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg) + : renderer_(renderer), contentRect_(contentRect), cfg_(cfg), y_(startY) { + cardLeft_ = contentRect.x + cfg_.outerMarginX; + cardWidth_ = contentRect.width - cfg_.outerMarginX * 2; + innerLeft_ = cardLeft_ + cfg_.innerPadX; + innerRight_ = cardLeft_ + cardWidth_ - cfg_.innerPadX; + innerWidth_ = innerRight_ - innerLeft_; + lineH_ = renderer_.getLineHeight(UI_10_FONT_ID); + rowStep_ = lineH_ + 2; + titleH_ = lineH_ + 2; +} + +void CardLayout::card(const char* title, const std::function& bodyFn) { + const int top = y_; + const int titleBlock = title ? titleH_ + cfg_.titleGap : 0; + const int bodyTop = top + cfg_.innerPadY + titleBlock; + int innerY = bodyTop; + + // Body draws directly into the frame buffer; we measure how far it + // advanced so the rounded border can be sized exactly to the content. + Body body(*this, innerY); + bodyFn(body); + + const int bodyHeight = innerY - bodyTop; + const int cardHeight = cfg_.innerPadY + titleBlock + bodyHeight + cfg_.innerPadY; + renderer_.drawRoundedRect(cardLeft_, top, cardWidth_, cardHeight, 1, cfg_.radius, true); + if (title) { + renderer_.drawCenteredText(UI_10_FONT_ID, top + cfg_.innerPadY, title, true, EpdFontFamily::BOLD); + } + y_ = top + cardHeight + cfg_.cardSpacing; +} + +void CardLayout::Body::rowLR(const char* label, const std::string& value) { + layout.renderer_.drawText(UI_10_FONT_ID, layout.innerLeft_, innerY, label, true, EpdFontFamily::BOLD); + const int vw = layout.renderer_.getTextWidth(UI_10_FONT_ID, value.c_str()); + layout.renderer_.drawText(UI_10_FONT_ID, layout.innerRight_ - vw, innerY, value.c_str()); + innerY += layout.rowStep_; +} + +void CardLayout::Body::statGrid(const std::array, 4>& cells) { + const int cellW = layout.innerWidth_ / 4; + const int valueY = innerY; + const int labelY = innerY + layout.lineH_ + 2; + for (int i = 0; i < 4; ++i) { + const auto& [value, label] = cells[i]; + const int cellCenterX = layout.innerLeft_ + cellW * i + cellW / 2; + const int vw = layout.renderer_.getTextWidth(UI_12_FONT_ID, value.c_str(), EpdFontFamily::BOLD); + const int lw = layout.renderer_.getTextWidth(UI_10_FONT_ID, label); + layout.renderer_.drawText(UI_12_FONT_ID, cellCenterX - vw / 2, valueY, value.c_str(), true, EpdFontFamily::BOLD); + layout.renderer_.drawText(UI_10_FONT_ID, cellCenterX - lw / 2, labelY, label); + if (i < 3) { + const int divX = layout.innerLeft_ + cellW * (i + 1); + layout.renderer_.drawLine(divX, valueY - 2, divX, labelY + layout.lineH_, true); + } + } + innerY = labelY + layout.lineH_ + 4; +} + +void CardLayout::Body::centeredMessage(const char* msg) { + const int mw = layout.renderer_.getTextWidth(UI_10_FONT_ID, msg); + layout.renderer_.drawText(UI_10_FONT_ID, layout.innerLeft_ + (layout.innerWidth_ - mw) / 2, innerY, msg); + innerY += layout.rowStep_; +} diff --git a/src/components/CardLayout.h b/src/components/CardLayout.h new file mode 100644 index 00000000..23d0d505 --- /dev/null +++ b/src/components/CardLayout.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include + +#include "components/themes/BaseTheme.h" // for Rect + +class GfxRenderer; + +// Reusable "boxed card" layout primitive for stat-style screens. +// +// A CardLayout owns the vertical cursor on a content rect and renders a +// stack of rounded-border cards. Each card has an optional title and a body +// composed by the caller via a lambda; the body lambda can call helpers on +// the CardBody it receives to draw label/value rows or a 4-cell stat grid. +// +// Typical usage (Reading Stats screen): +// +// CardLayout layout(renderer, contentRect, startY); +// layout.card("Total time", [&](CardLayout::Body& b) { +// b.rowLR("Sessions", "12"); +// b.rowLR("Pages", "248"); +// }); +// layout.card(nullptr, [&](CardLayout::Body& b) { +// b.statGrid({{ {"3", "Sess"}, {"7", "Books"}, {"4", "Streak"}, {"9", "Best"} }}); +// }); +// +// Design notes +// - Title-less cards (title == nullptr) are used as "hero" panels where +// the grid is itself the headline. +// - Card height is computed automatically by tracking how far the body +// lambda advanced its internal y cursor. The rounded rect is drawn +// LAST so it sits cleanly around the content (it can't clip text +// because content padding is fixed at construction time). +// - All values use UI_10 font; grid values use UI_12 bold. This is +// consistent across stats screens; if a future caller needs another +// font scale we'll add an optional config struct. +class CardLayout { + public: + struct Config { + int radius = 4; + int innerPadX = 10; + int innerPadY = 6; + int titleGap = 4; + int cardSpacing = 6; + // Horizontal margin between the content rect and the card's outer + // bounds. Defaults to 0; pass theme.verticalSpacing * 2 to line up + // with the rest of the system UI on a given theme. + int outerMarginX = 0; + }; + + // Inner-card drawing context handed to card body lambdas. Tracks the + // running y cursor inside the card and exposes the same content geometry + // (innerLeft / innerRight / innerWidth) needed for custom layouts. + class Body { + friend class CardLayout; + const CardLayout& layout; + int& innerY; + Body(const CardLayout& layout, int& innerY) : layout(layout), innerY(innerY) {} + + public: + int currentY() const { return innerY; } + void advance(int dy) { innerY += dy; } + int innerLeft() const { return layout.innerLeft_; } + int innerRight() const { return layout.innerRight_; } + int innerWidth() const { return layout.innerWidth_; } + int lineHeight() const { return layout.lineH_; } + int rowStep() const { return layout.rowStep_; } + + // Label (bold) left, value right-aligned at innerRight. + void rowLR(const char* label, const std::string& value); + + // 4-cell stat grid: large bold value above small label, three 1-px + // dividers between cells. Advances y by ~2.5 line heights. + void statGrid(const std::array, 4>& cells); + + // Centered single-line message (e.g. "No data yet" placeholder). + void centeredMessage(const char* msg); + }; + + CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg = Config()); + + // Render a single card. `bodyFn` receives a `Body&` and may call its + // helpers in any order; the card auto-sizes to whatever the body draws. + // Pass `title == nullptr` for an untitled hero card. + void card(const char* title, const std::function& bodyFn); + + // Where the next card would start. Useful for callers that want to know + // how much vertical space they've consumed (e.g. to decide whether to + // skip a later section that wouldn't fit). + int cursorY() const { return y_; } + + private: + GfxRenderer& renderer_; + Rect contentRect_; + Config cfg_; + + int cardLeft_; + int cardWidth_; + int innerLeft_; + int innerRight_; + int innerWidth_; + int lineH_; + int rowStep_; + int titleH_; + int y_; +}; From bfe236fe6935599b15149fba39eadf31a6e78a39 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:56:57 +0200 Subject: [PATCH 08/14] Fix --- src/components/CardLayout.cpp | 2 +- src/components/CardLayout.h | 31 ++++++++++++++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/components/CardLayout.cpp b/src/components/CardLayout.cpp index 0f3a2fc8..3c8d75d5 100644 --- a/src/components/CardLayout.cpp +++ b/src/components/CardLayout.cpp @@ -4,7 +4,7 @@ #include "fontIds.h" -CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg) +CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, CardLayoutConfig cfg) : renderer_(renderer), contentRect_(contentRect), cfg_(cfg), y_(startY) { cardLeft_ = contentRect.x + cfg_.outerMarginX; cardWidth_ = contentRect.width - cfg_.outerMarginX * 2; diff --git a/src/components/CardLayout.h b/src/components/CardLayout.h index 23d0d505..faba6aa2 100644 --- a/src/components/CardLayout.h +++ b/src/components/CardLayout.h @@ -37,19 +37,24 @@ class GfxRenderer; // - All values use UI_10 font; grid values use UI_12 bold. This is // consistent across stats screens; if a future caller needs another // font scale we'll add an optional config struct. +// Card visual / spacing configuration. Lives outside CardLayout so it can +// be default-constructed by callers (GCC won't synthesize a default ctor +// for a class-nested aggregate inside its own class's default-arg list). +struct CardLayoutConfig { + int radius = 4; + int innerPadX = 10; + int innerPadY = 6; + int titleGap = 4; + int cardSpacing = 6; + // Horizontal margin between the content rect and the card's outer + // bounds. Defaults to 0; pass theme.verticalSpacing * 2 to line up + // with the rest of the system UI on a given theme. + int outerMarginX = 0; +}; + class CardLayout { public: - struct Config { - int radius = 4; - int innerPadX = 10; - int innerPadY = 6; - int titleGap = 4; - int cardSpacing = 6; - // Horizontal margin between the content rect and the card's outer - // bounds. Defaults to 0; pass theme.verticalSpacing * 2 to line up - // with the rest of the system UI on a given theme. - int outerMarginX = 0; - }; + using Config = CardLayoutConfig; // backwards-compat alias for call sites. // Inner-card drawing context handed to card body lambdas. Tracks the // running y cursor inside the card and exposes the same content geometry @@ -80,7 +85,7 @@ class CardLayout { void centeredMessage(const char* msg); }; - CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg = Config()); + CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, CardLayoutConfig cfg = {}); // Render a single card. `bodyFn` receives a `Body&` and may call its // helpers in any order; the card auto-sizes to whatever the body draws. @@ -95,7 +100,7 @@ class CardLayout { private: GfxRenderer& renderer_; Rect contentRect_; - Config cfg_; + CardLayoutConfig cfg_; int cardLeft_; int cardWidth_; From b4738fee0c9a0c8a4aab2182d025e31b5878142a Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 01:04:28 +0200 Subject: [PATCH 09/14] Tighten layout --- src/components/CardLayout.cpp | 113 +++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 15 deletions(-) diff --git a/src/components/CardLayout.cpp b/src/components/CardLayout.cpp index 3c8d75d5..eb59687f 100644 --- a/src/components/CardLayout.cpp +++ b/src/components/CardLayout.cpp @@ -2,6 +2,9 @@ #include +#include +#include + #include "fontIds.h" CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, CardLayoutConfig cfg) @@ -43,23 +46,103 @@ void CardLayout::Body::rowLR(const char* label, const std::string& value) { innerY += layout.rowStep_; } -void CardLayout::Body::statGrid(const std::array, 4>& cells) { - const int cellW = layout.innerWidth_ / 4; - const int valueY = innerY; - const int labelY = innerY + layout.lineH_ + 2; - for (int i = 0; i < 4; ++i) { - const auto& [value, label] = cells[i]; - const int cellCenterX = layout.innerLeft_ + cellW * i + cellW / 2; - const int vw = layout.renderer_.getTextWidth(UI_12_FONT_ID, value.c_str(), EpdFontFamily::BOLD); - const int lw = layout.renderer_.getTextWidth(UI_10_FONT_ID, label); - layout.renderer_.drawText(UI_12_FONT_ID, cellCenterX - vw / 2, valueY, value.c_str(), true, EpdFontFamily::BOLD); - layout.renderer_.drawText(UI_10_FONT_ID, cellCenterX - lw / 2, labelY, label); - if (i < 3) { - const int divX = layout.innerLeft_ + cellW * (i + 1); - layout.renderer_.drawLine(divX, valueY - 2, divX, labelY + layout.lineH_, true); +namespace { +// Split `label` into 1-2 centered lines that fit within `maxWidth`. Splits +// at the last space whose prefix still fits; if no such split exists (single +// long word) we fall back to character-wise truncation of the original with +// an ellipsis. The two-line variant is preferred whenever both halves fit +// inside the cell, so "Books tracked" wraps rather than getting clipped. +struct WrappedLabel { + std::string line1; + std::string line2; // empty when single-line +}; + +WrappedLabel wrapLabel(const GfxRenderer& renderer, const char* label, int maxWidth, int fontId) { + WrappedLabel out; + if (!label || !*label) return out; + if (renderer.getTextWidth(fontId, label) <= maxWidth) { + out.line1 = label; + return out; + } + + // Find the last whitespace whose prefix fits and whose suffix also fits. + const std::string s(label); + size_t bestSplit = std::string::npos; + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] != ' ') continue; + const std::string left = s.substr(0, i); + const std::string right = s.substr(i + 1); + if (renderer.getTextWidth(fontId, left.c_str()) <= maxWidth && + renderer.getTextWidth(fontId, right.c_str()) <= maxWidth) { + bestSplit = i; } } - innerY = labelY + layout.lineH_ + 4; + if (bestSplit != std::string::npos) { + out.line1 = s.substr(0, bestSplit); + out.line2 = s.substr(bestSplit + 1); + return out; + } + + // No good split — truncate the single token. Keep dropping characters + // until label + "…" fits. + const int ellipsisW = renderer.getTextWidth(fontId, "…"); + std::string truncated = s; + while (!truncated.empty() && + renderer.getTextWidth(fontId, truncated.c_str()) + ellipsisW > maxWidth) { + truncated.pop_back(); + } + out.line1 = truncated + "…"; + return out; +} +} // namespace + +void CardLayout::Body::statGrid(const std::array, 4>& cells) { + // Labels use SMALL_FONT_ID so multi-word labels like "Pages turned" / + // "Books tracked" / "Avg session" usually fit on one line. The two-line + // wrap below is still the fallback for genuinely long labels. + constexpr int kLabelFont = SMALL_FONT_ID; + const int labelLineH = layout.renderer_.getLineHeight(kLabelFont); + + const int cellW = layout.innerWidth_ / 4; + // Leave a small horizontal breathing room inside each cell so wrapped + // labels don't kiss the vertical divider on the next cell over. + constexpr int kLabelPadX = 4; + const int labelMaxW = std::max(0, cellW - kLabelPadX * 2); + + // Pre-wrap so we can compute the row's vertical extent before drawing the + // dividers (which span both label lines whenever any cell wraps). + std::array wrapped; + bool anyTwoLines = false; + for (int i = 0; i < 4; ++i) { + wrapped[i] = wrapLabel(layout.renderer_, cells[i].second, labelMaxW, kLabelFont); + if (!wrapped[i].line2.empty()) anyTwoLines = true; + } + + const int valueY = innerY; + const int labelY = innerY + layout.lineH_ + 2; + const int labelLine2Y = labelY + labelLineH; + const int gridBottom = (anyTwoLines ? labelLine2Y : labelY) + labelLineH; + + for (int i = 0; i < 4; ++i) { + const auto& [value, _label] = cells[i]; + const int cellCenterX = layout.innerLeft_ + cellW * i + cellW / 2; + const int vw = layout.renderer_.getTextWidth(UI_12_FONT_ID, value.c_str(), EpdFontFamily::BOLD); + layout.renderer_.drawText(UI_12_FONT_ID, cellCenterX - vw / 2, valueY, value.c_str(), true, EpdFontFamily::BOLD); + + const auto& w = wrapped[i]; + const int lw1 = layout.renderer_.getTextWidth(kLabelFont, w.line1.c_str()); + layout.renderer_.drawText(kLabelFont, cellCenterX - lw1 / 2, labelY, w.line1.c_str()); + if (!w.line2.empty()) { + const int lw2 = layout.renderer_.getTextWidth(kLabelFont, w.line2.c_str()); + layout.renderer_.drawText(kLabelFont, cellCenterX - lw2 / 2, labelLine2Y, w.line2.c_str()); + } + + if (i < 3) { + const int divX = layout.innerLeft_ + cellW * (i + 1); + layout.renderer_.drawLine(divX, valueY - 2, divX, gridBottom, true); + } + } + innerY = gridBottom + 4; } void CardLayout::Body::centeredMessage(const char* msg) { From 375c73b03b0942d6449b1afb8f1f9037e55442af Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 09:55:49 +0200 Subject: [PATCH 10/14] Phase 6 - remaining time info --- lib/I18n/translations/english.yaml | 3 + platformio.ini | 3 +- src/JsonSettingsIO.cpp | 16 ++++- src/ReadingSessionTracker.cpp | 8 +++ src/ReadingSessionTracker.h | 6 ++ src/ReadingStats.cpp | 64 +++++++++++++++++++ src/ReadingStats.h | 37 ++++++++++- src/activities/reader/EpubReaderActivity.cpp | 5 ++ src/activities/reader/MdReaderActivity.cpp | 1 + src/activities/reader/TxtReaderActivity.cpp | 1 + src/activities/reader/XtcReaderActivity.cpp | 1 + .../settings/ReadingStatsActivity.cpp | 5 ++ .../ReadingStatsBookDetailActivity.cpp | 20 ++++++ .../settings/ReadingStatsBookListActivity.cpp | 7 +- src/network/CrossPointWebServer.cpp | 15 ++++- src/network/html/StatsPage.html | 38 +++++++++-- 16 files changed, 216 insertions(+), 14 deletions(-) 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) { From b1908587ee1d78a061cb81e8929cf767b17bead2 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 10:57:46 +0200 Subject: [PATCH 11/14] Reduce non-sensical logs --- lib/EpdFont/FontDecompressor.cpp | 6 ++++++ lib/EpdFont/SdCardFont.cpp | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/lib/EpdFont/FontDecompressor.cpp b/lib/EpdFont/FontDecompressor.cpp index 3c05e77e..9694c81c 100644 --- a/lib/EpdFont/FontDecompressor.cpp +++ b/lib/EpdFont/FontDecompressor.cpp @@ -567,6 +567,12 @@ void FontDecompressor::resetStats() { stats = Stats{}; } void FontDecompressor::logStats(const char* label) { const uint32_t total = stats.cacheHits + stats.cacheMisses; + // Suppress the block entirely when the decompressor was untouched this phase + // (e.g. an SD-card font page — FontCacheManager early-returns before invoking us). + if (total == 0 && stats.pageBufferBytes == 0 && stats.decompressTimeMs == 0 && stats.getBitmapCalls == 0) { + resetStats(); + return; + } LOG_DBG("FDC", "[%s] hits=%lu misses=%lu (%.1f%% hit rate)", label, stats.cacheHits, stats.cacheMisses, total > 0 ? 100.0f * stats.cacheHits / total : 0.0f); LOG_DBG("FDC", "[%s] decompress=%lums groups_accessed=%u", label, stats.decompressTimeMs, stats.uniqueGroupsAccessed); diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 725bf8bd..939f6be8 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -1331,6 +1331,12 @@ void SdCardFont::clearAccumulation() { // --- Stats --- void SdCardFont::logStats(const char* label) { + // Suppress when this font wasn't touched this phase — FontCacheManager iterates every + // registered SD font, but only the active one for the page will have non-zero stats. + if (stats_.prewarmTotalMs == 0 && stats_.sdReadTimeMs == 0 && stats_.seekCount == 0 && stats_.uniqueGlyphs == 0 && + stats_.bitmapBytes == 0) { + return; + } LOG_DBG("SDCF", "[%s] total=%ums sd_read=%ums seeks=%u glyphs=%u bitmap=%u bytes", label, stats_.prewarmTotalMs, stats_.sdReadTimeMs, stats_.seekCount, stats_.uniqueGlyphs, stats_.bitmapBytes); } From 59e6281f2e75dda61d81ba39ed06c61234cf0f25 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 11:12:44 +0200 Subject: [PATCH 12/14] Remove typo --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 7eb218f5..31b97521 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,7 +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 -would board_upload.flash_size = 16MB +board_upload.flash_size = 16MB board_upload.maximum_size = 16777216 board_upload.offset_address = 0x10000 From d729201321fbfc59beeb70ba31c50a6e565266c5 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 19:35:34 +0200 Subject: [PATCH 13/14] yaclf --- src/components/CardLayout.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/CardLayout.cpp b/src/components/CardLayout.cpp index eb59687f..af393a09 100644 --- a/src/components/CardLayout.cpp +++ b/src/components/CardLayout.cpp @@ -87,8 +87,7 @@ WrappedLabel wrapLabel(const GfxRenderer& renderer, const char* label, int maxWi // until label + "…" fits. const int ellipsisW = renderer.getTextWidth(fontId, "…"); std::string truncated = s; - while (!truncated.empty() && - renderer.getTextWidth(fontId, truncated.c_str()) + ellipsisW > maxWidth) { + while (!truncated.empty() && renderer.getTextWidth(fontId, truncated.c_str()) + ellipsisW > maxWidth) { truncated.pop_back(); } out.line1 = truncated + "…"; From c5b8c55c0463d398d5b45f5afb1960adf26e38fb Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 20:56:03 +0200 Subject: [PATCH 14/14] Some fixes --- src/ReadingSessionTracker.cpp | 10 ++++++++-- src/ReadingStats.cpp | 8 ++------ src/activities/reader/TxtReaderActivity.cpp | 4 ++++ src/activities/reader/XtcReaderActivity.cpp | 6 ++++++ .../settings/ReadingStatsActivity.cpp | 7 +++++-- .../settings/ReadingStatsBookDetailActivity.cpp | 17 ++++++++++++++--- .../settings/ReadingStatsBookListActivity.cpp | 7 ++++--- src/components/CardLayout.cpp | 2 +- src/components/CardLayout.h | 2 +- src/network/html/FilesPage.html | 1 + src/network/html/HomePage.html | 1 + src/network/html/SettingsPage.html | 1 + src/network/html/StatsPage.html | 6 ++++-- 13 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/ReadingSessionTracker.cpp b/src/ReadingSessionTracker.cpp index c5b3a236..f4f7770b 100644 --- a/src/ReadingSessionTracker.cpp +++ b/src/ReadingSessionTracker.cpp @@ -53,7 +53,10 @@ 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(); + if (!READING_STATS.saveToFile()) { + LOG_ERR("RST", "saveToFile failed (markFinished) doc=%s title=%s author=%s wall=%lld", docId.c_str(), title.c_str(), + author.c_str(), (long long)walltime); + } LOG_DBG("RST", "Marked finished doc=%s wall=%lld", docId.c_str(), (long long)walltime); } @@ -77,7 +80,10 @@ void ReadingSessionTracker::end() { if (seconds > 0 && !docId.empty()) { READING_STATS.recordSession(docId, title, author, seconds, pagesTurnedThisSession, lastKnownProgress, static_cast(walltime)); - READING_STATS.saveToFile(); + if (!READING_STATS.saveToFile()) { + LOG_ERR("RST", "saveToFile failed (session end) doc=%s title=%s author=%s secs=%u pages=%u wall=%lld", + docId.c_str(), title.c_str(), author.c_str(), seconds, pagesTurnedThisSession, (long long)walltime); + } } active = false; diff --git a/src/ReadingStats.cpp b/src/ReadingStats.cpp index 9a187280..27444251 100644 --- a/src/ReadingStats.cpp +++ b/src/ReadingStats.cpp @@ -118,7 +118,6 @@ uint16_t ReadingStatsStore::computeCurrentStreak(uint16_t today) const { // yesterday. After that the chain is broken. uint16_t anchor = today; if (getSecondsForDay(anchor) == 0) { - if (anchor == 0) return 0; anchor -= 1; if (getSecondsForDay(anchor) == 0) return 0; } @@ -170,11 +169,8 @@ void ReadingStatsStore::markFinished(const std::string& docId, const std::string } size_t ReadingStatsStore::getFinishedBookCount() const { - size_t n = 0; - for (const auto& b : books) { - if (b.finishedCount > 0) ++n; - } - return n; + return static_cast( + std::count_if(books.begin(), books.end(), [](const BookReadingStats& b) { return b.finishedCount > 0; })); } const BookReadingStats* ReadingStatsStore::findBook(const std::string& docId) const { diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index fd301d65..e830d35d 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -905,23 +905,27 @@ void TxtReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION a case BA::BTN_PAGE_FORWARD: if (currentPage < totalPages - 1) { currentPage++; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } break; case BA::BTN_PAGE_BACK: if (currentPage > 0) { currentPage--; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } break; case BA::BTN_PAGE_FORWARD_10: currentPage += 10; clampPage(); + globalReadingSessionTracker().onPageTurn(); requestUpdate(); break; case BA::BTN_PAGE_BACK_10: currentPage -= 10; clampPage(); + globalReadingSessionTracker().onPageTurn(); requestUpdate(); break; case BA::BTN_STAR_PAGE: diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 0b22adf4..f9d36c66 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -523,21 +523,25 @@ void XtcReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION a case BA::BTN_PAGE_FORWARD: if (currentPage + 1 < pageCount) { currentPage++; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } break; case BA::BTN_PAGE_BACK: if (currentPage > 0) { currentPage--; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } break; case BA::BTN_PAGE_FORWARD_10: currentPage = (currentPage + 10 < pageCount) ? currentPage + 10 : pageCount - 1; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); break; case BA::BTN_PAGE_BACK_10: currentPage = (currentPage >= 10) ? currentPage - 10 : 0; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); break; case BA::BTN_NEXT_SECTION: @@ -547,6 +551,7 @@ void XtcReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION a [this](const auto& ch) { return ch.startPage > currentPage; }); if (it != chapters.end()) { currentPage = it->startPage; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } } @@ -559,6 +564,7 @@ void XtcReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION a if (prevChapter != chapters.rend()) { currentPage = prevChapter->startPage; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } } diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp index fb04aab0..4a02706f 100644 --- a/src/activities/settings/ReadingStatsActivity.cpp +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -3,10 +3,12 @@ #include // millis() #include #include +#include #include #include #include +#include #include #include @@ -178,7 +180,8 @@ void ReadingStatsActivity::render(RenderLock&&) { if (!store.getBooks().empty()) { std::vector sorted; sorted.reserve(store.getBooks().size()); - for (const auto& b : store.getBooks()) sorted.push_back(&b); + std::transform(store.getBooks().begin(), store.getBooks().end(), std::back_inserter(sorted), + [](const BookReadingStats& b) { return &b; }); std::sort(sorted.begin(), sorted.end(), [](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; }); @@ -199,7 +202,7 @@ void ReadingStatsActivity::render(RenderLock&&) { if (maxLabelWidth > 0 && renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) > maxLabelWidth) { while (!label.empty() && renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + ellipsisWidth > maxLabelWidth) { - label.pop_back(); + utf8RemoveLastChar(label); } label += "…"; } diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp index 56a1a0de..d067d287 100644 --- a/src/activities/settings/ReadingStatsBookDetailActivity.cpp +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -107,9 +108,19 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) { // Header — title (truncated) and author. Title fallback to docId so we // still produce a usable screen if the book's metadata was never recorded. std::string headerTitle = (book && !book->title.empty()) ? book->title : docId; - if (headerTitle.size() > 28) { - headerTitle.resize(28); - headerTitle += "…"; + { + constexpr size_t kMaxChars = 28; + const auto* p = reinterpret_cast(headerTitle.c_str()); + const auto* start = p; + size_t chars = 0; + while (*p != 0 && chars < kMaxChars) { + utf8NextCodepoint(&p); + ++chars; + } + if (*p != 0) { + headerTitle.resize(static_cast(p - start)); + headerTitle += "…"; + } } GUI.drawHeader(renderer, Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, diff --git a/src/activities/settings/ReadingStatsBookListActivity.cpp b/src/activities/settings/ReadingStatsBookListActivity.cpp index 6b6fa103..c5eae91e 100644 --- a/src/activities/settings/ReadingStatsBookListActivity.cpp +++ b/src/activities/settings/ReadingStatsBookListActivity.cpp @@ -5,6 +5,7 @@ #include #include +#include #include "MappedInputManager.h" #include "ReadingStatsBookDetailActivity.h" @@ -35,9 +36,9 @@ std::string formatDuration(uint32_t totalSeconds) { void ReadingStatsBookListActivity::rebuildSortedBooks() { sortedBooks.clear(); - for (const auto& b : READING_STATS.getBooks()) { - sortedBooks.push_back(&b); - } + sortedBooks.reserve(READING_STATS.getBooks().size()); + std::transform(READING_STATS.getBooks().begin(), READING_STATS.getBooks().end(), std::back_inserter(sortedBooks), + [](const BookReadingStats& b) { return &b; }); std::sort(sortedBooks.begin(), sortedBooks.end(), [](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; }); } diff --git a/src/components/CardLayout.cpp b/src/components/CardLayout.cpp index af393a09..3641486f 100644 --- a/src/components/CardLayout.cpp +++ b/src/components/CardLayout.cpp @@ -7,7 +7,7 @@ #include "fontIds.h" -CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, CardLayoutConfig cfg) +CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, const CardLayoutConfig& cfg) : renderer_(renderer), contentRect_(contentRect), cfg_(cfg), y_(startY) { cardLeft_ = contentRect.x + cfg_.outerMarginX; cardWidth_ = contentRect.width - cfg_.outerMarginX * 2; diff --git a/src/components/CardLayout.h b/src/components/CardLayout.h index faba6aa2..6afb40aa 100644 --- a/src/components/CardLayout.h +++ b/src/components/CardLayout.h @@ -85,7 +85,7 @@ class CardLayout { void centeredMessage(const char* msg); }; - CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, CardLayoutConfig cfg = {}); + CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, const CardLayoutConfig& cfg = {}); // Render a single card. `bodyFn` receives a `Body&` and may call its // helpers in any order; the card auto-sizes to whatever the body draws. diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 104236a0..9ce673ff 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -108,6 +108,7 @@ .nav-links { margin: 20px 0; display: flex; + flex-wrap: wrap; gap: 10px; } diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index 1735bdd2..c88ee4ea 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -113,6 +113,7 @@ .nav-links { margin: 20px 0; display: flex; + flex-wrap: wrap; gap: 10px; } diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index f807de8b..dfa0fcde 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -59,6 +59,7 @@ .nav-links { margin: 20px 0; display: flex; + flex-wrap: wrap; gap: 10px; } .nav-links a { diff --git a/src/network/html/StatsPage.html b/src/network/html/StatsPage.html index 0bbe7bcc..aa2c4043 100644 --- a/src/network/html/StatsPage.html +++ b/src/network/html/StatsPage.html @@ -310,8 +310,10 @@ // seconds. function formatEta(seconds) { if (!seconds || seconds <= 0) return "—"; - const h = Math.floor(seconds / 3600); - const m = Math.round((seconds % 3600) / 60); + let h = Math.floor(seconds / 3600); + let m = Math.round((seconds % 3600) / 60); + // Rounding can push m to 60 (e.g. 3570s → 0h 60m); carry into hours. + if (m === 60) { h += 1; m = 0; } if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`; if (m > 0) return `${m}m`; return "<1m";