From 7d58cb780e66de22079aa04c78b9e4e23c754f7f Mon Sep 17 00:00:00 2001 From: Omar Rao Date: Wed, 26 Aug 2026 14:14:44 -0400 Subject: [PATCH] fix(dashboard): modern crisp Storage Growth chart (v5.0.3) - Root cause of the blurry/distorted graph: preserveAspectRatio="none" stretched the 380-unit viewBox to the card width, non-uniformly scaling strokes and text. - Rewrote renderStorageChart to render at the container's exact pixel size (1 unit = 1px) for crisp output at any width; smooth Catmull-Rom gradient area curve, light gridlines, MB y-axis labels, dated x-axis ticks; tooltip retained. - Updated dashboard-insights.svg + USERGUIDE 15A; SW cache v8. 105 tests pass, lint + headers clean; verified 1:1 crisp rendering in demo. --- CHANGELOG.md | 13 +++ docs/USERGUIDE.md | 2 +- docs/index.html | 103 +++++++++++++++++------- docs/screenshots/dashboard-insights.svg | 55 ++++++++----- docs/sw.js | 2 +- package-lock.json | 4 +- package.json | 2 +- 7 files changed, 129 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 663ad0f..f45b639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [5.0.3] — 2026-07-20 + +### Fixed (dashboard) +- **Storage Growth chart redesigned.** The previous chart used + `preserveAspectRatio="none"`, which stretched the viewBox and rendered blurry, + non-uniformly-scaled strokes and text. It now renders at the container's exact + pixel size (1 unit = 1 px) for crisp output, with a **smooth gradient area + curve** (Catmull-Rom bezier), light gridlines, MB y-axis labels, and dated + x-axis ticks. Interactive hover/focus tooltip retained. +- Updated `dashboard-insights.svg` and USERGUIDE §15A to match. + +--- + ## [5.0.2] — 2026-07-20 ### Changed (dashboard UX) diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index e16304f..2068c21 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -963,7 +963,7 @@ Directly under the stat cards, the **System Health** grid gives a traffic-light ### Storage Growth chart -An inline SVG area chart plots the size (MB) of the last 10 backup sessions oldest→newest. **Hover (or keyboard-focus) any point** to see an interactive tooltip with that session's name, size, delta mode (full/delta/unchanged), and date; the point enlarges to mark the active selection. Use it to spot steady growth (plan capacity) or sudden drops (investigate with Session Diff). +A modern, gradient-filled **smooth area chart** plots the size (MB) of the last 10 backup sessions oldest→newest, with light gridlines, MB y-axis labels, and dated x-axis ticks. It renders at the container's exact pixel size (1 SVG unit = 1 px) so strokes and text stay crisp at any width — no stretching. **Hover (or keyboard-focus) any point** to see an interactive tooltip with that session's name, size, delta mode (full/delta/unchanged), and date; the point enlarges to mark the active selection. Use it to spot steady growth (plan capacity) or sudden drops (investigate with Session Diff). ### Backup Timeline heatmap diff --git a/docs/index.html b/docs/index.html index 6302d82..7fc09e6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1046,16 +1046,16 @@ .health-val { font-size: 13.5px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .health-arrow { width: 15px; height: 15px; flex-shrink: 0; color: var(--muted-light); opacity: 0; transform: translateX(-3px); transition: opacity var(--t), transform var(--t), color var(--t); } - /* ── Storage growth area chart ────────────────────────────────────────── */ - .area-chart { width: 100%; height: 140px; display: block; } - .area-chart .grid-line { stroke: var(--border); stroke-width: 1; } - .area-chart .area-fill { fill: var(--accent); opacity: .12; } - .area-chart .area-line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } - .area-chart .area-dot { fill: var(--bg-card); stroke: var(--accent); stroke-width: 2; transition: r .12s ease; } - .area-chart .area-dot.active { r: 5; fill: var(--accent); } - .area-chart .area-hit { fill: transparent; cursor: pointer; outline: none; } - .area-chart .area-hit:focus-visible + text, .area-chart .area-hit:focus-visible { stroke: var(--accent); } - .area-chart text { fill: var(--muted); font-size: 10px; } + /* ── Storage growth area chart (crisp, 1 unit = 1px) ──────────────────── */ + .sg-chart { display: block; width: 100%; overflow: visible; } + .sg-grid { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; opacity: .7; } + .sg-area { stroke: none; } + .sg-line { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; } + .sg-dot { fill: var(--bg-card); stroke: var(--accent); stroke-width: 2; transition: r .12s ease; } + .sg-dot.active { r: 5.5; fill: var(--accent); } + .sg-hit { fill: transparent; cursor: pointer; outline: none; } + .sg-ylabel, .sg-xlabel, .sg-unit { fill: var(--muted); font-size: 11px; font-family: inherit; } + .sg-unit { font-weight: 700; } /* Interactive chart tooltip */ .chart-tip { position: absolute; z-index: 5; transform: translate(-50%, calc(-100% - 12px)); @@ -3124,30 +3124,81 @@

