Merge pull request #251 from jpirnay/feat-reading-stats
feat: Add first version of reading stats
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_;
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user