Phase 6 - web dashboard

This commit is contained in:
jpirnay
2026-05-20 00:54:14 +02:00
8 changed files with 636 additions and 0 deletions
+82
View File
@@ -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);
+5
View File
@@ -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;
};
+1
View File
@@ -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>
+1
View File
@@ -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>
+1
View File
@@ -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>
+1
View File
@@ -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>
+544
View File
@@ -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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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>
+1
View File
@@ -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>