Recent Runs

`).join(''); } +// Catmull-Rom → cubic-bezier: a smooth, modern line through the points. +function smoothPath(pts) { + if (pts.length < 2) return pts.length ? `M${pts[0][0]},${pts[0][1]}` : ''; + let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2; + const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; + const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; + d += `C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`; + } + return d; +} + function renderStorageChart(sessions) { const el = document.getElementById('storage-chart'); if (!el) return; const data = sessions.filter(s => typeof s.sizeMB === 'number').slice(0, 10).reverse(); if (data.length < 2) { el.innerHTML = '

Not enough session data to chart.

'; return; } - const W = 380, H = 140, padL = 34, padB = 20, padT = 10, padR = 8; + + // Render at the container's real pixel size so 1 unit == 1px — no stretching, + // so strokes and text stay crisp (the old preserveAspectRatio="none" distorted them). + el.style.position = 'relative'; + // viewBox width == the element's real pixel width → exact 1:1 mapping (crisp, + // no letterboxing or non-uniform text scaling). + const W = Math.round(el.getBoundingClientRect().width) || 420; + const H = 180; + const padL = 40, padT = 16, padB = 30, padR = 14; const iw = W - padL - padR, ih = H - padT - padB; - const max = Math.max(...data.map(d => d.sizeMB)) * 1.1 || 1; + const uid = 'sg' + Math.random().toString(36).slice(2, 8); + + const maxVal = Math.max(...data.map(d => d.sizeMB)); + const max = Math.max(1, Math.ceil(maxVal * 1.15 / 50) * 50); // round headroom to 50 MB const x = i => padL + (iw * i / (data.length - 1)); const y = v => padT + ih - (ih * v / max); const pts = data.map((d, i) => [x(i), y(d.sizeMB)]); - const line = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); - const area = `M${pts[0][0].toFixed(1)} ${(padT+ih).toFixed(1)} ` + pts.map(p => 'L'+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ') + ` L${pts[pts.length-1][0].toFixed(1)} ${(padT+ih).toFixed(1)} Z`; - const gridY = [0, 0.5, 1].map(f => { const gy = padT + ih - ih*f; return `${Math.round(max*f)}`; }).join(''); - // Visible dot + a larger transparent hit target for easy hover/focus. + const baseline = padT + ih; + + const linePath = smoothPath(pts); + const areaPath = `${linePath} L${pts[pts.length - 1][0].toFixed(1)},${baseline} L${pts[0][0].toFixed(1)},${baseline} Z`; + + // Horizontal gridlines + right-aligned y labels. + const ticks = [0, 0.25, 0.5, 0.75, 1]; + const grid = ticks.map(f => { + const gy = padT + ih - ih * f; + return `` + + `${Math.round(max * f)}`; + }).join(''); + + // X-axis date labels: first, middle, last (compact). + const shortDate = d => d && d.createdTime ? new Date(d.createdTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''; + const xIdx = [0, Math.floor((data.length - 1) / 2), data.length - 1]; + const xLabels = xIdx.map((i, k) => { + const anchor = k === 0 ? 'start' : k === xIdx.length - 1 ? 'end' : 'middle'; + return `${shortDate(data[i])}`; + }).join(''); + const dots = pts.map((p, i) => - `` + - `` + `` + + `` ).join(''); - el.style.position = 'relative'; + el.innerHTML = - `` + - `${gridY}${dots}` + - `oldestnewest · MB` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${grid}` + + `` + + `` + + `${dots}${xLabels}` + + `MB` + + `` + ``; const tip = el.querySelector('#storage-tip'); @@ -3158,15 +3209,13 @@

