Phase 6 - web dashboard
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
#include "FontInstaller.h"
|
||||
#include "HttpFileStreamer.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "ReadingStats.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "SdCardFontRegistry.h"
|
||||
#include "SettingsList.h"
|
||||
@@ -27,6 +28,7 @@
|
||||
#include "html/FontsPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
#include "html/SettingsPageHtml.generated.h"
|
||||
#include "html/StatsPageHtml.generated.h"
|
||||
#include "html/WelcomePageHtml.generated.h"
|
||||
#include "html/js/jszip_minJs.generated.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
@@ -219,6 +221,10 @@ void CrossPointWebServer::begin() {
|
||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
||||
|
||||
// Font management endpoints
|
||||
server->on("/stats", HTTP_GET, [this] { handleStatsPage(); });
|
||||
server->on("/api/stats", HTTP_GET, [this] { handleStatsApi(); });
|
||||
server->on("/api/stats/export", HTTP_GET, [this] { handleStatsExport(); });
|
||||
|
||||
server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); });
|
||||
server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); });
|
||||
server->on("/api/fonts/manifest", HTTP_GET, [this] { handleFontManifest(); });
|
||||
@@ -419,6 +425,82 @@ void CrossPointWebServer::handleSystemInfoPage() const {
|
||||
LOG_DBG("WEB", "Served system info page in %d ms", t1 - t0);
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleStatsPage() const {
|
||||
int32_t t0 = millis();
|
||||
sendHtmlContent(server.get(), StatsPageHtml, sizeof(StatsPageHtml));
|
||||
int32_t t1 = millis();
|
||||
LOG_DBG("WEB", "Served stats page in %d ms", t1 - t0);
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleStatsApi() const {
|
||||
// Wire the same data the on-device screens use into a JSON payload the
|
||||
// browser dashboard can consume. We pre-compute streaks and todayDayIndex
|
||||
// here so the browser doesn't have to recreate the day-index math; the day
|
||||
// arrays still go across untouched so the browser can render the sparkline.
|
||||
const auto& store = READING_STATS;
|
||||
const uint16_t today = currentLocalDayIndex();
|
||||
const bool haveStreak = today != 0 && !store.getGlobalDays().empty();
|
||||
|
||||
JsonDocument doc;
|
||||
doc["totalSeconds"] = store.getGlobalTotalSeconds();
|
||||
doc["totalSessions"] = store.getGlobalTotalSessions();
|
||||
doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned();
|
||||
doc["bookCount"] = static_cast<uint32_t>(store.getBookCount());
|
||||
doc["todayDayIndex"] = today;
|
||||
if (haveStreak) {
|
||||
doc["currentStreak"] = store.computeCurrentStreak(today);
|
||||
doc["longestStreak"] = store.computeLongestStreak();
|
||||
}
|
||||
|
||||
// Day buckets as [[dayIndex, seconds], …] — same compact shape as on disk
|
||||
// so the browser code can treat the export and the live API identically.
|
||||
JsonArray globalDays = doc["globalDays"].to<JsonArray>();
|
||||
for (const auto& d : store.getGlobalDays()) {
|
||||
JsonArray pair = globalDays.add<JsonArray>();
|
||||
pair.add(d.dayIndex);
|
||||
pair.add(d.seconds);
|
||||
}
|
||||
|
||||
JsonArray booksArr = doc["books"].to<JsonArray>();
|
||||
for (const auto& book : store.getBooks()) {
|
||||
JsonObject obj = booksArr.add<JsonObject>();
|
||||
obj["docId"] = book.docId;
|
||||
obj["title"] = book.title;
|
||||
obj["author"] = book.author;
|
||||
obj["totalSeconds"] = book.totalSeconds;
|
||||
obj["pagesTurned"] = book.pagesTurned;
|
||||
obj["sessions"] = book.sessions;
|
||||
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
||||
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
||||
obj["progress"] = book.progress;
|
||||
obj["finished"] = book.finished;
|
||||
JsonArray days = obj["days"].to<JsonArray>();
|
||||
for (const auto& d : book.days) {
|
||||
JsonArray pair = days.add<JsonArray>();
|
||||
pair.add(d.dayIndex);
|
||||
pair.add(d.seconds);
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
server->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleStatsExport() const {
|
||||
// Stream the raw stats file straight from SD — this is the same shape the
|
||||
// device writes and reads, so it round-trips cleanly through external
|
||||
// tooling without us having to maintain a second schema.
|
||||
constexpr const char* kStatsFile = "/.crosspoint/reading-stats.json";
|
||||
if (!Storage.exists(kStatsFile)) {
|
||||
server->send(404, "application/json", "{}");
|
||||
return;
|
||||
}
|
||||
String content = Storage.readFile(kStatsFile);
|
||||
server->sendHeader("Content-Disposition", "attachment; filename=\"reading-stats.json\"");
|
||||
server->send(200, "application/json", content);
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleJszip() const {
|
||||
server->sendHeader("Content-Encoding", "gzip");
|
||||
server->send_P(200, "application/javascript", jszip_minJs, jszip_minJsCompressedSize);
|
||||
|
||||
@@ -144,4 +144,9 @@ class CrossPointWebServer {
|
||||
void handleGetWifiNetworks() const;
|
||||
void handlePostWifiNetwork();
|
||||
void handleDeleteWifiNetwork();
|
||||
|
||||
// Reading-stats handlers
|
||||
void handleStatsPage() const;
|
||||
void handleStatsApi() const;
|
||||
void handleStatsExport() const;
|
||||
};
|
||||
|
||||
@@ -1821,6 +1821,7 @@
|
||||
<a href="/files" class="active">File Manager</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/fonts">Font Manager</a>
|
||||
<a href="/stats">Reading Stats</a>
|
||||
<a href="/systeminfo">System Info</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/fonts" class="active">Font Manager</a>
|
||||
<a href="/stats">Reading Stats</a>
|
||||
<a href="/systeminfo">System Info</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/fonts">Font Manager</a>
|
||||
<a href="/stats">Reading Stats</a>
|
||||
<a href="/systeminfo" class="active">System Info</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/settings" class="active">Settings</a>
|
||||
<a href="/fonts">Font Manager</a>
|
||||
<a href="/stats">Reading Stats</a>
|
||||
<a href="/systeminfo">System Info</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reading Stats - %%CROSSPOINT%%</title>
|
||||
<style>
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-color: #333;
|
||||
--bg: #f5f5f5;
|
||||
--title-color: #2c3e50;
|
||||
--card-bg: #fff;
|
||||
--label-color: #7f8c8d;
|
||||
--border-color: #eee;
|
||||
--accent-color: rgb(110, 154, 130);
|
||||
--accent-hover-color: #5a8c73;
|
||||
--bar-color: rgb(110, 154, 130);
|
||||
--bar-empty: #d8e0dc;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--font-color: #f5f5f5;
|
||||
--bg: #333;
|
||||
--title-color: #ecf0f1;
|
||||
--card-bg: #444;
|
||||
--label-color: #bdc3c7;
|
||||
--border-color: #555;
|
||||
--bar-empty: #2f3a35;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: var(--bg);
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--title-color);
|
||||
border-bottom: 2px solid var(--accent-color);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--title-color);
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
padding: 10px 20px;
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.nav-links a.active {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: var(--accent-hover-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 4-cell stat grid — mirrors the on-device card */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.grid > div {
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.grid > div:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.grid .value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
.grid .label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--label-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-weight: 600;
|
||||
color: var(--label-color);
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
color: var(--title-color);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Sparkline */
|
||||
.sparkline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 80px;
|
||||
margin-top: 10px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sparkline .bar {
|
||||
flex: 1;
|
||||
background: var(--bar-color);
|
||||
min-height: 2px;
|
||||
border-radius: 1px 1px 0 0;
|
||||
}
|
||||
|
||||
.sparkline .bar.empty {
|
||||
background: var(--bar-empty);
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.sparkline-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--label-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Books table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: var(--label-color);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
th:hover {
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
th.sorted-asc::after { content: " ▲"; font-size: 0.7em; }
|
||||
th.sorted-desc::after { content: " ▼"; font-size: 0.7em; }
|
||||
|
||||
td.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tr.book-row { cursor: pointer; }
|
||||
tr.book-row:hover td { background: rgba(110, 154, 130, 0.08); }
|
||||
|
||||
.book-detail {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.book-detail { background: rgba(255, 255, 255, 0.03); }
|
||||
tr.book-row:hover td { background: rgba(110, 154, 130, 0.15); }
|
||||
}
|
||||
|
||||
.book-detail td { padding: 16px; }
|
||||
|
||||
.book-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.book-detail-grid div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.book-detail-grid .label {
|
||||
color: var(--label-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--label-color);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions a, .actions button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.actions a:hover, .actions button:hover {
|
||||
background: var(--accent-hover-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Reading Stats</h1>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/files">Files</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/stats" class="active">Stats</a>
|
||||
<a href="/systeminfo">System</a>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="card">
|
||||
<div class="empty-state">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Format a number of seconds as "1h 23m" / "23m 45s" / "12s" — matches
|
||||
// the on-device format so the dashboard reads as the same product.
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return "0s";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
|
||||
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function formatPagesPerMin(pages, seconds) {
|
||||
if (seconds < 60 || !pages) return "—";
|
||||
return ((pages * 60) / seconds).toFixed(1);
|
||||
}
|
||||
|
||||
// Renders a 30-bar sparkline into `host` for the day window
|
||||
// [todayDayIndex - 29 .. todayDayIndex] using a map of dayIndex → seconds.
|
||||
function renderSparkline(host, daysMap, todayDayIndex, windowSize = 30) {
|
||||
host.innerHTML = "";
|
||||
const bars = document.createElement("div");
|
||||
bars.className = "sparkline";
|
||||
|
||||
let max = 1;
|
||||
const series = [];
|
||||
for (let i = 0; i < windowSize; i++) {
|
||||
const d = todayDayIndex - (windowSize - 1 - i);
|
||||
const sec = d > 0 ? (daysMap[d] || 0) : 0;
|
||||
series.push(sec);
|
||||
if (sec > max) max = sec;
|
||||
}
|
||||
for (const sec of series) {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = sec > 0 ? "bar" : "bar empty";
|
||||
bar.style.height = sec > 0 ? `${Math.max(4, (sec / max) * 100)}%` : "2px";
|
||||
bar.title = sec > 0 ? formatDuration(sec) : "no reading";
|
||||
bars.appendChild(bar);
|
||||
}
|
||||
host.appendChild(bars);
|
||||
|
||||
const labels = document.createElement("div");
|
||||
labels.className = "sparkline-labels";
|
||||
labels.innerHTML = `<span>${windowSize} days ago</span><span>today</span>`;
|
||||
host.appendChild(labels);
|
||||
}
|
||||
|
||||
// Format a unix epoch as relative-or-date (matches the on-device formatter).
|
||||
function formatDateOrRelative(epoch) {
|
||||
if (!epoch) return "—";
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const delta = nowSec - epoch;
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
const days = Math.floor(delta / 86400);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const d = new Date(epoch * 1000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
let currentSort = { col: "totalSeconds", dir: "desc" };
|
||||
let expandedDocId = null;
|
||||
|
||||
function sortBooks(books, col, dir) {
|
||||
const cmp = (a, b) => {
|
||||
let av = a[col], bv = b[col];
|
||||
if (col === "title") {
|
||||
av = (av || a.docId).toLowerCase();
|
||||
bv = (bv || b.docId).toLowerCase();
|
||||
return dir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
}
|
||||
return dir === "asc" ? av - bv : bv - av;
|
||||
};
|
||||
return [...books].sort(cmp);
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const content = document.getElementById("content");
|
||||
content.innerHTML = "";
|
||||
|
||||
if (!data || data.totalSeconds === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "card";
|
||||
empty.innerHTML = '<div class="empty-state">No reading recorded yet. Open a book and read for a while — your stats will appear here.</div>';
|
||||
content.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- All-time card ----
|
||||
const allTime = document.createElement("div");
|
||||
allTime.className = "card";
|
||||
const curStreak = data.currentStreak != null ? String(data.currentStreak) : "—";
|
||||
const maxStreak = data.longestStreak != null ? String(data.longestStreak) : "—";
|
||||
allTime.innerHTML = `
|
||||
<h2>All time</h2>
|
||||
<div class="grid">
|
||||
<div><div class="value">${data.totalSessions}</div><div class="label">Sessions</div></div>
|
||||
<div><div class="value">${data.bookCount}</div><div class="label">Books</div></div>
|
||||
<div><div class="value">${curStreak}</div><div class="label">Streak</div></div>
|
||||
<div><div class="value">${maxStreak}</div><div class="label">Longest</div></div>
|
||||
</div>
|
||||
<div style="margin-top: 14px;">
|
||||
<div class="info-row"><span class="label">Total time</span><span class="value">${formatDuration(data.totalSeconds)}</span></div>
|
||||
<div class="info-row"><span class="label">Pages turned</span><span class="value">${data.totalPagesTurned}</span></div>
|
||||
<div class="info-row"><span class="label">Pages/min</span><span class="value">${formatPagesPerMin(data.totalPagesTurned, data.totalSeconds)}</span></div>
|
||||
</div>
|
||||
`;
|
||||
content.appendChild(allTime);
|
||||
|
||||
// ---- Sparkline card ----
|
||||
if (data.todayDayIndex && data.globalDays && data.globalDays.length > 0) {
|
||||
const sparkCard = document.createElement("div");
|
||||
sparkCard.className = "card";
|
||||
sparkCard.innerHTML = "<h2>Last 30 days</h2><div id=\"global-spark\"></div>";
|
||||
content.appendChild(sparkCard);
|
||||
const daysMap = {};
|
||||
for (const [day, sec] of data.globalDays) daysMap[day] = sec;
|
||||
renderSparkline(sparkCard.querySelector("#global-spark"), daysMap, data.todayDayIndex);
|
||||
}
|
||||
|
||||
// ---- Books card ----
|
||||
const booksCard = document.createElement("div");
|
||||
booksCard.className = "card";
|
||||
booksCard.innerHTML = "<h2>Books</h2>";
|
||||
if (!data.books || data.books.length === 0) {
|
||||
booksCard.innerHTML += '<div class="empty-state">No books tracked yet.</div>';
|
||||
content.appendChild(booksCard);
|
||||
} else {
|
||||
const table = document.createElement("table");
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="title">Title</th>
|
||||
<th data-col="totalSeconds" class="num">Time</th>
|
||||
<th data-col="sessions" class="num">Sessions</th>
|
||||
<th data-col="pagesTurned" class="num">Pages</th>
|
||||
<th data-col="lastReadEpoch" class="num">Last read</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tbody = table.querySelector("tbody");
|
||||
const sorted = sortBooks(data.books, currentSort.col, currentSort.dir);
|
||||
for (const b of sorted) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "book-row";
|
||||
tr.dataset.docId = b.docId;
|
||||
tr.innerHTML = `
|
||||
<td>${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">${formatDateOrRelative(b.lastReadEpoch)}</td>
|
||||
`;
|
||||
tr.addEventListener("click", () => toggleBookDetail(b, tr, data.todayDayIndex));
|
||||
tbody.appendChild(tr);
|
||||
if (expandedDocId === b.docId) {
|
||||
const detailRow = buildDetailRow(b, data.todayDayIndex);
|
||||
tbody.appendChild(detailRow);
|
||||
}
|
||||
}
|
||||
booksCard.appendChild(table);
|
||||
|
||||
// Header sort
|
||||
table.querySelectorAll("th").forEach(th => {
|
||||
const col = th.dataset.col;
|
||||
if (col === currentSort.col) th.classList.add(`sorted-${currentSort.dir}`);
|
||||
th.addEventListener("click", () => {
|
||||
if (currentSort.col === col) {
|
||||
currentSort.dir = currentSort.dir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
currentSort.col = col;
|
||||
currentSort.dir = col === "title" ? "asc" : "desc";
|
||||
}
|
||||
render(data);
|
||||
});
|
||||
});
|
||||
|
||||
content.appendChild(booksCard);
|
||||
}
|
||||
|
||||
// ---- Actions ----
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
actions.innerHTML = '<a href="/api/stats/export" download="reading-stats.json">Download backup</a>';
|
||||
content.appendChild(actions);
|
||||
}
|
||||
|
||||
function buildDetailRow(book, todayDayIndex) {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "book-detail";
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = 5;
|
||||
const avg = book.sessions > 0 ? formatDuration(Math.floor(book.totalSeconds / book.sessions)) : "—";
|
||||
cell.innerHTML = `
|
||||
<div class="book-detail-grid">
|
||||
<div><span class="label">Author</span><span>${escapeHtml(book.author || "—")}</span></div>
|
||||
<div><span class="label">Progress</span><span>${book.progress}%</span></div>
|
||||
<div><span class="label">Avg session</span><span>${avg}</span></div>
|
||||
<div><span class="label">Pages/min</span><span>${formatPagesPerMin(book.pagesTurned, book.totalSeconds)}</span></div>
|
||||
<div><span class="label">First read</span><span>${formatDateOrRelative(book.firstReadEpoch)}</span></div>
|
||||
<div><span class="label">Last read</span><span>${formatDateOrRelative(book.lastReadEpoch)}</span></div>
|
||||
</div>
|
||||
`;
|
||||
if (todayDayIndex && book.days && book.days.length > 0) {
|
||||
const sparkHost = document.createElement("div");
|
||||
sparkHost.style.marginTop = "12px";
|
||||
const daysMap = {};
|
||||
for (const [day, sec] of book.days) daysMap[day] = sec;
|
||||
renderSparkline(sparkHost, daysMap, todayDayIndex);
|
||||
cell.appendChild(sparkHost);
|
||||
}
|
||||
row.appendChild(cell);
|
||||
return row;
|
||||
}
|
||||
|
||||
function toggleBookDetail(book, tr, todayDayIndex) {
|
||||
const next = tr.nextElementSibling;
|
||||
if (next && next.classList.contains("book-detail")) {
|
||||
next.remove();
|
||||
expandedDocId = null;
|
||||
} else {
|
||||
// Remove any other open detail row
|
||||
document.querySelectorAll("tr.book-detail").forEach(r => r.remove());
|
||||
const detailRow = buildDetailRow(book, todayDayIndex);
|
||||
tr.parentNode.insertBefore(detailRow, tr.nextSibling);
|
||||
expandedDocId = book.docId;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/stats");
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const data = await res.json();
|
||||
render(data);
|
||||
} catch (e) {
|
||||
const content = document.getElementById("content");
|
||||
content.innerHTML = `<div class="card"><div class="empty-state">Failed to load stats: ${escapeHtml(e.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -108,6 +108,7 @@
|
||||
<a href="/files">Open File Manager</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/fonts">Font Manager</a>
|
||||
<a href="/stats">Reading Stats</a>
|
||||
<a href="/systeminfo">System Info</a>
|
||||
</div>
|
||||
<div class="footer">Fast start page designed for weak connections</div>
|
||||
|
||||
Reference in New Issue
Block a user