Phase 6 - web dashboard
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
#include "FontInstaller.h"
|
#include "FontInstaller.h"
|
||||||
#include "HttpFileStreamer.h"
|
#include "HttpFileStreamer.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
#include "ReadingStats.h"
|
||||||
#include "SdCardFontGlobals.h"
|
#include "SdCardFontGlobals.h"
|
||||||
#include "SdCardFontRegistry.h"
|
#include "SdCardFontRegistry.h"
|
||||||
#include "SettingsList.h"
|
#include "SettingsList.h"
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
#include "html/FontsPageHtml.generated.h"
|
#include "html/FontsPageHtml.generated.h"
|
||||||
#include "html/HomePageHtml.generated.h"
|
#include "html/HomePageHtml.generated.h"
|
||||||
#include "html/SettingsPageHtml.generated.h"
|
#include "html/SettingsPageHtml.generated.h"
|
||||||
|
#include "html/StatsPageHtml.generated.h"
|
||||||
#include "html/WelcomePageHtml.generated.h"
|
#include "html/WelcomePageHtml.generated.h"
|
||||||
#include "html/js/jszip_minJs.generated.h"
|
#include "html/js/jszip_minJs.generated.h"
|
||||||
#include "network/HttpDownloader.h"
|
#include "network/HttpDownloader.h"
|
||||||
@@ -219,6 +221,10 @@ void CrossPointWebServer::begin() {
|
|||||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
||||||
|
|
||||||
// Font management endpoints
|
// 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("/fonts", HTTP_GET, [this] { handleFontsPage(); });
|
||||||
server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); });
|
server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); });
|
||||||
server->on("/api/fonts/manifest", HTTP_GET, [this] { handleFontManifest(); });
|
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);
|
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 {
|
void CrossPointWebServer::handleJszip() const {
|
||||||
server->sendHeader("Content-Encoding", "gzip");
|
server->sendHeader("Content-Encoding", "gzip");
|
||||||
server->send_P(200, "application/javascript", jszip_minJs, jszip_minJsCompressedSize);
|
server->send_P(200, "application/javascript", jszip_minJs, jszip_minJsCompressedSize);
|
||||||
|
|||||||
@@ -144,4 +144,9 @@ class CrossPointWebServer {
|
|||||||
void handleGetWifiNetworks() const;
|
void handleGetWifiNetworks() const;
|
||||||
void handlePostWifiNetwork();
|
void handlePostWifiNetwork();
|
||||||
void handleDeleteWifiNetwork();
|
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="/files" class="active">File Manager</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
<a href="/fonts">Font Manager</a>
|
<a href="/fonts">Font Manager</a>
|
||||||
|
<a href="/stats">Reading Stats</a>
|
||||||
<a href="/systeminfo">System Info</a>
|
<a href="/systeminfo">System Info</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
<a href="/fonts" class="active">Font Manager</a>
|
<a href="/fonts" class="active">Font Manager</a>
|
||||||
|
<a href="/stats">Reading Stats</a>
|
||||||
<a href="/systeminfo">System Info</a>
|
<a href="/systeminfo">System Info</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@
|
|||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
<a href="/fonts">Font Manager</a>
|
<a href="/fonts">Font Manager</a>
|
||||||
|
<a href="/stats">Reading Stats</a>
|
||||||
<a href="/systeminfo" class="active">System Info</a>
|
<a href="/systeminfo" class="active">System Info</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -301,6 +301,7 @@
|
|||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
<a href="/settings" class="active">Settings</a>
|
<a href="/settings" class="active">Settings</a>
|
||||||
<a href="/fonts">Font Manager</a>
|
<a href="/fonts">Font Manager</a>
|
||||||
|
<a href="/stats">Reading Stats</a>
|
||||||
<a href="/systeminfo">System Info</a>
|
<a href="/systeminfo">System Info</a>
|
||||||
</div>
|
</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="/files">Open File Manager</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
<a href="/fonts">Font Manager</a>
|
<a href="/fonts">Font Manager</a>
|
||||||
|
<a href="/stats">Reading Stats</a>
|
||||||
<a href="/systeminfo">System Info</a>
|
<a href="/systeminfo">System Info</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">Fast start page designed for weak connections</div>
|
<div class="footer">Fast start page designed for weak connections</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user