Merge pull request #251 from jpirnay/feat-reading-stats

feat: Add first version of reading stats
This commit is contained in:
jpirnay
2026-05-21 11:45:27 +02:00
committed by GitHub
36 changed files with 2409 additions and 6 deletions
+6
View File
@@ -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);
+6
View File
@@ -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);
}
+23
View File
@@ -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"
-1
View File
@@ -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
+108
View File
@@ -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<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>();
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<int64_t>(book.firstReadEpoch);
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
obj["progress"] = book.progress;
obj["finishedCount"] = book.finishedCount;
obj["lastFinishedEpoch"] = static_cast<int64_t>(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<JsonArray>(), 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<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;
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<time_t>(obj["firstReadEpoch"] | (int64_t)0);
book.lastReadEpoch = static_cast<time_t>(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<time_t>(obj["lastFinishedEpoch"] | (int64_t)0);
readDays(obj["days"].as<JsonArray>(), 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;
}
+5
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
#include "ReadingSessionTracker.h"
#include <Arduino.h> // millis()
#include <HalClock.h>
#include <Logging.h>
#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<int64_t>(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<int64_t>(HalClock::now()) : 0;
READING_STATS.markFinished(docId, title, author, static_cast<time_t>(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<uint32_t>(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<int64_t>(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<time_t>(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<uint32_t>(accumulatedMs / 1000);
}
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <cstdint>
#include <string>
// 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();
+228
View File
@@ -0,0 +1,228 @@
#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,
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<size_t>(
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<float>(totalSecondsFromCountedBooks) / static_cast<float>(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<float>(b->totalSeconds) / static_cast<float>(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<uint32_t>(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());
}
+142
View File
@@ -0,0 +1,142 @@
#pragma once
#include <cstdint>
#include <ctime>
#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.
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<DayBucket> 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<BookReadingStats> 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<DayBucket> 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<BookReadingStats>& 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<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();
};
#define READING_STATS ReadingStatsStore::getInstance()
@@ -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<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(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<ReadingStatsBookDetailActivity>(
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<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(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<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(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> page, const int orientedMarginTop,
@@ -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;
}
@@ -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,
+16 -1
View File
@@ -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<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(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<int>(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;
+22 -1
View File
@@ -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<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(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<int>(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:
+23 -2
View File
@@ -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<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(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<int>(currentPage), static_cast<int>(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<int>(currentPage), static_cast<int>(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();
}
}
@@ -0,0 +1,220 @@
#include "ReadingStatsActivity.h"
#include <Arduino.h> // millis()
#include <GfxRenderer.h>
#include <I18n.h>
#include <Utf8.h>
#include <algorithm>
#include <array>
#include <cstdio>
#include <iterator>
#include <utility>
#include <vector>
#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<ReadingStatsBookListActivity>(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<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;
}
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);
const int h = s == 0 ? 1 : std::max<int>(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<const BookReadingStats*> 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<size_t>(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();
}
@@ -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;
};
@@ -0,0 +1,228 @@
#include "ReadingStatsBookDetailActivity.h"
#include <GfxRenderer.h>
#include <HalClock.h>
#include <I18n.h>
#include <Utf8.h>
#include <algorithm>
#include <array>
#include <cstdio>
#include <ctime>
#include <utility>
#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<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;
}
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<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 (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<const unsigned char*>(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<size_t>(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<float>(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<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);
}
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();
}
@@ -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 <iterator>
#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<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. 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();
}
@@ -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();
};
@@ -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<Activity> createActivityForAction(SettingAction action, GfxRende
return std::make_unique<SyncTimeActivity>(renderer, mappedInput);
case SettingAction::DetectTimezone:
return std::make_unique<DetectTimezoneActivity>(renderer, mappedInput);
case SettingAction::ReadingStats:
return std::make_unique<ReadingStatsActivity>(renderer, mappedInput);
case SettingAction::Submenu:
case SettingAction::None:
return nullptr;
+1
View File
@@ -31,6 +31,7 @@ enum class SettingAction {
DetectTimezone,
SyncTime,
Weather,
ReadingStats,
Submenu,
};
@@ -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);
+151
View File
@@ -0,0 +1,151 @@
#include "CardLayout.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <string>
#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<void(Body&)>& 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<std::pair<std::string, const char*>, 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<WrappedLabel, 4> 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_;
}
+114
View File
@@ -0,0 +1,114 @@
#pragma once
#include <array>
#include <functional>
#include <string>
#include <utility>
#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<std::pair<std::string, const char*>, 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<void(Body&)>& 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_;
};
+2
View File
@@ -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.
+95
View File
@@ -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<uint32_t>(store.getBookCount());
doc["finishedBookCount"] = static_cast<uint32_t>(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<JsonArray>();
for (const auto& d : store.getGlobalDays()) {
JsonArray pair = globalDays.add<JsonArray>();
pair.add(d.dayIndex);
pair.add(d.seconds);
}
JsonArray booksArr = doc["books"].to<JsonArray>();
for (const auto& book : store.getBooks()) {
JsonObject obj = booksArr.add<JsonObject>();
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<int64_t>(book.firstReadEpoch);
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
obj["progress"] = book.progress;
obj["finishedCount"] = book.finishedCount;
obj["lastFinishedEpoch"] = static_cast<int64_t>(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<float>(book.progress)) : 0.0f;
obj["etaSeconds"] = store.estimateRemainingSeconds(book.docId, remainingPercent);
obj["secondsPerPercent"] = store.avgSecondsPerPercent(book.docId);
JsonArray days = obj["days"].to<JsonArray>();
for (const auto& d : book.days) {
JsonArray pair = days.add<JsonArray>();
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);
+5
View File
@@ -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;
};
+2
View File
@@ -108,6 +108,7 @@
.nav-links {
margin: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@@ -1821,6 +1822,7 @@
<a href="/files" class="active">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts">Font Manager</a>
<a href="/stats">Reading Stats</a>
<a href="/systeminfo">System Info</a>
</div>
+1
View File
@@ -197,6 +197,7 @@
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts" class="active">Font Manager</a>
<a href="/stats">Reading Stats</a>
<a href="/systeminfo">System Info</a>
</div>
+2
View File
@@ -113,6 +113,7 @@
.nav-links {
margin: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@@ -174,6 +175,7 @@
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts">Font Manager</a>
<a href="/stats">Reading Stats</a>
<a href="/systeminfo" class="active">System Info</a>
</div>
+2
View File
@@ -59,6 +59,7 @@
.nav-links {
margin: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.nav-links a {
@@ -301,6 +302,7 @@
<a href="/files">File Manager</a>
<a href="/settings" class="active">Settings</a>
<a href="/fonts">Font Manager</a>
<a href="/stats">Reading Stats</a>
<a href="/systeminfo">System Info</a>
</div>
+574
View File
@@ -0,0 +1,574 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reading Stats - %%CROSSPOINT%%</title>
<style>
html {
scrollbar-gutter: stable;
}
:root {
--font-color: #333;
--bg: #f5f5f5;
--title-color: #2c3e50;
--card-bg: #fff;
--label-color: #7f8c8d;
--border-color: #eee;
--accent-color: rgb(110, 154, 130);
--accent-hover-color: #5a8c73;
--bar-color: rgb(110, 154, 130);
--bar-empty: #d8e0dc;
}
@media (prefers-color-scheme: dark) {
:root {
--font-color: #f5f5f5;
--bg: #333;
--title-color: #ecf0f1;
--card-bg: #444;
--label-color: #bdc3c7;
--border-color: #555;
--bar-empty: #2f3a35;
color-scheme: dark;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg);
color: var(--font-color);
}
h1 {
color: var(--title-color);
border-bottom: 2px solid var(--accent-color);
padding-bottom: 10px;
}
h2 {
color: var(--title-color);
margin: 0 0 16px 0;
font-size: 1.1rem;
}
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nav-links {
margin: 20px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.nav-links a {
padding: 10px 20px;
color: var(--font-color);
text-decoration: none;
border-radius: 4px;
background: var(--card-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.nav-links a.active {
background: var(--accent-color);
color: white;
}
.nav-links a:hover {
background: var(--accent-hover-color);
color: white;
}
/* 4-cell stat grid — mirrors the on-device card */
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
}
.grid > div {
text-align: center;
padding: 8px 4px;
border-right: 1px solid var(--border-color);
}
.grid > div:last-child {
border-right: none;
}
.grid .value {
font-size: 1.6rem;
font-weight: 700;
color: var(--title-color);
}
.grid .label {
font-size: 0.8rem;
color: var(--label-color);
margin-top: 4px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.info-row:last-child {
border-bottom: none;
}
.info-row .label {
font-weight: 600;
color: var(--label-color);
}
.info-row .value {
color: var(--title-color);
font-variant-numeric: tabular-nums;
}
/* Sparkline */
.sparkline {
display: flex;
align-items: flex-end;
gap: 2px;
height: 80px;
margin-top: 10px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.sparkline .bar {
flex: 1;
background: var(--bar-color);
min-height: 2px;
border-radius: 1px 1px 0 0;
}
.sparkline .bar.empty {
background: var(--bar-empty);
min-height: 1px;
}
.sparkline-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--label-color);
margin-top: 4px;
}
/* Books table */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
th, td {
text-align: left;
padding: 8px;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
color: var(--label-color);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: pointer;
user-select: none;
}
th:hover {
color: var(--title-color);
}
th.sorted-asc::after { content: " ▲"; font-size: 0.7em; }
th.sorted-desc::after { content: " ▼"; font-size: 0.7em; }
td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr.book-row { cursor: pointer; }
tr.book-row:hover td { background: rgba(110, 154, 130, 0.08); }
.book-detail {
background: rgba(0, 0, 0, 0.02);
}
@media (prefers-color-scheme: dark) {
.book-detail { background: rgba(255, 255, 255, 0.03); }
tr.book-row:hover td { background: rgba(110, 154, 130, 0.15); }
}
.book-detail td { padding: 16px; }
.book-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px 16px;
}
.book-detail-grid div {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
}
.book-detail-grid .label {
color: var(--label-color);
font-weight: 600;
}
.empty-state {
text-align: center;
color: var(--label-color);
padding: 30px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.actions a, .actions button {
padding: 10px 16px;
border-radius: 6px;
background: var(--accent-color);
color: white;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 0.95rem;
}
.actions a:hover, .actions button:hover {
background: var(--accent-hover-color);
}
</style>
</head>
<body>
<h1>Reading Stats</h1>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">Files</a>
<a href="/settings">Settings</a>
<a href="/stats" class="active">Stats</a>
<a href="/systeminfo">System</a>
</div>
<div id="content">
<div class="card">
<div class="empty-state">Loading…</div>
</div>
</div>
<script>
// Format a number of seconds as "1h 23m" / "23m 45s" / "12s" — matches
// the on-device format so the dashboard reads as the same product.
function formatDuration(seconds) {
if (!seconds) return "0s";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function formatPagesPerMin(pages, seconds) {
if (seconds < 60 || !pages) return "—";
return ((pages * 60) / seconds).toFixed(1);
}
// Compact "time-to-finish" rendering. For long projections, "1h 23m" is
// less noisy than "1h 23m 45s" — round to the nearest minute and drop the
// seconds.
function formatEta(seconds) {
if (!seconds || seconds <= 0) return "—";
let h = Math.floor(seconds / 3600);
let m = Math.round((seconds % 3600) / 60);
// Rounding can push m to 60 (e.g. 3570s → 0h 60m); carry into hours.
if (m === 60) { h += 1; m = 0; }
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m`;
return "<1m";
}
// Renders a 30-bar sparkline into `host` for the day window
// [todayDayIndex - 29 .. todayDayIndex] using a map of dayIndex → seconds.
function renderSparkline(host, daysMap, todayDayIndex, windowSize = 30) {
host.innerHTML = "";
const bars = document.createElement("div");
bars.className = "sparkline";
let max = 1;
const series = [];
for (let i = 0; i < windowSize; i++) {
const d = todayDayIndex - (windowSize - 1 - i);
const sec = d > 0 ? (daysMap[d] || 0) : 0;
series.push(sec);
if (sec > max) max = sec;
}
for (const sec of series) {
const bar = document.createElement("div");
bar.className = sec > 0 ? "bar" : "bar empty";
bar.style.height = sec > 0 ? `${Math.max(4, (sec / max) * 100)}%` : "2px";
bar.title = sec > 0 ? formatDuration(sec) : "no reading";
bars.appendChild(bar);
}
host.appendChild(bars);
const labels = document.createElement("div");
labels.className = "sparkline-labels";
labels.innerHTML = `<span>${windowSize} days ago</span><span>today</span>`;
host.appendChild(labels);
}
// Format a unix epoch as relative-or-date (matches the on-device formatter).
function formatDateOrRelative(epoch) {
if (!epoch) return "—";
const nowSec = Math.floor(Date.now() / 1000);
const delta = nowSec - epoch;
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
const days = Math.floor(delta / 86400);
if (days < 30) return `${days}d ago`;
const d = new Date(epoch * 1000);
return d.toISOString().slice(0, 10);
}
let currentSort = { col: "totalSeconds", dir: "desc" };
let expandedDocId = null;
function sortBooks(books, col, dir) {
const cmp = (a, b) => {
let av = a[col], bv = b[col];
if (col === "title") {
av = (av || a.docId).toLowerCase();
bv = (bv || b.docId).toLowerCase();
return dir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
}
return dir === "asc" ? av - bv : bv - av;
};
return [...books].sort(cmp);
}
function render(data) {
const content = document.getElementById("content");
content.innerHTML = "";
if (!data || data.totalSeconds === 0) {
const empty = document.createElement("div");
empty.className = "card";
empty.innerHTML = '<div class="empty-state">No reading recorded yet. Open a book and read for a while — your stats will appear here.</div>';
content.appendChild(empty);
return;
}
// ---- All-time card ----
const allTime = document.createElement("div");
allTime.className = "card";
const curStreak = data.currentStreak != null ? String(data.currentStreak) : "—";
const maxStreak = data.longestStreak != null ? String(data.longestStreak) : "—";
const finishedRow = (data.finishedBookCount && data.finishedBookCount > 0)
? `<div class="info-row"><span class="label">Finished</span><span class="value">${data.finishedBookCount}</span></div>`
: "";
allTime.innerHTML = `
<h2>All time</h2>
<div class="grid">
<div><div class="value">${data.totalSessions}</div><div class="label">Sessions</div></div>
<div><div class="value">${data.bookCount}</div><div class="label">Books</div></div>
<div><div class="value">${curStreak}</div><div class="label">Streak</div></div>
<div><div class="value">${maxStreak}</div><div class="label">Longest</div></div>
</div>
<div style="margin-top: 14px;">
<div class="info-row"><span class="label">Total time</span><span class="value">${formatDuration(data.totalSeconds)}</span></div>
<div class="info-row"><span class="label">Pages turned</span><span class="value">${data.totalPagesTurned}</span></div>
<div class="info-row"><span class="label">Pages/min</span><span class="value">${formatPagesPerMin(data.totalPagesTurned, data.totalSeconds)}</span></div>
${finishedRow}
</div>
`;
content.appendChild(allTime);
// ---- Sparkline card ----
if (data.todayDayIndex && data.globalDays && data.globalDays.length > 0) {
const sparkCard = document.createElement("div");
sparkCard.className = "card";
sparkCard.innerHTML = "<h2>Last 30 days</h2><div id=\"global-spark\"></div>";
content.appendChild(sparkCard);
const daysMap = {};
for (const [day, sec] of data.globalDays) daysMap[day] = sec;
renderSparkline(sparkCard.querySelector("#global-spark"), daysMap, data.todayDayIndex);
}
// ---- Books card ----
const booksCard = document.createElement("div");
booksCard.className = "card";
booksCard.innerHTML = "<h2>Books</h2>";
if (!data.books || data.books.length === 0) {
booksCard.innerHTML += '<div class="empty-state">No books tracked yet.</div>';
content.appendChild(booksCard);
} else {
const table = document.createElement("table");
table.innerHTML = `
<thead>
<tr>
<th data-col="title">Title</th>
<th data-col="totalSeconds" class="num">Time</th>
<th data-col="progress" class="num">Progress</th>
<th data-col="etaSeconds" class="num">To finish</th>
<th data-col="lastReadEpoch" class="num">Last read</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector("tbody");
const sorted = sortBooks(data.books, currentSort.col, currentSort.dir);
for (const b of sorted) {
const tr = document.createElement("tr");
tr.className = "book-row";
tr.dataset.docId = b.docId;
const finishedMark = b.finishedCount > 0
? `<span title="Finished${b.finishedCount > 1 ? ' ' + b.finishedCount + '×' : ''}" style="margin-right:6px">✓</span>`
: "";
const etaCell = b.progress >= 100 ? "done" : formatEta(b.etaSeconds);
tr.innerHTML = `
<td>${finishedMark}${escapeHtml(b.title || b.docId)}</td>
<td class="num">${formatDuration(b.totalSeconds)}</td>
<td class="num">${b.progress}%</td>
<td class="num">${etaCell}</td>
<td class="num">${formatDateOrRelative(b.lastReadEpoch)}</td>
`;
tr.addEventListener("click", () => toggleBookDetail(b, tr, data.todayDayIndex));
tbody.appendChild(tr);
if (expandedDocId === b.docId) {
const detailRow = buildDetailRow(b, data.todayDayIndex);
tbody.appendChild(detailRow);
}
}
booksCard.appendChild(table);
// Header sort
table.querySelectorAll("th").forEach(th => {
const col = th.dataset.col;
if (col === currentSort.col) th.classList.add(`sorted-${currentSort.dir}`);
th.addEventListener("click", () => {
if (currentSort.col === col) {
currentSort.dir = currentSort.dir === "asc" ? "desc" : "asc";
} else {
currentSort.col = col;
currentSort.dir = col === "title" ? "asc" : "desc";
}
render(data);
});
});
content.appendChild(booksCard);
}
// ---- Actions ----
const actions = document.createElement("div");
actions.className = "actions";
actions.innerHTML = '<a href="/api/stats/export" download="reading-stats.json">Download backup</a>';
content.appendChild(actions);
}
function buildDetailRow(book, todayDayIndex) {
const row = document.createElement("tr");
row.className = "book-detail";
const cell = document.createElement("td");
cell.colSpan = 5;
const avg = book.sessions > 0 ? formatDuration(Math.floor(book.totalSeconds / book.sessions)) : "—";
const finishedDetail = book.finishedCount > 0
? `<div><span class="label">Finished</span><span>${book.finishedCount}×${formatDateOrRelative(book.lastFinishedEpoch)}</span></div>`
: "";
const etaDetail = book.progress < 100
? `<div><span class="label">Time to finish</span><span>${formatEta(book.etaSeconds)}</span></div>`
: "";
cell.innerHTML = `
<div class="book-detail-grid">
<div><span class="label">Author</span><span>${escapeHtml(book.author || "—")}</span></div>
<div><span class="label">Progress</span><span>${book.progress}%</span></div>
<div><span class="label">Avg session</span><span>${avg}</span></div>
<div><span class="label">Pages/min</span><span>${formatPagesPerMin(book.pagesTurned, book.totalSeconds)}</span></div>
<div><span class="label">First read</span><span>${formatDateOrRelative(book.firstReadEpoch)}</span></div>
<div><span class="label">Last read</span><span>${formatDateOrRelative(book.lastReadEpoch)}</span></div>
${etaDetail}
${finishedDetail}
</div>
`;
if (todayDayIndex && book.days && book.days.length > 0) {
const sparkHost = document.createElement("div");
sparkHost.style.marginTop = "12px";
const daysMap = {};
for (const [day, sec] of book.days) daysMap[day] = sec;
renderSparkline(sparkHost, daysMap, todayDayIndex);
cell.appendChild(sparkHost);
}
row.appendChild(cell);
return row;
}
function toggleBookDetail(book, tr, todayDayIndex) {
const next = tr.nextElementSibling;
if (next && next.classList.contains("book-detail")) {
next.remove();
expandedDocId = null;
} else {
// Remove any other open detail row
document.querySelectorAll("tr.book-detail").forEach(r => r.remove());
const detailRow = buildDetailRow(book, todayDayIndex);
tr.parentNode.insertBefore(detailRow, tr.nextSibling);
expandedDocId = book.docId;
}
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[c]));
}
async function load() {
try {
const res = await fetch("/api/stats");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
render(data);
} catch (e) {
const content = document.getElementById("content");
content.innerHTML = `<div class="card"><div class="empty-state">Failed to load stats: ${escapeHtml(e.message)}</div></div>`;
}
}
load();
</script>
</body>
</html>
+1
View File
@@ -108,6 +108,7 @@
<a href="/files">Open File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts">Font Manager</a>
<a href="/stats">Reading Stats</a>
<a href="/systeminfo">System Info</a>
</div>
<div class="footer">Fast start page designed for weak connections</div>