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); } diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 55f94b7d..8efbb7a4 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -530,6 +530,29 @@ 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_READING_STATS_STREAK: "Streak" +STR_READING_STATS_LAST_30D: "Last 30 days" +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_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..31b97521 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,7 +15,6 @@ 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 board_upload.maximum_size = 16777216 board_upload.offset_address = 0x10000 diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index bea75ab2..7dfb7d71 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,110 @@ 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(); + + // 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(); + 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["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); + } + + 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.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; + 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; + // 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)); + } + + 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..f4f7770b --- /dev/null +++ b/src/ReadingSessionTracker.cpp @@ -0,0 +1,105 @@ +#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::markFinished() { + if (!active) return; + const int64_t walltime = HalClock::isSynced() ? static_cast(HalClock::now()) : 0; + READING_STATS.markFinished(docId, title, author, static_cast(walltime)); + 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); +} + +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)); + 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; + 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..d440d374 --- /dev/null +++ b/src/ReadingSessionTracker.h @@ -0,0 +1,90 @@ +#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); + + // 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 + // 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..27444251 --- /dev/null +++ b/src/ReadingStats.cpp @@ -0,0 +1,228 @@ +#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, + 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; + // 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; + globalTotalSessions += 1; + 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) { + 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; +} + +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 { + 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 { + 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); +} + +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..a6c8dc1d --- /dev/null +++ b/src/ReadingStats.h @@ -0,0 +1,142 @@ +#pragma once +#include +#include +#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. +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 + 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 + // from sparklines/streaks but kept so totals remain consistent. + std::vector days; +}; + +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; + // 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*); + + 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). 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); + + // 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; } + + // 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(); +}; + +#define READING_STATS ReadingStatsStore::getInstance() diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 07b0bcff..3866691a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -30,13 +30,16 @@ #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" +#include "activities/settings/ReadingStatsBookDetailActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/ScreenshotUtil.h" @@ -324,6 +327,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 +344,10 @@ 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(); // If a pre-render left the next page in the frame buffer, redraw the current page so the // next activity (notably SleepActivity's OVERLAY mode) sees what the user was looking at. // Must run before section.reset() and the orientation reset below. @@ -495,6 +509,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) { @@ -714,6 +731,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; @@ -743,6 +772,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) { @@ -1542,6 +1572,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { preRenderedPage.ready = false; usePreRenderedBuffer = true; sessionPagesAdvanced++; + globalReadingSessionTracker().onPageTurn(); lastPageTurnTime = millis(); requestUpdate(); return; @@ -1551,6 +1582,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { return; } sessionPagesAdvanced++; + globalReadingSessionTracker().onPageTurn(); preRenderedPage.ready = false; requestUpdate(); } @@ -1594,6 +1626,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) { @@ -1926,6 +1959,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/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/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 4cf07319..3ca84738 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(); @@ -292,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) { @@ -914,6 +925,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 +933,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 +1095,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 +1103,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..e830d35d 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(); @@ -243,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) { @@ -515,6 +530,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 +538,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); } } @@ -888,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 224596dc..f9d36c66 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(); @@ -146,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) { @@ -182,10 +193,12 @@ void XtcReaderActivity::loop() { if (prevTriggered) { if (currentPage > 0) { currentPage--; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } } else if (nextTriggered) { currentPage++; + globalReadingSessionTracker().onPageTurn(); requestUpdate(); } } @@ -393,15 +406,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); } } @@ -508,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: @@ -532,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(); } } @@ -544,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 new file mode 100644 index 00000000..4a02706f --- /dev/null +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -0,0 +1,220 @@ +#include "ReadingStatsActivity.h" + +#include // millis() +#include +#include +#include + +#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" — 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; + 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; +} + +// 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() { + Activity::onEnter(); + requestUpdate(); +} + +void ReadingStatsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + 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, 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 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 card ---- + if (tracker.isActive()) { + 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 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; + } + + // 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())); + // 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 ---- + const uint16_t today = currentLocalDayIndex(); + if (today != 0 && !store.getGlobalDays().empty()) { + 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; + } + + 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 card ---- + if (!store.getBooks().empty()) { + std::vector sorted; + sorted.reserve(store.getBooks().size()); + 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; }); + + 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) { + utf8RemoveLastChar(label); + } + label += "…"; + } + renderer.drawText(UI_10_FONT_ID, innerLeft, b.currentY(), label.c_str(), true, EpdFontFamily::BOLD); + b.advance(b.rowStep()); + } + }); + } + + 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/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/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp new file mode 100644 index 00000000..d067d287 --- /dev/null +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -0,0 +1,228 @@ +#include "ReadingStatsBookDetailActivity.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "MappedInputManager.h" +#include "ReadingStats.h" +#include "components/CardLayout.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; +} + +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, + [](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 (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; + { + 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}, + headerTitle.c_str(), book && !book->author.empty() ? book->author.c_str() : nullptr); + + 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. + layout.card(nullptr, [](CardLayout::Body& b) { b.centeredMessage(tr(STR_READING_STATS_NO_DATA)); }); + } else { + // ---- 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); + 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)}}}); + }); + + // ---- 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)); + // 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) ---- + const uint16_t today = currentLocalDayIndex(); + if (today != 0 && !book->days.empty()) { + 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; + } + + 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); + }); + } + } + + 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..c5eae91e --- /dev/null +++ b/src/activities/settings/ReadingStatsBookListActivity.cpp @@ -0,0 +1,118 @@ +#include "ReadingStatsBookListActivity.h" + +#include +#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(); + 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; }); +} + +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. 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 + // 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(); +}; 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/components/CardLayout.cpp b/src/components/CardLayout.cpp new file mode 100644 index 00000000..3641486f --- /dev/null +++ b/src/components/CardLayout.cpp @@ -0,0 +1,151 @@ +#include "CardLayout.h" + +#include + +#include +#include + +#include "fontIds.h" + +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; + 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_; +} + +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; + } + } + 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) { + 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..6afb40aa --- /dev/null +++ b/src/components/CardLayout.h @@ -0,0 +1,114 @@ +#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. +// 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: + 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 + // (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, 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. + // 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_; + CardLayoutConfig cfg_; + + int cardLeft_; + int cardWidth_; + int innerLeft_; + int innerRight_; + int innerWidth_; + int lineH_; + int rowStep_; + int titleH_; + int y_; +}; 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. diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 11df159c..8e9f407e 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -17,6 +17,7 @@ #include "FontInstaller.h" #include "HttpFileStreamer.h" #include "OpdsServerStore.h" +#include "ReadingStats.h" #include "SdCardFontGlobals.h" #include "SdCardFontRegistry.h" #include "SettingsList.h" @@ -27,6 +28,7 @@ #include "html/FontsPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "html/SettingsPageHtml.generated.h" +#include "html/StatsPageHtml.generated.h" #include "html/WelcomePageHtml.generated.h" #include "html/js/jszip_minJs.generated.h" #include "network/HttpDownloader.h" @@ -219,6 +221,10 @@ void CrossPointWebServer::begin() { server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); // Font management endpoints + server->on("/stats", HTTP_GET, [this] { handleStatsPage(); }); + server->on("/api/stats", HTTP_GET, [this] { handleStatsApi(); }); + server->on("/api/stats/export", HTTP_GET, [this] { handleStatsExport(); }); + server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); }); server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); }); server->on("/api/fonts/manifest", HTTP_GET, [this] { handleFontManifest(); }); @@ -419,6 +425,95 @@ void CrossPointWebServer::handleSystemInfoPage() const { LOG_DBG("WEB", "Served system info page in %d ms", t1 - t0); } +void CrossPointWebServer::handleStatsPage() const { + int32_t t0 = millis(); + sendHtmlContent(server.get(), StatsPageHtml, sizeof(StatsPageHtml)); + int32_t t1 = millis(); + LOG_DBG("WEB", "Served stats page in %d ms", t1 - t0); +} + +void CrossPointWebServer::handleStatsApi() const { + // Wire the same data the on-device screens use into a JSON payload the + // browser dashboard can consume. We pre-compute streaks and todayDayIndex + // here so the browser doesn't have to recreate the day-index math; the day + // arrays still go across untouched so the browser can render the sparkline. + const auto& store = READING_STATS; + const uint16_t today = currentLocalDayIndex(); + const bool haveStreak = today != 0 && !store.getGlobalDays().empty(); + + JsonDocument doc; + doc["totalSeconds"] = store.getGlobalTotalSeconds(); + 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(); + } + + // Day buckets as [[dayIndex, seconds], …] — same compact shape as on disk + // so the browser code can treat the export and the live API identically. + JsonArray globalDays = doc["globalDays"].to(); + for (const auto& d : store.getGlobalDays()) { + JsonArray pair = globalDays.add(); + pair.add(d.dayIndex); + pair.add(d.seconds); + } + + JsonArray booksArr = doc["books"].to(); + for (const auto& book : store.getBooks()) { + JsonObject obj = booksArr.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["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(); + pair.add(d.dayIndex); + pair.add(d.seconds); + } + } + + String json; + serializeJson(doc, json); + server->send(200, "application/json", json); +} + +void CrossPointWebServer::handleStatsExport() const { + // Stream the raw stats file straight from SD — this is the same shape the + // device writes and reads, so it round-trips cleanly through external + // tooling without us having to maintain a second schema. + constexpr const char* kStatsFile = "/.crosspoint/reading-stats.json"; + if (!Storage.exists(kStatsFile)) { + server->send(404, "application/json", "{}"); + return; + } + String content = Storage.readFile(kStatsFile); + server->sendHeader("Content-Disposition", "attachment; filename=\"reading-stats.json\""); + server->send(200, "application/json", content); +} + void CrossPointWebServer::handleJszip() const { server->sendHeader("Content-Encoding", "gzip"); server->send_P(200, "application/javascript", jszip_minJs, jszip_minJsCompressedSize); diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index e73dce97..0987c61e 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -144,4 +144,9 @@ class CrossPointWebServer { void handleGetWifiNetworks() const; void handlePostWifiNetwork(); void handleDeleteWifiNetwork(); + + // Reading-stats handlers + void handleStatsPage() const; + void handleStatsApi() const; + void handleStatsExport() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 6f63fdda..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; } @@ -1821,6 +1822,7 @@ File Manager Settings Font Manager + Reading Stats System Info diff --git a/src/network/html/FontsPage.html b/src/network/html/FontsPage.html index 9147213e..0f6e7e58 100644 --- a/src/network/html/FontsPage.html +++ b/src/network/html/FontsPage.html @@ -197,6 +197,7 @@ File Manager Settings Font Manager + Reading Stats System Info diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index 2f1b6aba..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; } @@ -174,6 +175,7 @@ File Manager Settings Font Manager + Reading Stats System Info diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index 319d574e..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 { @@ -301,6 +302,7 @@ File Manager Settings Font Manager + Reading Stats System Info diff --git a/src/network/html/StatsPage.html b/src/network/html/StatsPage.html new file mode 100644 index 00000000..aa2c4043 --- /dev/null +++ b/src/network/html/StatsPage.html @@ -0,0 +1,574 @@ + + + + + + + Reading Stats - %%CROSSPOINT%% + + + + +

Reading Stats

+ + + +
+
+
Loading…
+
+
+ + + + + diff --git a/src/network/html/WelcomePage.html b/src/network/html/WelcomePage.html index dab7f564..e2e274bd 100644 --- a/src/network/html/WelcomePage.html +++ b/src/network/html/WelcomePage.html @@ -108,6 +108,7 @@ Open File Manager Settings Font Manager + Reading Stats System Info