Phase 1 - basic architecture
This commit is contained in:
@@ -529,6 +529,14 @@ STR_OVERLAY_READING_PROGRESS: "Reading progress: Page %lu/%u - %.0f%%"
|
|||||||
STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "Reading progress: Page %lu"
|
STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "Reading progress: Page %lu"
|
||||||
STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Page %d/%d - %.0f%% read"
|
STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Page %d/%d - %.0f%% read"
|
||||||
STR_SYSTEM_INFO: "System Information"
|
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_LOAD_XTC_FAILED: "Failed to load XTC file"
|
STR_LOAD_XTC_FAILED: "Failed to load XTC file"
|
||||||
STR_LOAD_EPUB_FAILED: "Failed to load EPUB file"
|
STR_LOAD_EPUB_FAILED: "Failed to load EPUB file"
|
||||||
STR_FW_VERSION: "FW version"
|
STR_FW_VERSION: "FW version"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
#include "ReadingStats.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "SettingsList.h"
|
#include "SettingsList.h"
|
||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
@@ -589,3 +590,65 @@ bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* ne
|
|||||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
||||||
return true;
|
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();
|
||||||
|
|
||||||
|
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["finished"] = book.finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.globalTotalSeconds = doc["totalSeconds"] | (uint32_t)0;
|
||||||
|
store.globalTotalSessions = doc["totalSessions"] | (uint32_t)0;
|
||||||
|
store.globalTotalPagesTurned = doc["totalPagesTurned"] | (uint32_t)0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
book.finished = obj["finished"] | false;
|
||||||
|
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 KOReaderCredentialStore;
|
||||||
class RecentBooksStore;
|
class RecentBooksStore;
|
||||||
class OpdsServerStore;
|
class OpdsServerStore;
|
||||||
|
class ReadingStatsStore;
|
||||||
|
|
||||||
namespace JsonSettingsIO {
|
namespace JsonSettingsIO {
|
||||||
|
|
||||||
@@ -33,4 +34,8 @@ bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
|||||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
bool saveOpds(const OpdsServerStore& store, const char* path);
|
||||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
|
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
|
} // namespace JsonSettingsIO
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#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::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));
|
||||||
|
READING_STATS.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,84 @@
|
|||||||
|
#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);
|
||||||
|
|
||||||
|
// 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,73 @@
|
|||||||
|
#include "ReadingStats.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <JsonSettingsIO.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr char READING_STATS_FILE[] = "/.crosspoint/reading-stats.json";
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalTotalSeconds += sessionSeconds;
|
||||||
|
globalTotalSessions += 1;
|
||||||
|
globalTotalPagesTurned += sessionPagesTurned;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,74 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Per-book reading statistics. Keyed by KOReader document hash (or filename
|
||||||
|
// hash fallback) so a renamed/moved file keeps its history.
|
||||||
|
//
|
||||||
|
// Phase-1 schema is intentionally narrow: aggregate counters plus first/last
|
||||||
|
// timestamps. Day-bucket history for sparklines/heatmaps lands in phase 2.
|
||||||
|
struct BookReadingStats {
|
||||||
|
std::string docId;
|
||||||
|
std::string title;
|
||||||
|
std::string author;
|
||||||
|
uint32_t totalSeconds = 0; // idle-clamped, sum across all sessions
|
||||||
|
uint32_t pagesTurned = 0; // forward + backward
|
||||||
|
uint32_t sessions = 0; // session-open count
|
||||||
|
// 0 if HalClock was never synced when the session ran. Treat as "unknown".
|
||||||
|
time_t firstReadEpoch = 0;
|
||||||
|
time_t lastReadEpoch = 0;
|
||||||
|
uint8_t progress = 0; // 0-100, snapshot of last known progress
|
||||||
|
bool finished = false; // user-marked finished (Phase 1: always false)
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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). 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);
|
||||||
|
|
||||||
|
// Lookup by document hash; returns nullptr if unknown.
|
||||||
|
const BookReadingStats* findBook(const std::string& docId) 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(); }
|
||||||
|
|
||||||
|
bool saveToFile() const;
|
||||||
|
bool loadFromFile();
|
||||||
|
};
|
||||||
|
|
||||||
|
#define READING_STATS ReadingStatsStore::getInstance()
|
||||||
@@ -30,10 +30,12 @@
|
|||||||
#include "FinishedBookActivity.h"
|
#include "FinishedBookActivity.h"
|
||||||
#include "GlobalBookmarkIndex.h"
|
#include "GlobalBookmarkIndex.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
|
#include "KOReaderDocumentId.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "QrDisplayActivity.h"
|
#include "QrDisplayActivity.h"
|
||||||
#include "ReaderActivity.h"
|
#include "ReaderActivity.h"
|
||||||
#include "ReaderUtils.h"
|
#include "ReaderUtils.h"
|
||||||
|
#include "ReadingSessionTracker.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "SdCardFontGlobals.h"
|
#include "SdCardFontGlobals.h"
|
||||||
#include "StarredPagesActivity.h"
|
#include "StarredPagesActivity.h"
|
||||||
@@ -324,6 +326,13 @@ void EpubReaderActivity::onEnter() {
|
|||||||
bookParagraphAlignmentOverride = currentBook.paragraphAlignmentOverride;
|
bookParagraphAlignmentOverride = currentBook.paragraphAlignmentOverride;
|
||||||
logReaderMemSnapshot("onEnter_after_recent_books");
|
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
|
// Trigger first update
|
||||||
logReaderMemSnapshot("onEnter_before_request_update");
|
logReaderMemSnapshot("onEnter_before_request_update");
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
@@ -334,6 +343,11 @@ void EpubReaderActivity::onExit() {
|
|||||||
Activity::onExit();
|
Activity::onExit();
|
||||||
logReaderMemSnapshot("onExit_before_release");
|
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();
|
||||||
|
|
||||||
// Save bookmarks before exit
|
// Save bookmarks before exit
|
||||||
bookmarkStore.save();
|
bookmarkStore.save();
|
||||||
if (epub) {
|
if (epub) {
|
||||||
@@ -1537,6 +1551,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
|||||||
preRenderedPage.ready = false;
|
preRenderedPage.ready = false;
|
||||||
usePreRenderedBuffer = true;
|
usePreRenderedBuffer = true;
|
||||||
sessionPagesAdvanced++;
|
sessionPagesAdvanced++;
|
||||||
|
globalReadingSessionTracker().onPageTurn();
|
||||||
lastPageTurnTime = millis();
|
lastPageTurnTime = millis();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
@@ -1546,6 +1561,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionPagesAdvanced++;
|
sessionPagesAdvanced++;
|
||||||
|
globalReadingSessionTracker().onPageTurn();
|
||||||
preRenderedPage.ready = false;
|
preRenderedPage.ready = false;
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
@@ -1921,6 +1937,7 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
|
|||||||
LOG_ERR("ERS", "Could not save progress!");
|
LOG_ERR("ERS", "Could not save progress!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
globalReadingSessionTracker().updateProgress(percent);
|
||||||
LOG_DBG("ERS", "Progress saved: Chapter %d, Page %d (%d%%)", spineIndex, currentPage, 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,
|
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#include "ReadingStatsActivity.h"
|
||||||
|
|
||||||
|
#include <Arduino.h> // millis()
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "ReadingSessionTracker.h"
|
||||||
|
#include "ReadingStats.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// "1h 23m" / "23m 45s" / "12s" — Phase 1 keeps it compact so a long row fits
|
||||||
|
// the right column without truncation on the X3's narrow screen.
|
||||||
|
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 ReadingStatsActivity::onEnter() {
|
||||||
|
Activity::onEnter();
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReadingStatsActivity::loop() {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If a reading session happens to be live (e.g. a future entry point lets
|
||||||
|
// the user pop this screen mid-read), tick at most once per second so
|
||||||
|
// "this session" moves visibly without hammering the e-ink panel.
|
||||||
|
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 leftX = contentRect.x + metrics.verticalSpacing * 3;
|
||||||
|
const int valueX = contentRect.x + contentRect.width / 2;
|
||||||
|
const int lineH = renderer.getLineHeight(UI_10_FONT_ID);
|
||||||
|
const int rowStep = lineH + 2;
|
||||||
|
const int subHeaderHeight = lineH + 6;
|
||||||
|
int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||||
|
|
||||||
|
auto drawSection = [&](const char* title) {
|
||||||
|
GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title);
|
||||||
|
y += subHeaderHeight + 2;
|
||||||
|
};
|
||||||
|
auto drawRow = [&](const char* label, const std::string& value) {
|
||||||
|
renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str());
|
||||||
|
y += rowStep;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto& store = READING_STATS;
|
||||||
|
auto& tracker = globalReadingSessionTracker();
|
||||||
|
|
||||||
|
// ---- Live session (only if currently reading) ----
|
||||||
|
if (tracker.isActive()) {
|
||||||
|
drawSection(tr(STR_READING_STATS_CURRENT_SESSION));
|
||||||
|
drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds()));
|
||||||
|
drawRow(tr(STR_READING_STATS_PAGES), std::to_string(tracker.getLivePages()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- All time ----
|
||||||
|
drawSection(tr(STR_READING_STATS_TOTAL_TIME));
|
||||||
|
if (store.getGlobalTotalSeconds() == 0) {
|
||||||
|
drawRow("", tr(STR_READING_STATS_NO_DATA));
|
||||||
|
} else {
|
||||||
|
drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds()));
|
||||||
|
drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(store.getGlobalTotalSessions()));
|
||||||
|
drawRow(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned()));
|
||||||
|
drawRow(tr(STR_READING_STATS_BOOKS), std::to_string(store.getBookCount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Top books (up to 3 by total time) ----
|
||||||
|
if (!store.getBooks().empty()) {
|
||||||
|
std::vector<const BookReadingStats*> sorted;
|
||||||
|
sorted.reserve(store.getBooks().size());
|
||||||
|
for (const auto& b : store.getBooks()) sorted.push_back(&b);
|
||||||
|
std::sort(sorted.begin(), sorted.end(),
|
||||||
|
[](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; });
|
||||||
|
|
||||||
|
drawSection(tr(STR_READING_STATS_TOP_BOOKS));
|
||||||
|
const size_t shown = std::min<size_t>(sorted.size(), 3);
|
||||||
|
for (size_t i = 0; i < shown; ++i) {
|
||||||
|
const auto* b = sorted[i];
|
||||||
|
// Use the title when known; fall back to docId so the row is never
|
||||||
|
// empty even before metadata is recorded.
|
||||||
|
std::string label = b->title.empty() ? b->docId : b->title;
|
||||||
|
// Trim to fit; 22 chars keeps it in the left column at UI_10.
|
||||||
|
if (label.size() > 22) {
|
||||||
|
label.resize(22);
|
||||||
|
label += "…";
|
||||||
|
}
|
||||||
|
drawRow(label.c_str(), formatDuration(b->totalSeconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "LanguageSelectActivity.h"
|
#include "LanguageSelectActivity.h"
|
||||||
#include "OpdsServerListActivity.h"
|
#include "OpdsServerListActivity.h"
|
||||||
#include "OtaUpdateActivity.h"
|
#include "OtaUpdateActivity.h"
|
||||||
|
#include "ReadingStatsActivity.h"
|
||||||
#include "SdFirmwareUpdateActivity.h"
|
#include "SdFirmwareUpdateActivity.h"
|
||||||
#include "StatusBarSettingsActivity.h"
|
#include "StatusBarSettingsActivity.h"
|
||||||
#include "SyncTimeActivity.h"
|
#include "SyncTimeActivity.h"
|
||||||
@@ -52,6 +53,8 @@ std::unique_ptr<Activity> createActivityForAction(SettingAction action, GfxRende
|
|||||||
return std::make_unique<SyncTimeActivity>(renderer, mappedInput);
|
return std::make_unique<SyncTimeActivity>(renderer, mappedInput);
|
||||||
case SettingAction::DetectTimezone:
|
case SettingAction::DetectTimezone:
|
||||||
return std::make_unique<DetectTimezoneActivity>(renderer, mappedInput);
|
return std::make_unique<DetectTimezoneActivity>(renderer, mappedInput);
|
||||||
|
case SettingAction::ReadingStats:
|
||||||
|
return std::make_unique<ReadingStatsActivity>(renderer, mappedInput);
|
||||||
case SettingAction::Submenu:
|
case SettingAction::Submenu:
|
||||||
case SettingAction::None:
|
case SettingAction::None:
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ enum class SettingAction {
|
|||||||
DetectTimezone,
|
DetectTimezone,
|
||||||
SyncTime,
|
SyncTime,
|
||||||
Weather,
|
Weather,
|
||||||
|
ReadingStats,
|
||||||
Submenu,
|
Submenu,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ void SettingsActivity::onEnter() {
|
|||||||
addToMoved(systemSettings, lastSystemSub,
|
addToMoved(systemSettings, lastSystemSub,
|
||||||
std::move(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo)
|
std::move(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo)
|
||||||
.withSubcategory(StrId::STR_MENU_SYS_SYSTEM)));
|
.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(displaySettings, submenuData);
|
||||||
SettingInfo::prepareSubmenus(readerSettings, submenuData);
|
SettingInfo::prepareSubmenus(readerSettings, submenuData);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
#include "ReadingStats.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "SdCardFontSystem.h"
|
#include "SdCardFontSystem.h"
|
||||||
#include "SilentRestart.h"
|
#include "SilentRestart.h"
|
||||||
@@ -384,6 +385,7 @@ void setup() {
|
|||||||
HalClock::restore();
|
HalClock::restore();
|
||||||
RECENT_BOOKS.loadFromFile();
|
RECENT_BOOKS.loadFromFile();
|
||||||
GLOBAL_BOOKMARKS.load();
|
GLOBAL_BOOKMARKS.load();
|
||||||
|
READING_STATS.loadFromFile();
|
||||||
|
|
||||||
if (recoveryFirmwareMode) {
|
if (recoveryFirmwareMode) {
|
||||||
// Skip normal home/reader routing: jump straight into the SD firmware picker.
|
// Skip normal home/reader routing: jump straight into the SD firmware picker.
|
||||||
|
|||||||
Reference in New Issue
Block a user