From 4b0c99f35dca973c185a6d3b07f0dfea624e7ab2 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 20 May 2026 00:08:39 +0200 Subject: [PATCH] Phase 3 - local book stats --- src/JsonSettingsIO.cpp | 33 +++ src/ReadingStats.cpp | 103 +++++++++- src/ReadingStats.h | 55 ++++- .../settings/ReadingStatsActivity.cpp | 66 +++++- .../ReadingStatsBookDetailActivity.cpp | 190 ++++++++++++++++++ .../settings/ReadingStatsBookDetailActivity.h | 22 ++ .../settings/ReadingStatsBookListActivity.cpp | 118 +++++++++++ .../settings/ReadingStatsBookListActivity.h | 28 +++ 8 files changed, 600 insertions(+), 15 deletions(-) create mode 100644 src/activities/settings/ReadingStatsBookDetailActivity.cpp create mode 100644 src/activities/settings/ReadingStatsBookDetailActivity.h create mode 100644 src/activities/settings/ReadingStatsBookListActivity.cpp create mode 100644 src/activities/settings/ReadingStatsBookListActivity.h diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index d49d0f5a..18613f15 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -599,6 +599,19 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char doc["totalSessions"] = store.getGlobalTotalSessions(); doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned(); + // Day buckets are serialised as a flat array of [dayIndex, seconds] pairs + // to keep the file compact when many days are populated. The C++ side + // already keeps days sorted, so we preserve that on disk too. + auto writeDays = [](JsonArray out, const std::vector& days) { + for (const auto& d : days) { + JsonArray pair = out.add(); + pair.add(d.dayIndex); + pair.add(d.seconds); + } + }; + + writeDays(doc["globalDays"].to(), store.getGlobalDays()); + JsonArray arr = doc["books"].to(); for (const auto& book : store.getBooks()) { JsonObject obj = arr.add(); @@ -612,6 +625,7 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char obj["lastReadEpoch"] = static_cast(book.lastReadEpoch); obj["progress"] = book.progress; obj["finished"] = book.finished; + writeDays(obj["days"].to(), book.days); } String json; @@ -628,10 +642,28 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json } store.books.clear(); + store.globalDays.clear(); store.globalTotalSeconds = doc["totalSeconds"] | (uint32_t)0; store.globalTotalSessions = doc["totalSessions"] | (uint32_t)0; store.globalTotalPagesTurned = doc["totalPagesTurned"] | (uint32_t)0; + // Reads [dayIndex, seconds] pairs into a DayBucket vector, dropping + // malformed entries. We don't re-sort because saver writes in order; the + // result of accidentally hand-edited unsorted input is just degraded + // streak/sparkline accuracy, not a crash. + auto readDays = [](JsonArray in, std::vector& out) { + for (JsonArray pair : in) { + if (pair.size() < 2) continue; + DayBucket b; + b.dayIndex = pair[0] | (uint16_t)0; + b.seconds = pair[1] | (uint32_t)0; + if (b.dayIndex == 0 || b.seconds == 0) continue; + out.push_back(b); + } + }; + + readDays(doc["globalDays"].as(), store.globalDays); + JsonArray arr = doc["books"].as(); for (JsonObject obj : arr) { BookReadingStats book; @@ -646,6 +678,7 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json book.lastReadEpoch = static_cast(obj["lastReadEpoch"] | (int64_t)0); book.progress = obj["progress"] | (uint8_t)0; book.finished = obj["finished"] | false; + readDays(obj["days"].as(), book.days); store.books.push_back(std::move(book)); } diff --git a/src/ReadingStats.cpp b/src/ReadingStats.cpp index cbfb34d5..83b0563f 100644 --- a/src/ReadingStats.cpp +++ b/src/ReadingStats.cpp @@ -1,15 +1,64 @@ #include "ReadingStats.h" +#include #include #include #include #include +#include namespace { constexpr char READING_STATS_FILE[] = "/.crosspoint/reading-stats.json"; + +// Add `seconds` to the bucket for `dayIndex` in `days`, inserting in sorted +// position if absent. dayIndex == 0 ("unknown day") is silently skipped here — +// the caller decides whether to credit unknown-day reading to a sentinel +// bucket or drop it entirely. +void mergeDay(std::vector& days, uint16_t dayIndex, uint32_t seconds) { + if (dayIndex == 0 || seconds == 0) return; + auto it = std::lower_bound(days.begin(), days.end(), dayIndex, + [](const DayBucket& b, uint16_t v) { return b.dayIndex < v; }); + if (it != days.end() && it->dayIndex == dayIndex) { + it->seconds += seconds; + } else { + days.insert(it, {dayIndex, seconds}); + } +} + +uint16_t dayIndexFromLocaltime(const struct tm& t) { + // Days since 1970-01-01 by Y/M/D in local time. Uses the proleptic + // Gregorian calendar — close enough for a 65k-day uint16 range (≈179 + // years). We deliberately do NOT call mktime() to avoid DST round-trip + // surprises near transition midnights. + const int year = t.tm_year + 1900; + const int month = t.tm_mon + 1; + const int day = t.tm_mday; + // Howard Hinnant's days-from-civil, lightly inlined. + const int y = year - (month <= 2 ? 1 : 0); + const int era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = static_cast(y - era * 400); + const unsigned doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + const long days = era * 146097L + static_cast(doe) - 719468L; + if (days < 1 || days > 65535) return 0; // outside our uint16_t window + return static_cast(days); +} + } // namespace +uint16_t localDayIndexFromEpoch(time_t epoch) { + if (epoch == 0) return 0; + struct tm t{}; + localtime_r(&epoch, &t); + return dayIndexFromLocaltime(t); +} + +uint16_t currentLocalDayIndex() { + if (!HalClock::isSynced()) return 0; + return localDayIndexFromEpoch(HalClock::now()); +} + ReadingStatsStore ReadingStatsStore::instance; void ReadingStatsStore::recordSession(const std::string& docId, const std::string& title, const std::string& author, @@ -20,8 +69,7 @@ void ReadingStatsStore::recordSession(const std::string& docId, const std::strin return; } - auto it = std::find_if(books.begin(), books.end(), - [&docId](const BookReadingStats& b) { return b.docId == docId; }); + auto it = std::find_if(books.begin(), books.end(), [&docId](const BookReadingStats& b) { return b.docId == docId; }); if (it == books.end()) { BookReadingStats fresh; fresh.docId = docId; @@ -43,6 +91,12 @@ void ReadingStatsStore::recordSession(const std::string& docId, const std::strin if (walltimeEpoch != 0) { if (it->firstReadEpoch == 0) it->firstReadEpoch = walltimeEpoch; it->lastReadEpoch = walltimeEpoch; + // Credit the local-day buckets on both the book and the global map. + // We only do this when the clock is trustworthy; unknown-day sessions + // still contribute to the running totals above but not to streaks. + const uint16_t day = localDayIndexFromEpoch(walltimeEpoch); + mergeDay(it->days, day, sessionSeconds); + mergeDay(globalDays, day, sessionSeconds); } globalTotalSeconds += sessionSeconds; @@ -50,9 +104,50 @@ void ReadingStatsStore::recordSession(const std::string& docId, const std::strin globalTotalPagesTurned += sessionPagesTurned; } +uint32_t ReadingStatsStore::getSecondsForDay(uint16_t dayIndex) const { + if (dayIndex == 0) return 0; + auto it = std::lower_bound(globalDays.begin(), globalDays.end(), dayIndex, + [](const DayBucket& b, uint16_t v) { return b.dayIndex < v; }); + if (it != globalDays.end() && it->dayIndex == dayIndex) return it->seconds; + return 0; +} + +uint16_t ReadingStatsStore::computeCurrentStreak(uint16_t today) const { + if (today == 0 || globalDays.empty()) return 0; + // 1-day grace: if there's no reading today, the streak may still end at + // yesterday. After that the chain is broken. + uint16_t anchor = today; + if (getSecondsForDay(anchor) == 0) { + if (anchor == 0) return 0; + anchor -= 1; + if (getSecondsForDay(anchor) == 0) return 0; + } + uint16_t streak = 0; + while (anchor > 0 && getSecondsForDay(anchor) > 0) { + streak += 1; + if (anchor == 1) break; + anchor -= 1; + } + return streak; +} + +uint16_t ReadingStatsStore::computeLongestStreak() const { + if (globalDays.empty()) return 0; + uint16_t longest = 1; + uint16_t run = 1; + for (size_t i = 1; i < globalDays.size(); ++i) { + if (globalDays[i].dayIndex == globalDays[i - 1].dayIndex + 1) { + run += 1; + if (run > longest) longest = run; + } else { + run = 1; + } + } + return longest; +} + const BookReadingStats* ReadingStatsStore::findBook(const std::string& docId) const { - auto it = std::find_if(books.begin(), books.end(), - [&docId](const BookReadingStats& b) { return b.docId == docId; }); + auto it = std::find_if(books.begin(), books.end(), [&docId](const BookReadingStats& b) { return b.docId == docId; }); return it == books.end() ? nullptr : &*it; } diff --git a/src/ReadingStats.h b/src/ReadingStats.h index 476a0630..c9328d38 100644 --- a/src/ReadingStats.h +++ b/src/ReadingStats.h @@ -4,23 +4,38 @@ #include #include +// Day buckets are keyed by an ordinal day count (days since 1970-01-01 in +// LOCAL time, computed by localDayIndex() below). A "reading day" is the +// calendar day the session ENDED in — phase 2 keeps this simple and doesn't +// model the KOReader "day shift" / hour cutoff setting yet. +struct DayBucket { + uint16_t dayIndex = 0; + uint32_t seconds = 0; +}; + +// Helpers — both return 0 when HalClock is unsynced (caller should skip). +uint16_t localDayIndexFromEpoch(time_t epoch); +uint16_t currentLocalDayIndex(); + // Per-book reading statistics. Keyed by KOReader document hash (or filename // hash fallback) so a renamed/moved file keeps its history. -// -// Phase-1 schema is intentionally narrow: aggregate counters plus first/last -// timestamps. Day-bucket history for sparklines/heatmaps lands in phase 2. struct BookReadingStats { std::string docId; std::string title; std::string author; - uint32_t totalSeconds = 0; // idle-clamped, sum across all sessions - uint32_t pagesTurned = 0; // forward + backward - uint32_t sessions = 0; // session-open count + uint32_t totalSeconds = 0; // idle-clamped, sum across all sessions + uint32_t pagesTurned = 0; // forward + backward + uint32_t sessions = 0; // session-open count // 0 if HalClock was never synced when the session ran. Treat as "unknown". time_t firstReadEpoch = 0; time_t lastReadEpoch = 0; - uint8_t progress = 0; // 0-100, snapshot of last known progress - bool finished = false; // user-marked finished (Phase 1: always false) + uint8_t progress = 0; // 0-100, snapshot of last known progress + bool finished = false; // user-marked finished (Phase 1: always false) + // Sparse day buckets, sorted ascending by dayIndex. Only days with reading + // are stored — the typical case is a few dozen entries. Bucket with + // dayIndex == 0 is reserved for "clock-unknown" sessions and is excluded + // from sparklines/streaks but kept so totals remain consistent. + std::vector days; }; class ReadingStatsStore; @@ -45,6 +60,9 @@ class ReadingStatsStore { uint32_t globalTotalSeconds = 0; uint32_t globalTotalSessions = 0; uint32_t globalTotalPagesTurned = 0; + // Global per-day reading time, sorted ascending. Same shape as per-book. + // Used to compute streaks and the sparkline on the stats screen. + std::vector globalDays; friend bool JsonSettingsIO::loadReadingStats(ReadingStatsStore&, const char*); @@ -53,8 +71,10 @@ class ReadingStatsStore { // Apply a finished session to the store. Creates a per-book entry on first // use. Increments aggregate counters. Updates first/last epoch when - // walltimeEpoch != 0 (HalClock was synced). Caller is responsible for - // calling saveToFile() — we don't auto-persist on every page turn. + // walltimeEpoch != 0 (HalClock was synced). When walltimeEpoch != 0, also + // credits the session into a local-day bucket on both the book and the + // global map. Caller is responsible for calling saveToFile() — we don't + // auto-persist on every page turn. void recordSession(const std::string& docId, const std::string& title, const std::string& author, uint32_t sessionSeconds, uint32_t sessionPagesTurned, uint8_t progress, time_t walltimeEpoch); @@ -67,6 +87,21 @@ class ReadingStatsStore { uint32_t getGlobalTotalPagesTurned() const { return globalTotalPagesTurned; } size_t getBookCount() const { return books.size(); } + // Read-only view of the global day map. + const std::vector& getGlobalDays() const { return globalDays; } + + // Seconds read on a specific local-day index. 0 if unknown. + uint32_t getSecondsForDay(uint16_t dayIndex) const; + + // Current streak in days, ending at `today` (or `today-1` for a 1-day grace + // so a session that ended just past midnight still extends yesterday's + // streak when you check this morning). Returns 0 if globalDays is empty or + // if HalClock is unsynced (so `today == 0`). + uint16_t computeCurrentStreak(uint16_t today) const; + + // Longest run of consecutive days with any reading. + uint16_t computeLongestStreak() const; + bool saveToFile() const; bool loadFromFile(); }; diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp index a0f2671d..ee102288 100644 --- a/src/activities/settings/ReadingStatsActivity.cpp +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -11,6 +11,7 @@ #include "MappedInputManager.h" #include "ReadingSessionTracker.h" #include "ReadingStats.h" +#include "ReadingStatsBookListActivity.h" #include "components/UITheme.h" #include "fontIds.h" @@ -45,6 +46,13 @@ void ReadingStatsActivity::loop() { finish(); return; } + // Confirm opens the all-books list when there's anything to drill into. + // Suppressed when the store is empty so the button hint never lies. + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm) && !READING_STATS.getBooks().empty()) { + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult&) { requestUpdate(); }); + return; + } // If a reading session happens to be live (e.g. a future entry point lets // the user pop this screen mid-read), tick at most once per second so // "this session" moves visibly without hammering the e-ink panel. @@ -103,6 +111,61 @@ void ReadingStatsActivity::render(RenderLock&&) { drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(store.getGlobalTotalSessions())); drawRow(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned())); drawRow(tr(STR_READING_STATS_BOOKS), std::to_string(store.getBookCount())); + + // Streaks: only meaningful when at least one wall-clocked session exists. + if (!store.getGlobalDays().empty()) { + const uint16_t today = currentLocalDayIndex(); + const uint16_t current = store.computeCurrentStreak(today); + const uint16_t longest = store.computeLongestStreak(); + char buf[24]; + snprintf(buf, sizeof(buf), "%u%s / %u%s", current, tr(STR_READING_STATS_DAYS_UNIT), longest, + tr(STR_READING_STATS_DAYS_UNIT)); + drawRow(tr(STR_READING_STATS_STREAK), buf); + } + } + + // ---- 30-day sparkline ---- + // Renders one bar per day for the last 30 local days ending at "today". + // Height of each bar is proportional to that day's seconds vs. the maximum + // seen in the window. Days with no reading get a flat 1px baseline so the + // gap pattern stays visible. Drawn only when the clock is synced — without + // it we have no "today" to anchor the window against. + const uint16_t today = currentLocalDayIndex(); + if (today != 0 && !store.getGlobalDays().empty()) { + drawSection(tr(STR_READING_STATS_LAST_30D)); + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 38; + constexpr int kBarGap = 1; + const int sparkLeft = leftX; + const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; + const int sparkWidth = std::max(0, sparkRight - sparkLeft); + const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; + const int sparkOriginY = y; + + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = + (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; + const uint32_t s = store.getSecondsForDay(d); + if (s > maxSeconds) maxSeconds = s; + } + + // Baseline (axis) — 1px line under the bars so the visual grouping reads + // as a chart even when most days are empty. + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, sparkOriginY + kSparkHeight, + true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = + (today > static_cast(kSparkDays - 1 - i)) ? static_cast(today - (kSparkDays - 1 - i)) : 0; + const uint32_t s = store.getSecondsForDay(d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + // 1px minimum so empty days still tick on the axis. + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + y += kSparkHeight + 6; } // ---- Top books (up to 3 by total time) ---- @@ -129,7 +192,8 @@ void ReadingStatsActivity::render(RenderLock&&) { } } - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + const char* btn2 = store.getBooks().empty() ? "" : tr(STR_READING_STATS_BOOK_LIST); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), btn2, "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp new file mode 100644 index 00000000..7daea1ad --- /dev/null +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -0,0 +1,190 @@ +#include "ReadingStatsBookDetailActivity.h" + +#include +#include +#include + +#include +#include +#include + +#include "MappedInputManager.h" +#include "ReadingStats.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { + +std::string formatDuration(uint32_t totalSeconds) { + const uint32_t h = totalSeconds / 3600; + const uint32_t m = (totalSeconds % 3600) / 60; + const uint32_t s = totalSeconds % 60; + char buf[24]; + if (h > 0) { + snprintf(buf, sizeof(buf), "%uh %02um", h, m); + } else if (m > 0) { + snprintf(buf, sizeof(buf), "%um %02us", m, s); + } else { + snprintf(buf, sizeof(buf), "%us", s); + } + return buf; +} + +// "today", "yesterday", "N days ago", or "YYYY-MM-DD" beyond a month. +// Returns "—" when epoch is 0 or the clock isn't synced. +std::string formatDateOrRelative(time_t epoch) { + if (epoch == 0 || !HalClock::isSynced()) { + return tr(STR_READING_STATS_UNKNOWN); + } + const time_t now = HalClock::now(); + if (now <= epoch) return "just now"; + const uint32_t delta = static_cast(now - epoch); + char buf[24]; + if (delta < 60) return "just now"; + if (delta < 3600) { + snprintf(buf, sizeof(buf), "%um ago", delta / 60); + return buf; + } + if (delta < 86400) { + snprintf(buf, sizeof(buf), "%uh ago", delta / 3600); + return buf; + } + const uint32_t days = delta / 86400; + if (days < 30) { + snprintf(buf, sizeof(buf), "%ud ago", days); + return buf; + } + // Past a month, the relative form ("60d ago") is noisier than a date. + struct tm t {}; + localtime_r(&epoch, &t); + snprintf(buf, sizeof(buf), "%04d-%02d-%02d", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday); + return buf; +} + +uint32_t secondsForDayIn(const std::vector& days, uint16_t dayIndex) { + if (dayIndex == 0) return 0; + auto it = std::lower_bound(days.begin(), days.end(), dayIndex, + [](const DayBucket& b, uint16_t v) { return b.dayIndex < v; }); + if (it != days.end() && it->dayIndex == dayIndex) return it->seconds; + return 0; +} + +} // namespace + +void ReadingStatsBookDetailActivity::onEnter() { + Activity::onEnter(); + requestUpdate(); +} + +void ReadingStatsBookDetailActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } +} + +void ReadingStatsBookDetailActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, /*hasBottomHints=*/true, /*hasSideHints=*/false); + const auto& store = READING_STATS; + const BookReadingStats* book = store.findBook(docId); + + renderer.clearScreen(); + + // Header: title up to ~28 chars (theme will further clip if the screen is + // narrow). Fallback to docId so we still produce a usable screen if the + // book's metadata was never recorded. + std::string headerTitle = (book && !book->title.empty()) ? book->title : docId; + if (headerTitle.size() > 28) { + headerTitle.resize(28); + headerTitle += "…"; + } + GUI.drawHeader(renderer, + Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, + headerTitle.c_str(), book && !book->author.empty() ? book->author.c_str() : nullptr); + + const int leftX = contentRect.x + metrics.verticalSpacing * 3; + const int valueX = contentRect.x + contentRect.width / 2; + const int lineH = renderer.getLineHeight(UI_10_FONT_ID); + const int rowStep = lineH + 2; + const int subHeaderHeight = lineH + 6; + int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + + auto drawSection = [&](const char* title) { + GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title); + y += subHeaderHeight + 2; + }; + auto drawRow = [&](const char* label, const std::string& value) { + renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); + renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); + y += rowStep; + }; + + if (!book) { + // The book may have been removed from the store between the list and + // the detail screen (e.g. a future "clear stats for this book" action). + // Show a placeholder rather than crash on a null deref. + drawRow("", tr(STR_READING_STATS_NO_DATA)); + } else { + drawSection(tr(STR_READING_STATS_TOTAL_TIME)); + drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(book->totalSeconds)); + drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(book->sessions)); + drawRow(tr(STR_READING_STATS_PAGES), std::to_string(book->pagesTurned)); + if (book->sessions > 0) { + drawRow(tr(STR_READING_STATS_AVG_SESSION), formatDuration(book->totalSeconds / book->sessions)); + } + char pctBuf[8]; + snprintf(pctBuf, sizeof(pctBuf), "%u%%", book->progress); + drawRow(tr(STR_READING_STATS_PROGRESS), pctBuf); + + drawSection(tr(STR_READING_STATS_FIRST_READ)); + drawRow(tr(STR_READING_STATS_FIRST_READ), formatDateOrRelative(book->firstReadEpoch)); + drawRow(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch)); + + // Per-book 30-day sparkline. Identical algorithm to the main screen but + // reads from this book's own day vector. Hidden when no wall-clocked day + // exists or the clock isn't synced — otherwise the bars would be + // meaningless ("we don't know what day this was"). + const uint16_t today = currentLocalDayIndex(); + if (today != 0 && !book->days.empty()) { + drawSection(tr(STR_READING_STATS_LAST_30D)); + constexpr int kSparkDays = 30; + constexpr int kSparkHeight = 38; + constexpr int kBarGap = 1; + const int sparkLeft = leftX; + const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3; + const int sparkWidth = std::max(0, sparkRight - sparkLeft); + const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays); + const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1); + const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2; + const int sparkOriginY = y; + + uint32_t maxSeconds = 1; + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + if (s > maxSeconds) maxSeconds = s; + } + + renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, + sparkOriginY + kSparkHeight, true); + for (int i = 0; i < kSparkDays; ++i) { + const uint16_t d = (today > static_cast(kSparkDays - 1 - i)) + ? static_cast(today - (kSparkDays - 1 - i)) + : 0; + const uint32_t s = secondsForDayIn(book->days, d); + const int barX = sparkOriginX + i * (barWidth + kBarGap); + const int h = s == 0 ? 1 : std::max(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + y += kSparkHeight + 6; + } + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.h b/src/activities/settings/ReadingStatsBookDetailActivity.h new file mode 100644 index 00000000..4309f3a0 --- /dev/null +++ b/src/activities/settings/ReadingStatsBookDetailActivity.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "activities/Activity.h" + +// Phase-2 sub-screen: per-book reading stats with a 30-day sparkline keyed on +// that book's own day buckets. Constructed with the docId of the book to +// display; resolved against ReadingStatsStore on each render so a session +// that finishes while this screen is open updates the next time we redraw. +class ReadingStatsBookDetailActivity final : public Activity { + public: + ReadingStatsBookDetailActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string docId) + : Activity("ReadingStatsBookDetail", renderer, mappedInput), docId(std::move(docId)) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + std::string docId; +}; diff --git a/src/activities/settings/ReadingStatsBookListActivity.cpp b/src/activities/settings/ReadingStatsBookListActivity.cpp new file mode 100644 index 00000000..161df141 --- /dev/null +++ b/src/activities/settings/ReadingStatsBookListActivity.cpp @@ -0,0 +1,118 @@ +#include "ReadingStatsBookListActivity.h" + +#include +#include + +#include +#include + +#include "MappedInputManager.h" +#include "ReadingStatsBookDetailActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { + +// Same compact format as the main stats screen — kept inline rather than +// shared via a header to keep this slice's footprint small. If a third +// caller appears we'll lift it into a util. +std::string formatDuration(uint32_t totalSeconds) { + const uint32_t h = totalSeconds / 3600; + const uint32_t m = (totalSeconds % 3600) / 60; + const uint32_t s = totalSeconds % 60; + char buf[24]; + if (h > 0) { + snprintf(buf, sizeof(buf), "%uh %02um", h, m); + } else if (m > 0) { + snprintf(buf, sizeof(buf), "%um %02us", m, s); + } else { + snprintf(buf, sizeof(buf), "%us", s); + } + return buf; +} + +} // namespace + +void ReadingStatsBookListActivity::rebuildSortedBooks() { + sortedBooks.clear(); + for (const auto& b : READING_STATS.getBooks()) { + sortedBooks.push_back(&b); + } + std::sort(sortedBooks.begin(), sortedBooks.end(), + [](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; }); +} + +void ReadingStatsBookListActivity::onEnter() { + Activity::onEnter(); + rebuildSortedBooks(); + if (selectedIndex >= static_cast(sortedBooks.size())) { + selectedIndex = 0; + } + requestUpdate(); +} + +void ReadingStatsBookListActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (!sortedBooks.empty()) { + buttonNavigator.onNextList(selectedIndex, static_cast(sortedBooks.size()), + [this]() { requestUpdate(); }); + buttonNavigator.onPreviousList(selectedIndex, static_cast(sortedBooks.size()), + [this]() { requestUpdate(); }); + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + const BookReadingStats* book = sortedBooks[selectedIndex]; + startActivityForResult( + std::make_unique(renderer, mappedInput, book->docId), + // After detail closes the underlying store hasn't changed (it's + // read-only), so just redraw — selectedIndex is preserved. + [this](const ActivityResult&) { requestUpdate(); }); + } + } +} + +void ReadingStatsBookListActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, /*hasBottomHints=*/true, /*hasSideHints=*/false); + + renderer.clearScreen(); + + GUI.drawHeader(renderer, + Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight}, + tr(STR_READING_STATS_BOOK_LIST), nullptr); + + const int contentTop = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = + contentRect.height - (metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 2); + + if (sortedBooks.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, contentTop + contentHeight / 2, tr(STR_READING_STATS_NO_DATA)); + } else { + GUI.drawList( + renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, + static_cast(sortedBooks.size()), selectedIndex, + [this](int index) { + const auto* b = sortedBooks[index]; + // Title is the primary label; fall back to docId so a row without + // metadata is still recognizable. + return b->title.empty() ? b->docId : b->title; + }, + [this](int index) { + // Subtitle row: author, when known. Empty string is treated by the + // theme as "no subtitle" and the row collapses to a single line. + return sortedBooks[index]->author; + }, + nullptr, + [this](int index) { return formatDuration(sortedBooks[index]->totalSeconds); }, true); + } + + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), sortedBooks.empty() ? "" : tr(STR_SELECT), + sortedBooks.empty() ? "" : tr(STR_DIR_UP), sortedBooks.empty() ? "" : tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ReadingStatsBookListActivity.h b/src/activities/settings/ReadingStatsBookListActivity.h new file mode 100644 index 00000000..60c9072d --- /dev/null +++ b/src/activities/settings/ReadingStatsBookListActivity.h @@ -0,0 +1,28 @@ +#pragma once + +#include "ReadingStats.h" +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +// Phase-2 sub-screen: scrollable list of all books with recorded reading time. +// Sorted by total time descending so the most-read books are easiest to reach. +// Selecting a row pushes ReadingStatsBookDetailActivity. +class ReadingStatsBookListActivity final : public Activity { + public: + explicit ReadingStatsBookListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("ReadingStatsBookList", renderer, mappedInput) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + // Cached pointers into ReadingStatsStore::books, sorted by totalSeconds desc. + // Rebuilt on onEnter() so the list reflects the latest state even after a + // session has been recorded between visits. + std::vector sortedBooks; + ButtonNavigator buttonNavigator; + int selectedIndex = 0; + + void rebuildSortedBooks(); +};