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
+91 -55
View File
@@ -1,5 +1,6 @@
import { useRef, useState, useCallback, useContext, createContext } from "react";
import { PURPLE, PURPLE_LIGHT, EMOJI_OPTIONS } from "../../constants/index.js";
import { useApp } from "../../context/AppContext.jsx";
const DragContext = createContext(null);
@@ -64,75 +65,109 @@ export function VProgressBar({ value, max, color = PURPLE }) {
// ── DonutChart ────────────────────────────────────────────────────────────────
export function DonutChart({ segments, total, size = 120, label, centerVal }) {
const { darkMode, T } = useApp();
const wrapRef = useRef(null);
const [hover, setHover] = useState(null); // { i, x, y } — actieve categorie + cursor
const strokeW = size * 0.155;
const r = (size - strokeW) / 2 - 1;
const cx = size / 2, cy = size / 2;
const circ = 2 * Math.PI * r;
const active = segments.filter((s) => s.val > 0);
const GAP = active.length > 1 ? circ * 0.022 : 0;
const multi = active.length > 1;
// Middenwaarde schaalt mee met de beschikbare ruimte binnen de ring, zodat
// lange bedragen niet uit verhouding raken.
const innerW = (2 * r - strokeW) * 0.92;
const valLen = centerVal ? String(centerVal).length : 0;
const valFont = valLen
? Math.min(size * 0.125, innerW / (valLen * 0.6))
: 0;
// Segmenten sluiten naadloos aan (geen tussenruimte).
let off = 0;
const slices = active.map((s) => {
const raw = (s.val / total) * circ;
const dash = Math.max(raw - GAP, 2);
const sl = { ...s, dash, offset: off };
const sl = { ...s, dash: Math.max(raw, 1), offset: off, share: total ? s.val / total : 0 };
off += raw;
return sl;
});
const glowId = `dgl${size}`;
const track = (i) => (e) => {
const box = wrapRef.current?.getBoundingClientRect();
setHover({
i,
x: box ? e.clientX - box.left : cx,
y: box ? e.clientY - box.top : cy,
});
};
const hovered = hover ? slices[hover.i] : null;
const fmtPct = (v) => `${(v * 100).toFixed(1).replace(".", ",")}%`;
const fmtEur = (v) =>
new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v || 0);
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: "visible" }}>
<defs>
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={size * 0.022} />
</filter>
</defs>
<div ref={wrapRef} style={{ position: "relative", width: size, height: size, lineHeight: 0 }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* Baan — zelfde toon als de voortgangsbalken */}
<circle cx={cx} cy={cy} r={r} fill="none"
stroke={darkMode ? "rgba(255,255,255,0.07)" : "rgba(15,23,42,0.07)"}
strokeWidth={strokeW} />
{/* Dark inner fill */}
<circle cx={cx} cy={cy} r={r - strokeW / 2 + 1} fill="rgba(0,0,0,0.55)" />
{/* Segmenten */}
{slices.map((s, i) => (
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={hover && hover.i === i ? strokeW * 1.1 : strokeW}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap={multi ? "butt" : "round"}
opacity={hover && hover.i !== i ? 0.35 : 1}
style={{ cursor: "pointer", transition: "opacity 0.15s ease, stroke-width 0.15s ease" }}
onMouseEnter={track(i)}
onMouseMove={track(i)}
onMouseLeave={() => setHover(null)}
/>
))}
{/* Track */}
<circle cx={cx} cy={cy} r={r} fill="none"
stroke="rgba(255,255,255,0.06)" strokeWidth={strokeW} />
{/* Center label */}
{label && (
<text x={cx} y={cy - valFont * 0.62} fill={T.muted}
fontSize={size * 0.085} textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit" style={{ pointerEvents: "none" }}>
{label}
</text>
)}
{/* Center value */}
{centerVal && (
<text x={cx} y={label ? cy + valFont * 0.45 : cy} fill={T.text}
fontSize={valFont} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit" style={{ pointerEvents: "none" }}>
{centerVal}
</text>
)}
</svg>
{/* Glow pass */}
{slices.map((s, i) => (
<circle key={`g${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={strokeW * 1.5}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap="round"
opacity="0.18"
filter={`url(#${glowId})`}
/>
))}
{/* Crisp segments */}
{slices.map((s, i) => (
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={strokeW}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap="round"
/>
))}
{/* Center label */}
{label && (
<text x={cx} y={cy - size * 0.1} fill="rgba(255,255,255,0.4)"
fontSize={size * 0.09} textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
{label}
</text>
{/* Tooltip: categorie, bedrag en aandeel */}
{hovered && (
<div style={{
position: "absolute", left: hover.x, top: hover.y - 12,
transform: "translate(-50%, -100%)", pointerEvents: "none",
background: T.popupBg, border: `1px solid ${T.border}`,
borderRadius: 10, padding: "6px 10px", boxShadow: T.shadow,
whiteSpace: "nowrap", lineHeight: 1.35, zIndex: 5,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: hovered.color }} />
<span style={{ fontSize: 11, fontWeight: 700, color: T.text }}>
{hovered.naam || hovered.label || "—"}
</span>
</div>
<div style={{ fontSize: 11, color: T.subtext, marginTop: 2 }}>
{fmtEur(hovered.val)} · <span style={{ fontWeight: 700, color: T.text }}>{fmtPct(hovered.share)}</span>
</div>
</div>
)}
{/* Center value */}
{centerVal && (
<text x={cx} y={cy + size * 0.09} fill="#fff"
fontSize={size * 0.155} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
{centerVal}
</text>
)}
</svg>
</div>
);
}
@@ -289,6 +324,7 @@ export function Overlay({ children, onClose, zIndex = 400 }) {
}
export function ModalBox({ children, maxWidth = 480 }) {
const { T } = useApp();
const ref = useRef(null);
const [pos, setPos] = useState(null);
const dragState = useRef(null);
@@ -331,8 +367,8 @@ export function ModalBox({ children, maxWidth = 480 }) {
<div ref={ref} style={{
position: "fixed",
...posStyle,
background: "#1a1d27",
border: "1px solid rgba(255,255,255,0.07)",
background: T.popupBg,
border: `1px solid ${T.border}`,
borderRadius: 18, width: "100%", maxWidth,
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
overflow: "hidden",