Phase 6 - remaining time info
This commit is contained in:
+14
-2
@@ -624,7 +624,11 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char
|
||||
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
||||
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -677,7 +681,15 @@ bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json
|
||||
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;
|
||||
// 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));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ void ReadingSessionTracker::updateProgress(uint8_t 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() {
|
||||
if (!active) return;
|
||||
// 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.
|
||||
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
|
||||
|
||||
@@ -146,11 +146,75 @@ uint16_t ReadingStatsStore::computeLongestStreak() const {
|
||||
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 {
|
||||
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);
|
||||
|
||||
+35
-2
@@ -29,8 +29,9 @@ struct BookReadingStats {
|
||||
// 0 if HalClock was never synced when the session ran. Treat as "unknown".
|
||||
time_t firstReadEpoch = 0;
|
||||
time_t lastReadEpoch = 0;
|
||||
uint8_t progress = 0; // 0-100, snapshot of last known progress
|
||||
bool finished = false; // user-marked finished (Phase 1: always false)
|
||||
uint8_t progress = 0; // 0-100, snapshot of last known progress
|
||||
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
|
||||
@@ -78,14 +79,46 @@ class ReadingStatsStore {
|
||||
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; }
|
||||
|
||||
@@ -505,6 +505,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) {
|
||||
@@ -765,6 +768,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) {
|
||||
@@ -1618,6 +1622,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) {
|
||||
|
||||
@@ -302,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) {
|
||||
|
||||
@@ -257,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) {
|
||||
|
||||
@@ -156,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) {
|
||||
|
||||
@@ -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_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 ----
|
||||
|
||||
@@ -143,12 +143,32 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) {
|
||||
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) ----
|
||||
|
||||
@@ -94,8 +94,11 @@ void ReadingStatsBookListActivity::render(RenderLock&&) {
|
||||
[this](int index) {
|
||||
const auto* b = sortedBooks[index];
|
||||
// Title is the primary label; fall back to docId so a row without
|
||||
// metadata is still recognizable.
|
||||
return b->title.empty() ? b->docId : b->title;
|
||||
// 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
|
||||
|
||||
@@ -446,7 +446,12 @@ void CrossPointWebServer::handleStatsApi() const {
|
||||
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();
|
||||
@@ -473,7 +478,15 @@ void CrossPointWebServer::handleStatsApi() const {
|
||||
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
||||
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
||||
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>();
|
||||
for (const auto& d : book.days) {
|
||||
JsonArray pair = days.add<JsonArray>();
|
||||
|
||||
@@ -305,6 +305,18 @@
|
||||
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
|
||||
// [todayDayIndex - 29 .. todayDayIndex] using a map of dayIndex → seconds.
|
||||
function renderSparkline(host, daysMap, todayDayIndex, windowSize = 30) {
|
||||
@@ -382,6 +394,9 @@
|
||||
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">
|
||||
@@ -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">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);
|
||||
@@ -423,8 +439,8 @@
|
||||
<tr>
|
||||
<th data-col="title">Title</th>
|
||||
<th data-col="totalSeconds" class="num">Time</th>
|
||||
<th data-col="sessions" class="num">Sessions</th>
|
||||
<th data-col="pagesTurned" class="num">Pages</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>
|
||||
@@ -436,11 +452,15 @@
|
||||
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>${escapeHtml(b.title || b.docId)}</td>
|
||||
<td>${finishedMark}${escapeHtml(b.title || b.docId)}</td>
|
||||
<td class="num">${formatDuration(b.totalSeconds)}</td>
|
||||
<td class="num">${b.sessions}</td>
|
||||
<td class="num">${b.pagesTurned}</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));
|
||||
@@ -483,6 +503,12 @@
|
||||
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>
|
||||
@@ -491,6 +517,8 @@
|
||||
<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) {
|
||||
|
||||
Reference in New Issue
Block a user