Recent Runs

tip.innerHTML = `
${d.name}
` + `
${d.sizeMB} MB${mode}
` + (when ? `
${when}
` : ''); - // viewBox maps linearly to the element box (preserveAspectRatio=none), - // so fractional coordinates position the tooltip exactly. tip.style.left = (pts[i][0] / W * 100) + '%'; tip.style.top = (pts[i][1] / H * 100) + '%'; tip.hidden = false; - el.querySelectorAll('.area-dot').forEach((c, j) => c.classList.toggle('active', j === i)); + el.querySelectorAll('.sg-dot').forEach((c, j) => c.classList.toggle('active', j === i)); }; - const hide = () => { tip.hidden = true; el.querySelectorAll('.area-dot.active').forEach(c => c.classList.remove('active')); }; - el.querySelectorAll('.area-hit').forEach(h => { + const hide = () => { tip.hidden = true; el.querySelectorAll('.sg-dot.active').forEach(c => c.classList.remove('active')); }; + el.querySelectorAll('.sg-hit').forEach(h => { const i = +h.dataset.i; h.addEventListener('mouseenter', () => show(i)); h.addEventListener('mouseleave', hide); diff --git a/docs/screenshots/dashboard-insights.svg b/docs/screenshots/dashboard-insights.svg index c6fbd39..aead6d7 100644 --- a/docs/screenshots/dashboard-insights.svg +++ b/docs/screenshots/dashboard-insights.svg @@ -45,31 +45,46 @@ Storage Growth Session size over the last 10 backups - - - - - 620 - 310 - 0 - - - + + + + + + + + + + + + + + 650 + 325 + 0 + MB + + + + - + - + - - session-2026-08-20 - 488 MB - - DELTA - 8/20/2026 + + session-2026-08-22 + 397 MB + + DELTA + Aug 22 + + + + Aug 17 + Aug 21 + Aug 26 - oldest - newest · MB diff --git a/docs/sw.js b/docs/sw.js index b6fb8c6..b7f49cc 100644 --- a/docs/sw.js +++ b/docs/sw.js @@ -3,7 +3,7 @@ // This file is available under the GNU Affero General Public License v3.0 // or under a separate commercial license. // Service Worker — cache-first for the app shell -const CACHE = 'gh-backup-v7'; +const CACHE = 'gh-backup-v8'; const SHELL = [ '/github-gdrive-backup/', '/github-gdrive-backup/index.html', diff --git a/package-lock.json b/package-lock.json index 0f31200..aa6a106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-gdrive-backup", - "version": "5.0.2", + "version": "5.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-gdrive-backup", - "version": "5.0.2", + "version": "5.0.3", "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.600.0", diff --git a/package.json b/package.json index 417e1a9..7595b7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-gdrive-backup", - "version": "5.0.2", + "version": "5.0.3", "description": "Back up every GitHub repository to Google Drive — code, issues, PRs, releases, wiki, labels, milestones — and restore with one click.", "main": "src/server/app.js", "bin": {