Phase 6 - remaining time info

This commit is contained in:
jpirnay
2026-05-20 09:55:49 +02:00
parent b4738fee0c
commit 375c73b03b
16 changed files with 216 additions and 14 deletions
+14 -1
View File
@@ -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>();
+33 -5
View File
@@ -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) {