From 65418c260602debabf241680a618c4d6ea9ac999 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 19 May 2026 23:49:42 +0200 Subject: [PATCH] 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.