Donut-hover met percentages en werkende update-notificatie

- Donut: naadloze segmenten (donkere spleten weg), hover-tooltip per
  categorie met naam, bedrag en aandeel, middenwaarde schaalt mee
- Update-check via de eigen backend (/api/update-check) i.p.v. direct
  naar de Gitea API, die geen CORS-headers stuurt
- Semver-vergelijking, herhaalde check en release-notes in de melding
- release.ps1 maakt de Gitea-release aan (stap 4)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordy Hoogeveen
2026-09-04 09:19:23 +02:00
co-authored by Claude Opus 5
parent e45d5b73fb
commit 3c67381de9
24 changed files with 506 additions and 241 deletions
+7
View File
@@ -8,4 +8,11 @@ export const config = {
JWT_EXPIRES: process.env.JWT_EXPIRES || "7d",
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL,
// Update-notificaties: welke release-API wordt gepolld (Gitea).
// Leeg laten schakelt de melding in de app uit.
UPDATE_CHECK_URL: process.env.UPDATE_CHECK_URL
?? "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest",
// Alleen nodig wanneer de repo in Gitea niet publiek leesbaar is.
UPDATE_CHECK_TOKEN: process.env.UPDATE_CHECK_TOKEN || null,
};
+52
View File
@@ -0,0 +1,52 @@
import { Router } from "express";
import { config } from "../config/index.js";
import { logger } from "../logger.js";
const router = Router();
// Gitea wordt server-side bevraagd: de browser mag dat niet (geen CORS-headers
// op de Gitea API) en zo blijft het antwoord gecached voor alle gebruikers.
const TTL_MS = 15 * 60 * 1000;
const TIMEOUT_MS = 5000;
let cache = { at: 0, payload: null };
router.get("/", async (_req, res) => {
if (!config.UPDATE_CHECK_URL) {
return res.json({ latest: null, disabled: true });
}
if (cache.payload && Date.now() - cache.at < TTL_MS) {
return res.json(cache.payload);
}
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const upstream = await fetch(config.UPDATE_CHECK_URL, {
signal: ctrl.signal,
headers: {
Accept: "application/json",
...(config.UPDATE_CHECK_TOKEN && { Authorization: `token ${config.UPDATE_CHECK_TOKEN}` }),
},
});
if (!upstream.ok) throw new Error(`upstream ${upstream.status}`);
const rel = await upstream.json();
const payload = {
latest: String(rel?.tag_name ?? "").replace(/^v/, "") || null,
naam: rel?.name || null,
notes: rel?.body || null,
url: rel?.html_url || null,
publishedAt: rel?.published_at || null,
};
cache = { at: Date.now(), payload };
res.json(payload);
} catch (err) {
logger.warn({ err: err.message }, "Update-check bij Gitea mislukt");
// Val terug op het laatst bekende antwoord; anders "geen update bekend".
res.json(cache.payload ?? { latest: null, error: true });
} finally {
clearTimeout(timer);
}
});
export default router;
+2
View File
@@ -10,6 +10,7 @@ import authRouter from "./routes/auth.js";
import dataRouter from "./routes/data.js";
import usersRouter from "./routes/users.js";
import healthRouter from "./routes/health.js";
import updateRouter from "./routes/update.js";
import { errorHandler } from "./middleware/errorHandler.js";
export const logger = pino({
@@ -41,6 +42,7 @@ if (config.NODE_ENV !== "test") {
// ── Routes ────────────────────────────────────────────────────────────────────
app.use("/api/health", healthRouter);
app.use("/api/update-check", updateRouter);
app.use("/api/auth", authRouter);
app.use("/api/data", dataRouter);
app.use("/api/users", usersRouter);