From 8521bdcef7128000468912425f566a760fe881ed Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Mon, 19 Oct 2026 08:18:56 +0200 Subject: [PATCH] Fold the stats pages to fit a phone The stats pages were laid out for a desktop width and did not fit a phone. Below 600px the top-alerts rows now take three lines (name, count and sparkline, last seen and mean fix) instead of six columns, the heatmap shrinks to the screen with fewer hour labels, and the drilldown's time-to-resolve bars and occurrences table fold into stacked lines and cards rather than scrolling sideways. Grid children on the stats pages may now shrink below their content width, which is what let the heatmap push the page wider than the screen. CSS and one data-label attribute only: no API, schema or behaviour change at wider widths. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN --- internal/web/static/app.css | 40 ++++++++++++++++++++++++++++ internal/web/static/js/statsalert.js | 13 +++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index b30a128..da15d4c 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1386,6 +1386,46 @@ a.top-row:hover { background: var(--surface-2); } .stats-split { grid-template-columns: 3fr 1fr; align-items: stretch; } } +/* Phones: nothing on the stats pages may be wider than the screen, so the + wide rows fold into two or three lines instead of scrolling sideways. */ +.stats > *, .stats-split > * { min-width: 0; } +@media (max-width: 599px) { + .top-head { display: none; } + .toplist { overflow-x: visible; } + .top-row { + min-width: 0; grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; padding: 10px 4px; + } + .top-row > :nth-child(1) { grid-column: 1; grid-row: 1; font-weight: 600; } + .top-row > :nth-child(6) { grid-column: 2; grid-row: 1; } + .top-row > :nth-child(2) { grid-column: 1; grid-row: 2; } + .top-row > :nth-child(3) { grid-column: 2; grid-row: 2; } + .top-row > :nth-child(4) { grid-column: 1; grid-row: 3; font-size: 12px; } + .top-row > :nth-child(5) { grid-column: 2; grid-row: 3; font-size: 12px; text-align: right; } + .top-row > :nth-child(4)::before { content: "Last seen "; } + .top-row > :nth-child(5)::before { content: "Fix "; } + + .heatmap > * { min-width: 0; } + .heat-row, .heat-cells { gap: 2px; } + .heat-day { width: 26px; } + .heat-cell { height: 18px; border-radius: 3px; } + .heat-hour { font-size: 9px; overflow: visible; white-space: nowrap; } + .heat-hour:not(:nth-child(6n+1)) { visibility: hidden; } + + .fix-row { flex-wrap: wrap; row-gap: 2px; } + .fix-label { width: auto; flex: 1; } + .fix-total { width: auto; } + .fix-track { flex: 1 1 100%; order: 3; } + + .occ-wrap { overflow-x: visible; } + .occ { min-width: 0; } + .occ thead { display: none; } + .occ, .occ tbody { display: block; } + .occ tr { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; padding: 10px 0; border-top: 1px solid var(--border); } + .occ td { border-top: 0; padding: 0; overflow-wrap: anywhere; } + .occ td:first-child { grid-column: 1 / -1; font-weight: 600; } + .occ td[data-label]::before { content: attr(data-label) ": "; color: var(--muted); font-size: 12px; } +} + /* On-call, wide: the hero and the week on the left, your shifts on the right. .view-page > * caps its children at 760px, so the grid lifts that. */ @media (min-width: 900px) { diff --git a/internal/web/static/js/statsalert.js b/internal/web/static/js/statsalert.js index 2b44883..0e3f35a 100644 --- a/internal/web/static/js/statsalert.js +++ b/internal/web/static/js/statsalert.js @@ -103,17 +103,20 @@ function fixBars(d) { })); } +// On a phone each row folds into a card; the labels come from data-label. +const OCC_COLS = ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status']; + function table(d) { return h('div', { class: 'occ-wrap' }, h('table', { class: 'occ' }, - h('thead', {}, h('tr', {}, ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status'].map((t) => h('th', { text: t })))), + h('thead', {}, h('tr', {}, OCC_COLS.map((t) => h('th', { text: t })))), h('tbody', {}, d.occurrences.map((o) => h('tr', {}, h('td', {}, h('a', { href: `/incidents/${o.incident_id}`, text: fmtTime(o.triggered_at) }), ` ${WEEKDAYS[new Date(o.triggered_at * 1000).getUTCDay()]}`), - h('td', { text: sec(o.ack_seconds) }), - h('td', { text: sec(o.resolve_seconds) }), - h('td', { text: o.acknowledged_by || '—' }), - h('td', { text: o.status })))))); + h('td', { 'data-label': OCC_COLS[1], text: sec(o.ack_seconds) }), + h('td', { 'data-label': OCC_COLS[2], text: sec(o.resolve_seconds) }), + h('td', { 'data-label': OCC_COLS[3], text: o.acknowledged_by || '—' }), + h('td', { 'data-label': OCC_COLS[4], text: o.status })))))); } export function body(name, d, error, onRange) {