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);