diff --git a/.changeset/stats-ios-palette.md b/.changeset/stats-ios-palette.md new file mode 100644 index 0000000..fbac811 --- /dev/null +++ b/.changeset/stats-ios-palette.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +stats.ftw.energy now uses the phone app's colours, with a light theme that follows the device. The charts are ink, live is green and a failed collector is red. diff --git a/stats/src/dashboard.ts b/stats/src/dashboard.ts index 60dd233..1d6c99a 100644 --- a/stats/src/dashboard.ts +++ b/stats/src/dashboard.ts @@ -12,7 +12,7 @@ export function dashboardDocument(privateView: boolean): DashboardDocument { - + FTW project stats @@ -230,7 +230,7 @@ ${privateView ? opsSection : ''}
maxAgeHours * 3600000 ? 'warn' : 'ok')); + const dot = node('span', '', 'dot ' + (!timestamp ? 'idle' : Date.now() - Date.parse(timestamp) > maxAgeHours * 3600000 ? 'stale' : 'ok')); item.append(dot, node('span', label), node('strong', age(timestamp))); host.append(item); } @@ -536,7 +536,7 @@ ${privateView ? opsScript : ''} function renderPrivate(data) { collectors.replaceChildren(); for (const collector of data.collectors || []) { const item = node('li'); - item.append(node('span', '', 'dot ' + (collector.ok ? 'ok' : 'warn')), node('strong', collector.source), node('span', age(collector.finished_at), 'muted')); + item.append(node('span', '', 'dot ' + (collector.ok ? 'ok' : 'alarm')), node('strong', collector.source), node('span', age(collector.finished_at), 'muted')); collectors.append(item); } if (!collectors.children.length) collectors.append(node('li', 'No collector runs yet.', 'empty')); @@ -584,9 +584,10 @@ function randomNonce(): string { } const styles = ` -:root{--bg:#0b0c0b;--surface:#111311;--raised:#171917;--line:#2a2d2a;--fg:#f2f1ec;--dim:#9c9f9a;--accent:#ffab2e;--accent-soft:#3b2b13;--ok:#72d69b;--warn:#ffbf5f;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg);background:var(--bg);font-synthesis:none} +:root{color-scheme:dark;--bg:#05080f;--surface:#0b111c;--raised:#111a29;--line:#1b2433;--fg:#eef3ff;--dim:#9aa5b8;--muted:#727d90;--live:#83f52c;--alarm:#ff453a;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg);background:var(--bg);font-synthesis:none} +@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#f5f6f8;--surface:#ffffff;--raised:#ffffff;--line:#dce0e7;--fg:#0a0f1a;--dim:#545e70;--muted:#677184;--live:#2f7a00;--alarm:#d70015}} [hidden]{display:none!important} -*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -20%,#252116 0,transparent 34rem),var(--bg);min-height:100vh}a{color:inherit}.topbar{height:58px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);position:sticky;top:0;background:color-mix(in srgb,var(--bg) 90%,transparent);backdrop-filter:blur(16px);z-index:5}.brand{display:flex;gap:10px;align-items:center;text-decoration:none;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.18em}.brand-mark{color:var(--accent)}.brand-slash{color:#5e615d}.top-actions{display:flex;align-items:center;gap:16px;font:11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.mode{color:var(--accent);border:1px solid #59401c;padding:6px 8px;border-radius:2px}.muted{color:var(--dim)}main{width:min(1280px,calc(100% - 36px));margin:0 auto;padding:64px 0 72px}.hero{display:flex;justify-content:space-between;gap:48px;align-items:flex-end;margin-bottom:54px}.eyebrow{color:var(--accent);font:700 10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.2em;margin:0 0 12px}h1{font-size:clamp(36px,5vw,72px);line-height:.98;letter-spacing:-.055em;max-width:800px;margin:0}h2{font-size:20px;letter-spacing:-.025em;margin:0}h3{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;margin:0 0 10px}.lede{color:var(--dim);font-size:17px;max-width:660px;line-height:1.5;margin:20px 0 0}.freshness{min-width:230px;display:grid;gap:8px}.freshness-item{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;font-size:12px;color:var(--dim)}.freshness-item strong{font-weight:500;color:var(--fg)}.dot{width:7px;height:7px;border-radius:50%;background:#555;display:inline-block}.dot.ok{background:var(--ok);box-shadow:0 0 12px color-mix(in srgb,var(--ok) 50%,transparent)}.dot.warn{background:var(--warn)}.dot.idle{background:#555}.notice{border:1px solid #674a20;background:#21190e;color:#ffd48f;padding:14px 16px;margin-bottom:24px}.section-head,.panel-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px}.section-head{margin:0 0 16px}.section-copy{color:var(--dim);font-size:12px;line-height:1.5;max-width:560px;margin:0;text-align:right}.overview-grid,.metric-grid{display:grid;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-bottom:28px}.overview-grid{grid-template-columns:repeat(5,minmax(0,1fr))}.metric-grid{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.metric-card{background:color-mix(in srgb,var(--surface) 92%,transparent);min-height:154px;padding:22px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column}.overview-card{min-height:142px}.metric-card>span,.mini-grid span{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}.metric-card strong{font-size:42px;line-height:1;margin:auto 0 12px;letter-spacing:-.05em}.metric-card.status-card strong{font-size:30px;letter-spacing:-.035em}.metric-card small{color:var(--dim);font-size:12px;line-height:1.35}.two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:18px 0}.panel{background:color-mix(in srgb,var(--surface) 94%,transparent);border:1px solid var(--line);padding:22px;margin:18px 0}.two-col>.panel{margin:0}.chart-panel{min-height:330px}.chart{height:235px;margin-top:24px}.chart svg{display:block;width:100%;height:100%;overflow:visible}h2{scroll-margin-top:90px}.mini-grid.usage-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.chart-panel .muted{font-size:11px}.chart-area{fill:color-mix(in srgb,var(--accent) 9%,transparent)}.chart-line{fill:none;stroke:var(--accent);stroke-width:3;stroke-linejoin:round;stroke-linecap:round}.bars{height:230px;display:flex;gap:4px;align-items:flex-end;margin-top:24px;border-bottom:1px solid var(--line);padding:0 2px}.bars.compact{height:150px}.bar-wrap{height:100%;flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:8px}.bar{width:100%;max-width:28px;background:linear-gradient(to top,#88520d,var(--accent));border-radius:2px 2px 0 0;min-height:2px}.bar.withheld{background:#4a4c49}.bar.h0{height:2%}.bar.h1{height:5%}.bar.h2{height:10%}.bar.h3{height:15%}.bar.h4{height:20%}.bar.h5{height:25%}.bar.h6{height:30%}.bar.h7{height:35%}.bar.h8{height:40%}.bar.h9{height:45%}.bar.h10{height:50%}.bar.h11{height:55%}.bar.h12{height:60%}.bar.h13{height:65%}.bar.h14{height:70%}.bar.h15{height:75%}.bar.h16{height:80%}.bar.h17{height:85%}.bar.h18{height:90%}.bar.h19{height:95%}.bar.h20{height:100%}.bar-wrap span{font-size:8px;color:var(--dim);height:12px;white-space:nowrap}.table-wrap{overflow-x:auto;margin-top:20px}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;color:var(--dim);font:10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.08em;padding:12px 14px;border-bottom:1px solid var(--line)}td{padding:15px 14px;border-bottom:1px solid #232523;white-space:nowrap}td:first-child{font-weight:650}td a{text-decoration-color:#51544f;text-underline-offset:3px}.repo-role{display:block;color:var(--dim);font-size:11px;font-weight:400;margin-top:4px}.numeric{font-variant-numeric:tabular-nums}.empty{color:var(--dim);padding:30px 4px;text-align:center}.panel-copy,.footnote{color:var(--dim);font-size:13px;line-height:1.5}.big-inline{font-size:28px;font-weight:700;letter-spacing:-.04em}.dimension-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:26px}.dimension{border-top:1px solid var(--line);padding-top:14px}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:#d5d3cb;background:var(--raised);border:1px solid var(--line);padding:6px 8px;border-radius:2px}.mini-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);margin-top:22px}.mini-grid>div{background:var(--raised);padding:18px;display:flex;flex-direction:column;gap:12px}.mini-grid strong{font-size:26px}.ranked-list,.status-list{list-style:none;padding:0;margin:18px 0 0}.ranked-list li{display:flex;justify-content:space-between;gap:18px;padding:12px 0;border-bottom:1px solid var(--line);font-size:13px}.status-list li{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid var(--line);font-size:12px}footer{border-top:1px solid var(--line);padding:24px clamp(18px,4vw,56px);display:flex;justify-content:space-between;gap:20px;color:var(--dim);font-size:12px}footer a{color:var(--fg);text-underline-offset:3px} +*{box-sizing:border-box}body{margin:0;background:var(--bg);min-height:100vh}a{color:inherit}.topbar{height:58px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);position:sticky;top:0;background:color-mix(in srgb,var(--bg) 90%,transparent);backdrop-filter:blur(16px);z-index:5}.brand{display:flex;gap:10px;align-items:center;text-decoration:none;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.18em}.brand-mark{color:var(--fg)}.brand-slash{color:var(--muted)}.top-actions{display:flex;align-items:center;gap:16px;font:11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.mode{color:var(--fg);border:1px solid var(--line);padding:6px 8px;border-radius:2px}.muted{color:var(--dim)}main{width:min(1280px,calc(100% - 36px));margin:0 auto;padding:64px 0 72px}.hero{display:flex;justify-content:space-between;gap:48px;align-items:flex-end;margin-bottom:54px}.eyebrow{color:var(--dim);font:700 10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.2em;margin:0 0 12px}h1{font-size:clamp(36px,5vw,72px);line-height:.98;letter-spacing:-.055em;max-width:800px;margin:0}h2{font-size:20px;letter-spacing:-.025em;margin:0}h3{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;margin:0 0 10px}.lede{color:var(--dim);font-size:17px;max-width:660px;line-height:1.5;margin:20px 0 0}.freshness{min-width:230px;display:grid;gap:8px}.freshness-item{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;font-size:12px;color:var(--dim)}.freshness-item strong{font-weight:500;color:var(--fg)}.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);display:inline-block}.dot.ok{background:var(--live);box-shadow:0 0 12px color-mix(in srgb,var(--live) 50%,transparent)}.dot.stale{background:var(--dim)}.dot.alarm{background:var(--alarm)}.dot.idle{background:var(--muted)}.notice{border:1px solid color-mix(in srgb,var(--alarm) 45%,transparent);background:color-mix(in srgb,var(--alarm) 12%,transparent);color:var(--fg);padding:14px 16px;margin-bottom:24px}.section-head,.panel-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px}.section-head{margin:0 0 16px}.section-copy{color:var(--dim);font-size:12px;line-height:1.5;max-width:560px;margin:0;text-align:right}.overview-grid,.metric-grid{display:grid;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-bottom:28px}.overview-grid{grid-template-columns:repeat(5,minmax(0,1fr))}.metric-grid{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.metric-card{background:color-mix(in srgb,var(--surface) 92%,transparent);min-height:154px;padding:22px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column}.overview-card{min-height:142px}.metric-card>span,.mini-grid span{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}.metric-card strong{font-size:42px;line-height:1;margin:auto 0 12px;letter-spacing:-.05em}.metric-card.status-card strong{font-size:30px;letter-spacing:-.035em}.metric-card small{color:var(--dim);font-size:12px;line-height:1.35}.two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:18px 0}.panel{background:color-mix(in srgb,var(--surface) 94%,transparent);border:1px solid var(--line);padding:22px;margin:18px 0}.two-col>.panel{margin:0}.chart-panel{min-height:330px}.chart{height:235px;margin-top:24px}.chart svg{display:block;width:100%;height:100%;overflow:visible}h2{scroll-margin-top:90px}.mini-grid.usage-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.chart-panel .muted{font-size:11px}.chart-area{fill:color-mix(in srgb,var(--fg) 9%,transparent)}.chart-line{fill:none;stroke:var(--fg);stroke-width:3;stroke-linejoin:round;stroke-linecap:round}.bars{height:230px;display:flex;gap:4px;align-items:flex-end;margin-top:24px;border-bottom:1px solid var(--line);padding:0 2px}.bars.compact{height:150px}.bar-wrap{height:100%;flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:8px}.bar{width:100%;max-width:28px;background:linear-gradient(to top,color-mix(in srgb,var(--fg) 32%,transparent),color-mix(in srgb,var(--fg) 80%,transparent));border-radius:2px 2px 0 0;min-height:2px}.bar.withheld{background:var(--muted)}.bar.h0{height:2%}.bar.h1{height:5%}.bar.h2{height:10%}.bar.h3{height:15%}.bar.h4{height:20%}.bar.h5{height:25%}.bar.h6{height:30%}.bar.h7{height:35%}.bar.h8{height:40%}.bar.h9{height:45%}.bar.h10{height:50%}.bar.h11{height:55%}.bar.h12{height:60%}.bar.h13{height:65%}.bar.h14{height:70%}.bar.h15{height:75%}.bar.h16{height:80%}.bar.h17{height:85%}.bar.h18{height:90%}.bar.h19{height:95%}.bar.h20{height:100%}.bar-wrap span{font-size:8px;color:var(--dim);height:12px;white-space:nowrap}.table-wrap{overflow-x:auto;margin-top:20px}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;color:var(--dim);font:10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.08em;padding:12px 14px;border-bottom:1px solid var(--line)}td{padding:15px 14px;border-bottom:1px solid var(--line);white-space:nowrap}td:first-child{font-weight:650}td a{text-decoration-color:var(--muted);text-underline-offset:3px}.repo-role{display:block;color:var(--dim);font-size:11px;font-weight:400;margin-top:4px}.numeric{font-variant-numeric:tabular-nums}.empty{color:var(--dim);padding:30px 4px;text-align:center}.panel-copy,.footnote{color:var(--dim);font-size:13px;line-height:1.5}.big-inline{font-size:28px;font-weight:700;letter-spacing:-.04em}.dimension-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:26px}.dimension{border-top:1px solid var(--line);padding-top:14px}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--fg);background:var(--raised);border:1px solid var(--line);padding:6px 8px;border-radius:2px}.mini-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);margin-top:22px}.mini-grid>div{background:var(--raised);padding:18px;display:flex;flex-direction:column;gap:12px}.mini-grid strong{font-size:26px}.ranked-list,.status-list{list-style:none;padding:0;margin:18px 0 0}.ranked-list li{display:flex;justify-content:space-between;gap:18px;padding:12px 0;border-bottom:1px solid var(--line);font-size:13px}.status-list li{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid var(--line);font-size:12px}footer{border-top:1px solid var(--line);padding:24px clamp(18px,4vw,56px);display:flex;justify-content:space-between;gap:20px;color:var(--dim);font-size:12px}footer a{color:var(--fg);text-underline-offset:3px} .mini-grid.site-grid{grid-template-columns:repeat(3,1fr)} .usage-subhead{margin:28px 0 0} @media(max-width:1100px){.overview-grid{grid-template-columns:repeat(3,1fr)}} diff --git a/tests/stats-dashboard.test.ts b/tests/stats-dashboard.test.ts index 843391a..8501ed2 100644 --- a/tests/stats-dashboard.test.ts +++ b/tests/stats-dashboard.test.ts @@ -65,7 +65,8 @@ describe('stats dashboard coverage', () => { await render() const items = [...document.querySelectorAll('#freshness .freshness-item')] expect(items.find((item) => item.textContent!.startsWith('Repo traffic'))!.querySelector('.dot.ok')).not.toBeNull() - expect(items.find((item) => item.textContent!.startsWith('GitHub'))!.querySelector('.dot.warn')).not.toBeNull() + // A stale source falls to ink2, as freshness does in the app. + expect(items.find((item) => item.textContent!.startsWith('GitHub'))!.querySelector('.dot.stale')).not.toBeNull() // The hourly usage feed and the daily energy feed each have their own dot. expect(items.find((item) => item.textContent!.startsWith('Hourly energy'))!.querySelector('.dot.idle')).not.toBeNull() expect(items.find((item) => item.textContent!.startsWith('Daily energy'))!.querySelector('.dot.idle')).not.toBeNull() @@ -79,4 +80,12 @@ describe('stats dashboard coverage', () => { expect(document.getElementById('relay-rooms')!.textContent).toBe('17') expect(document.getElementById('relay-note')!.textContent).toContain('do not measure FTW Cloud') }) + + it('marks a failed collector as an alarm, not as stale', async () => { + const at = new Date().toISOString() + await render(true, { collectors: [{ source: 'github', ok: true, finished_at: at }, { source: 'cloud_ops', ok: false, finished_at: at }] }) + const rows = [...document.querySelectorAll('#collectors li')] + expect(rows.find((row) => row.textContent!.includes('github'))!.querySelector('.dot.ok')).not.toBeNull() + expect(rows.find((row) => row.textContent!.includes('cloud_ops'))!.querySelector('.dot.alarm')).not.toBeNull() + }) }) diff --git a/tests/stats-palette.test.ts b/tests/stats-palette.test.ts new file mode 100644 index 0000000..6a8dbbf --- /dev/null +++ b/tests/stats-palette.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { dashboardDocument } from '../stats/src/dashboard.ts' + +// The stats Worker serves its own page and cannot load the app's +// stylesheet, so it carries a copy of the roles it uses. This holds the +// copy to src/styles/tokens.css in both themes. +const tokens = readFileSync('src/styles/tokens.css', 'utf8') +const [tokensDark = '', tokensLight = ''] = tokens.split("html[data-theme='light']") + +function value(css: string, name: string): string | undefined { + return new RegExp(`--${name}:\\s*([^;}]+)[;}]`).exec(css)?.[1]?.trim() +} + +const page = dashboardDocument(false).html +const styles = /]*>([\s\S]*?)<\/style>/.exec(page)?.[1] ?? '' +const [statsDark = '', statsLight = ''] = styles.split('@media(prefers-color-scheme:light)') + +// Stats name → the app's role. +const ROLES: Record = { + bg: 'surface', + surface: 'surface-raised', + raised: 'surface-elevated', + line: 'line', + fg: 'fg', + dim: 'fg-dim', + muted: 'fg-muted', + live: 'live', + alarm: 'alarm', +} + +describe('stats page palette', () => { + it('takes the app palette in both themes', () => { + for (const [name, role] of Object.entries(ROLES)) { + const dark = value(tokensDark, role) + const light = value(tokensLight, role) ?? dark + expect(value(statsDark, name), `${name} dark`).toBe(dark) + expect(value(statsLight.slice(0, statsLight.indexOf('}}') + 2), name), `${name} light`).toBe(light) + } + expect(page).toContain('') + }) + + it('keeps raw colours in the two token blocks only', () => { + const rules = styles + .replace(/:root\{[^}]*\}/, '') + .replace(/@media\(prefers-color-scheme:light\)\{:root\{[^}]*\}\}/, '') + expect(rules.match(/#[0-9a-f]{3,8}\b|rgba?\(/gi) ?? []).toEqual([]) + }) +})