Phase 3 - local book stats

This commit is contained in:
jpirnay
2026-05-20 00:08:39 +02:00
parent 991a283e05
commit 4b0c99f35d
8 changed files with 600 additions and 15 deletions
+33
View File
@@ -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<DayBucket>& days) {
for (const auto& d : days) {
JsonArray pair = out.add<JsonArray>();
pair.add(d.dayIndex);
pair.add(d.seconds);
}
};
writeDays(doc["globalDays"].to<JsonArray>(), store.getGlobalDays());
JsonArray arr = doc["books"].to<JsonArray>();
for (const auto& book : store.getBooks()) {
JsonObject obj = arr.add<JsonObject>();
@@ -612,6 +625,7 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
obj["progress"] = book.progress;
obj["finished"] = book.finished;
writeDays(obj["days"].to<JsonArray>(), 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<DayBucket>& 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<JsonArray>(), store.globalDays);
JsonArray arr = doc["books"].as<JsonArray>();
for (JsonObject obj : arr) {
BookReadingStats book;
@@ -646,6 +678,7 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json
book.lastReadEpoch = static_cast<time_t>(obj["lastReadEpoch"] | (int64_t)0);
book.progress = obj["progress"] | (uint8_t)0;
book.finished = obj["finished"] | false;
readDays(obj["days"].as<JsonArray>(), book.days);
store.books.push_back(std::move(book));
}
+99 -4
View File
@@ -1,15 +1,64 @@
#include "ReadingStats.h"
#include <HalClock.h>
#include <HalStorage.h>
#include <JsonSettingsIO.h>
#include <Logging.h>
#include <algorithm>
#include <ctime>
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<DayBucket>& 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<unsigned>(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<long>(doe) - 719468L;
if (days < 1 || days > 65535) return 0; // outside our uint16_t window
return static_cast<uint16_t>(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;
}
+45 -10
View File
@@ -4,23 +4,38 @@
#include <string>
#include <vector>
// 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<DayBucket> 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<DayBucket> 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<DayBucket>& 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();
};
@@ -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<ReadingStatsBookListActivity>(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<uint16_t>(kSparkDays - 1 - i)) ? static_cast<uint16_t>(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<uint16_t>(kSparkDays - 1 - i)) ? static_cast<uint16_t>(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<int>(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();
@@ -0,0 +1,190 @@
#include "ReadingStatsBookDetailActivity.h"
#include <GfxRenderer.h>
#include <HalClock.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#include <ctime>
#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<uint32_t>(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<DayBucket>& 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<uint16_t>(kSparkDays - 1 - i))
? static_cast<uint16_t>(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<uint16_t>(kSparkDays - 1 - i))
? static_cast<uint16_t>(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<int>(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();
}
@@ -0,0 +1,22 @@
#pragma once
#include <string>
#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;
};
@@ -0,0 +1,118 @@
#include "ReadingStatsBookListActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#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<int>(sortedBooks.size())) {
selectedIndex = 0;
}
requestUpdate();
}
void ReadingStatsBookListActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (!sortedBooks.empty()) {
buttonNavigator.onNextList(selectedIndex, static_cast<int>(sortedBooks.size()),
[this]() { requestUpdate(); });
buttonNavigator.onPreviousList(selectedIndex, static_cast<int>(sortedBooks.size()),
[this]() { requestUpdate(); });
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
const BookReadingStats* book = sortedBooks[selectedIndex];
startActivityForResult(
std::make_unique<ReadingStatsBookDetailActivity>(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<int>(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();
}
@@ -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<const BookReadingStats*> sortedBooks;
ButtonNavigator buttonNavigator;
int selectedIndex = 0;
void rebuildSortedBooks();
};