Phase 6 - remaining time info
This commit is contained in:
@@ -549,6 +549,9 @@ STR_READING_STATS_FOR_THIS_BOOK: "Reading stats"
|
|||||||
STR_READING_STATS_LONGEST: "Longest"
|
STR_READING_STATS_LONGEST: "Longest"
|
||||||
STR_READING_STATS_PAGES_PER_MIN: "Pages/min"
|
STR_READING_STATS_PAGES_PER_MIN: "Pages/min"
|
||||||
STR_READING_STATS_HISTORY: "History"
|
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_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"
|
||||||
|
|||||||
+1
-2
@@ -15,8 +15,7 @@ upload_speed = 921600
|
|||||||
check_tool = cppcheck
|
check_tool = cppcheck
|
||||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||||
check_skip_packages = yes
|
check_skip_packages = yes
|
||||||
|
would board_upload.flash_size = 16MB
|
||||||
board_upload.flash_size = 16MB
|
|
||||||
board_upload.maximum_size = 16777216
|
board_upload.maximum_size = 16777216
|
||||||
board_upload.offset_address = 0x10000
|
board_upload.offset_address = 0x10000
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -624,7 +624,11 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char
|
|||||||
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
||||||
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
||||||
obj["progress"] = book.progress;
|
obj["progress"] = book.progress;
|
||||||
obj["finished"] = book.finished;
|
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);
|
writeDays(obj["days"].to<JsonArray>(), book.days);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +681,15 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json
|
|||||||
book.firstReadEpoch = static_cast<time_t>(obj["firstReadEpoch"] | (int64_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.lastReadEpoch = static_cast<time_t>(obj["lastReadEpoch"] | (int64_t)0);
|
||||||
book.progress = obj["progress"] | (uint8_t)0;
|
book.progress = obj["progress"] | (uint8_t)0;
|
||||||
book.finished = obj["finished"] | false;
|
// 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);
|
readDays(obj["days"].as<JsonArray>(), book.days);
|
||||||
store.books.push_back(std::move(book));
|
store.books.push_back(std::move(book));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ void ReadingSessionTracker::updateProgress(uint8_t progress) {
|
|||||||
lastKnownProgress = progress;
|
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));
|
||||||
|
READING_STATS.saveToFile();
|
||||||
|
LOG_DBG("RST", "Marked finished doc=%s wall=%lld", docId.c_str(), (long long)walltime);
|
||||||
|
}
|
||||||
|
|
||||||
void ReadingSessionTracker::end() {
|
void ReadingSessionTracker::end() {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
// Final idle flush so we credit the time between the last page turn and now,
|
// Final idle flush so we credit the time between the last page turn and now,
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class ReadingSessionTracker {
|
|||||||
// out when the session is flushed. Cheap; OK to call on every page turn.
|
// out when the session is flushed. Cheap; OK to call on every page turn.
|
||||||
void updateProgress(uint8_t progress);
|
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.
|
// Flush the session into ReadingStatsStore and reset internal state.
|
||||||
// Subsequent onPageTurn() calls are no-ops until begin() is called again.
|
// Subsequent onPageTurn() calls are no-ops until begin() is called again.
|
||||||
// Persists the stats file. If the session contributed no reading time it
|
// Persists the stats file. If the session contributed no reading time it
|
||||||
|
|||||||
@@ -146,11 +146,75 @@ uint16_t ReadingStatsStore::computeLongestStreak() const {
|
|||||||
return longest;
|
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 {
|
||||||
|
size_t n = 0;
|
||||||
|
for (const auto& b : books) {
|
||||||
|
if (b.finishedCount > 0) ++n;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
const BookReadingStats* ReadingStatsStore::findBook(const std::string& docId) const {
|
const BookReadingStats* ReadingStatsStore::findBook(const std::string& docId) const {
|
||||||
auto it = std::find_if(books.begin(), books.end(), [&docId](const BookReadingStats& b) { return b.docId == docId; });
|
auto it = std::find_if(books.begin(), books.end(), [&docId](const BookReadingStats& b) { return b.docId == docId; });
|
||||||
return it == books.end() ? nullptr : &*it;
|
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 {
|
bool ReadingStatsStore::saveToFile() const {
|
||||||
Storage.mkdir("/.crosspoint");
|
Storage.mkdir("/.crosspoint");
|
||||||
return JsonSettingsIO::saveReadingStats(*this, READING_STATS_FILE);
|
return JsonSettingsIO::saveReadingStats(*this, READING_STATS_FILE);
|
||||||
|
|||||||
+35
-2
@@ -29,8 +29,9 @@ struct BookReadingStats {
|
|||||||
// 0 if HalClock was never synced when the session ran. Treat as "unknown".
|
// 0 if HalClock was never synced when the session ran. Treat as "unknown".
|
||||||
time_t firstReadEpoch = 0;
|
time_t firstReadEpoch = 0;
|
||||||
time_t lastReadEpoch = 0;
|
time_t lastReadEpoch = 0;
|
||||||
uint8_t progress = 0; // 0-100, snapshot of last known progress
|
uint8_t progress = 0; // 0-100, snapshot of last known progress
|
||||||
bool finished = false; // user-marked finished (Phase 1: always false)
|
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
|
// Sparse day buckets, sorted ascending by dayIndex. Only days with reading
|
||||||
// are stored — the typical case is a few dozen entries. Bucket with
|
// are stored — the typical case is a few dozen entries. Bucket with
|
||||||
// dayIndex == 0 is reserved for "clock-unknown" sessions and is excluded
|
// dayIndex == 0 is reserved for "clock-unknown" sessions and is excluded
|
||||||
@@ -78,14 +79,46 @@ class ReadingStatsStore {
|
|||||||
void recordSession(const std::string& docId, const std::string& title, const std::string& author,
|
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);
|
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.
|
// Lookup by document hash; returns nullptr if unknown.
|
||||||
const BookReadingStats* findBook(const std::string& docId) const;
|
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; }
|
const std::vector<BookReadingStats>& getBooks() const { return books; }
|
||||||
uint32_t getGlobalTotalSeconds() const { return globalTotalSeconds; }
|
uint32_t getGlobalTotalSeconds() const { return globalTotalSeconds; }
|
||||||
uint32_t getGlobalTotalSessions() const { return globalTotalSessions; }
|
uint32_t getGlobalTotalSessions() const { return globalTotalSessions; }
|
||||||
uint32_t getGlobalTotalPagesTurned() const { return globalTotalPagesTurned; }
|
uint32_t getGlobalTotalPagesTurned() const { return globalTotalPagesTurned; }
|
||||||
size_t getBookCount() const { return books.size(); }
|
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.
|
// Read-only view of the global day map.
|
||||||
const std::vector<DayBucket>& getGlobalDays() const { return globalDays; }
|
const std::vector<DayBucket>& getGlobalDays() const { return globalDays; }
|
||||||
|
|||||||
@@ -505,6 +505,9 @@ void EpubReaderActivity::loop() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
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);
|
const auto& menuResult = std::get<MenuResult>(result.data);
|
||||||
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
||||||
if (SETTINGS.moveFinishedBooksToCompleted) {
|
if (SETTINGS.moveFinishedBooksToCompleted) {
|
||||||
@@ -765,6 +768,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
globalReadingSessionTracker().markFinished();
|
||||||
const auto& menuResult = std::get<MenuResult>(result.data);
|
const auto& menuResult = std::get<MenuResult>(result.data);
|
||||||
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
||||||
if (SETTINGS.moveFinishedBooksToCompleted) {
|
if (SETTINGS.moveFinishedBooksToCompleted) {
|
||||||
@@ -1618,6 +1622,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
globalReadingSessionTracker().markFinished();
|
||||||
const auto& menuResult = std::get<MenuResult>(result.data);
|
const auto& menuResult = std::get<MenuResult>(result.data);
|
||||||
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
||||||
if (SETTINGS.moveFinishedBooksToCompleted) {
|
if (SETTINGS.moveFinishedBooksToCompleted) {
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ void MdReaderActivity::loop() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
globalReadingSessionTracker().markFinished();
|
||||||
const auto& menuResult = std::get<MenuResult>(result.data);
|
const auto& menuResult = std::get<MenuResult>(result.data);
|
||||||
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
||||||
if (SETTINGS.moveFinishedBooksToCompleted) {
|
if (SETTINGS.moveFinishedBooksToCompleted) {
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ void TxtReaderActivity::launchFinishedBookFlow() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
globalReadingSessionTracker().markFinished();
|
||||||
const auto& menuResult = std::get<MenuResult>(result.data);
|
const auto& menuResult = std::get<MenuResult>(result.data);
|
||||||
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
||||||
if (SETTINGS.moveFinishedBooksToCompleted) {
|
if (SETTINGS.moveFinishedBooksToCompleted) {
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ void XtcReaderActivity::loop() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
globalReadingSessionTracker().markFinished();
|
||||||
const auto& menuResult = std::get<MenuResult>(result.data);
|
const auto& menuResult = std::get<MenuResult>(result.data);
|
||||||
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
|
||||||
if (SETTINGS.moveFinishedBooksToCompleted) {
|
if (SETTINGS.moveFinishedBooksToCompleted) {
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ void ReadingStatsActivity::render(RenderLock&&) {
|
|||||||
b.rowLR(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned()));
|
b.rowLR(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned()));
|
||||||
b.rowLR(tr(STR_READING_STATS_PAGES_PER_MIN),
|
b.rowLR(tr(STR_READING_STATS_PAGES_PER_MIN),
|
||||||
formatPagesPerMin(store.getGlobalTotalPagesTurned(), store.getGlobalTotalSeconds()));
|
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 ----
|
// ---- 30-day sparkline card ----
|
||||||
|
|||||||
@@ -143,12 +143,32 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) {
|
|||||||
layout.card(tr(STR_READING_STATS_TOTAL_TIME), [&](CardLayout::Body& b) {
|
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_TOTAL_TIME), formatDuration(book->totalSeconds));
|
||||||
b.rowLR(tr(STR_READING_STATS_PAGES_PER_MIN), formatPagesPerMin(book->pagesTurned, 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 ----
|
// ---- History card ----
|
||||||
layout.card(tr(STR_READING_STATS_HISTORY), [&](CardLayout::Body& b) {
|
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_FIRST_READ), formatDateOrRelative(book->firstReadEpoch));
|
||||||
b.rowLR(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch));
|
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) ----
|
// ---- Per-book sparkline (only when clock-anchored data exists) ----
|
||||||
|
|||||||
@@ -94,8 +94,11 @@ void ReadingStatsBookListActivity::render(RenderLock&&) {
|
|||||||
[this](int index) {
|
[this](int index) {
|
||||||
const auto* b = sortedBooks[index];
|
const auto* b = sortedBooks[index];
|
||||||
// Title is the primary label; fall back to docId so a row without
|
// Title is the primary label; fall back to docId so a row without
|
||||||
// metadata is still recognizable.
|
// metadata is still recognizable. Finished books get a leading
|
||||||
return b->title.empty() ? b->docId : b->title;
|
// 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) {
|
[this](int index) {
|
||||||
// Subtitle row: author, when known. Empty string is treated by the
|
// Subtitle row: author, when known. Empty string is treated by the
|
||||||
|
|||||||
@@ -446,7 +446,12 @@ void CrossPointWebServer::handleStatsApi() const {
|
|||||||
doc["totalSessions"] = store.getGlobalTotalSessions();
|
doc["totalSessions"] = store.getGlobalTotalSessions();
|
||||||
doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned();
|
doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned();
|
||||||
doc["bookCount"] = static_cast<uint32_t>(store.getBookCount());
|
doc["bookCount"] = static_cast<uint32_t>(store.getBookCount());
|
||||||
|
doc["finishedBookCount"] = static_cast<uint32_t>(store.getFinishedBookCount());
|
||||||
doc["todayDayIndex"] = today;
|
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) {
|
if (haveStreak) {
|
||||||
doc["currentStreak"] = store.computeCurrentStreak(today);
|
doc["currentStreak"] = store.computeCurrentStreak(today);
|
||||||
doc["longestStreak"] = store.computeLongestStreak();
|
doc["longestStreak"] = store.computeLongestStreak();
|
||||||
@@ -473,7 +478,15 @@ void CrossPointWebServer::handleStatsApi() const {
|
|||||||
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
||||||
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
||||||
obj["progress"] = book.progress;
|
obj["progress"] = book.progress;
|
||||||
obj["finished"] = book.finished;
|
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>();
|
JsonArray days = obj["days"].to<JsonArray>();
|
||||||
for (const auto& d : book.days) {
|
for (const auto& d : book.days) {
|
||||||
JsonArray pair = days.add<JsonArray>();
|
JsonArray pair = days.add<JsonArray>();
|
||||||
|
|||||||
@@ -305,6 +305,18 @@
|
|||||||
return ((pages * 60) / seconds).toFixed(1);
|
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 "—";
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.round((seconds % 3600) / 60);
|
||||||
|
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
|
// Renders a 30-bar sparkline into `host` for the day window
|
||||||
// [todayDayIndex - 29 .. todayDayIndex] using a map of dayIndex → seconds.
|
// [todayDayIndex - 29 .. todayDayIndex] using a map of dayIndex → seconds.
|
||||||
function renderSparkline(host, daysMap, todayDayIndex, windowSize = 30) {
|
function renderSparkline(host, daysMap, todayDayIndex, windowSize = 30) {
|
||||||
@@ -382,6 +394,9 @@
|
|||||||
allTime.className = "card";
|
allTime.className = "card";
|
||||||
const curStreak = data.currentStreak != null ? String(data.currentStreak) : "—";
|
const curStreak = data.currentStreak != null ? String(data.currentStreak) : "—";
|
||||||
const maxStreak = data.longestStreak != null ? String(data.longestStreak) : "—";
|
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 = `
|
allTime.innerHTML = `
|
||||||
<h2>All time</h2>
|
<h2>All time</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -394,6 +409,7 @@
|
|||||||
<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">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 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>
|
<div class="info-row"><span class="label">Pages/min</span><span class="value">${formatPagesPerMin(data.totalPagesTurned, data.totalSeconds)}</span></div>
|
||||||
|
${finishedRow}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
content.appendChild(allTime);
|
content.appendChild(allTime);
|
||||||
@@ -423,8 +439,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th data-col="title">Title</th>
|
<th data-col="title">Title</th>
|
||||||
<th data-col="totalSeconds" class="num">Time</th>
|
<th data-col="totalSeconds" class="num">Time</th>
|
||||||
<th data-col="sessions" class="num">Sessions</th>
|
<th data-col="progress" class="num">Progress</th>
|
||||||
<th data-col="pagesTurned" class="num">Pages</th>
|
<th data-col="etaSeconds" class="num">To finish</th>
|
||||||
<th data-col="lastReadEpoch" class="num">Last read</th>
|
<th data-col="lastReadEpoch" class="num">Last read</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -436,11 +452,15 @@
|
|||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "book-row";
|
tr.className = "book-row";
|
||||||
tr.dataset.docId = b.docId;
|
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 = `
|
tr.innerHTML = `
|
||||||
<td>${escapeHtml(b.title || b.docId)}</td>
|
<td>${finishedMark}${escapeHtml(b.title || b.docId)}</td>
|
||||||
<td class="num">${formatDuration(b.totalSeconds)}</td>
|
<td class="num">${formatDuration(b.totalSeconds)}</td>
|
||||||
<td class="num">${b.sessions}</td>
|
<td class="num">${b.progress}%</td>
|
||||||
<td class="num">${b.pagesTurned}</td>
|
<td class="num">${etaCell}</td>
|
||||||
<td class="num">${formatDateOrRelative(b.lastReadEpoch)}</td>
|
<td class="num">${formatDateOrRelative(b.lastReadEpoch)}</td>
|
||||||
`;
|
`;
|
||||||
tr.addEventListener("click", () => toggleBookDetail(b, tr, data.todayDayIndex));
|
tr.addEventListener("click", () => toggleBookDetail(b, tr, data.todayDayIndex));
|
||||||
@@ -483,6 +503,12 @@
|
|||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
cell.colSpan = 5;
|
cell.colSpan = 5;
|
||||||
const avg = book.sessions > 0 ? formatDuration(Math.floor(book.totalSeconds / book.sessions)) : "—";
|
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 = `
|
cell.innerHTML = `
|
||||||
<div class="book-detail-grid">
|
<div class="book-detail-grid">
|
||||||
<div><span class="label">Author</span><span>${escapeHtml(book.author || "—")}</span></div>
|
<div><span class="label">Author</span><span>${escapeHtml(book.author || "—")}</span></div>
|
||||||
@@ -491,6 +517,8 @@
|
|||||||
<div><span class="label">Pages/min</span><span>${formatPagesPerMin(book.pagesTurned, book.totalSeconds)}</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">First read</span><span>${formatDateOrRelative(book.firstReadEpoch)}</span></div>
|
||||||
<div><span class="label">Last read</span><span>${formatDateOrRelative(book.lastReadEpoch)}</span></div>
|
<div><span class="label">Last read</span><span>${formatDateOrRelative(book.lastReadEpoch)}</span></div>
|
||||||
|
${etaDetail}
|
||||||
|
${finishedDetail}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (todayDayIndex && book.days && book.days.length > 0) {
|
if (todayDayIndex && book.days && book.days.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user