| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- /**
- * The dashboard page: one filter row, a grid of panels, and a fetch per panel.
- *
- * Deliberate properties:
- * - **One filter row, above everything it scopes.** Changing the range or
- * hitting refresh re-queries every panel against the same slice; no panel
- * carries its own time control.
- * - **Panels fail alone.** Each one fetches, draws, and reports independently,
- * so a 503 on one query leaves the other eighteen on screen instead of
- * blanking the page.
- * - **No client-side cache.** The only reuse is deduplicating identical URLs
- * within a single render (four stat tiles read one /api/summary); that map is
- * thrown away afterwards, so refresh really does re-ask. Anything longer-lived
- * is the API's `Cache-Control` doing its job in the browser's own cache.
- * - **No skeleton flash.** A refetch dims the previous render instead of tearing
- * it down, so nothing jumps while new numbers land.
- * - **Every chart has a table twin.** "Show numbers" reveals the same data as
- * text, which is what keeps a value from being reachable only by hovering.
- */
- import { PANELS } from './panels.js';
- import { applyChartDefaults, shortDay } from './theme.js';
- const RANGE_PRESETS = [
- { days: 7, label: 'Last 7 days' },
- { days: 14, label: 'Last 14 days' },
- { days: 30, label: 'Last 30 days' },
- { days: 90, label: 'Last 90 days' },
- ];
- const DEFAULT_PRESET = 30;
- const DAY_MS = 86_400_000;
- const Chart = window.Chart;
- /** Every fetch goes through here so an expired session lands on /login instead
- * of failing silently mid-render. */
- export async function api(path) {
- const response = await fetch(path, { headers: { accept: 'application/json' } });
- if (response.status === 401) {
- window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
- throw new Error('session expired');
- }
- if (!response.ok) {
- const detail = await response.json().catch(() => null);
- throw new Error(detail?.error ?? `responded ${response.status}`);
- }
- return response.json();
- }
- // ---------------------------------------------------------------------------
- // Days
- // ---------------------------------------------------------------------------
- const utcDay = (atMs) => new Date(atMs).toISOString().slice(0, 10);
- const dayMs = (day) => Date.parse(`${day}T00:00:00Z`);
- const addDays = (day, delta) => utcDay(dayMs(day) + delta * DAY_MS);
- const isDay = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(dayMs(value));
- // ---------------------------------------------------------------------------
- // State
- // ---------------------------------------------------------------------------
- const state = {
- /** Latest day the nightly rollup has written; every preset ends here. */
- anchor: utcDay(Date.now()),
- earliest: null,
- preset: DEFAULT_PRESET,
- custom: { from: null, to: null },
- /** Panels whose table twin the reader has opened, kept across re-renders. */
- openTables: new Set(),
- renderToken: 0,
- };
- const charts = new Map();
- function currentRange() {
- if (state.preset === 'custom' && state.custom.from && state.custom.to) {
- return { from: state.custom.from, to: state.custom.to };
- }
- const to = state.anchor;
- return { from: addDays(to, -(state.preset - 1)), to };
- }
- // ---------------------------------------------------------------------------
- // DOM helpers
- // ---------------------------------------------------------------------------
- function el(tag, className, text) {
- const node = document.createElement(tag);
- if (className) node.className = className;
- if (text !== undefined) node.textContent = text;
- return node;
- }
- const $ = (root, role) => root.querySelector(`[data-role="${role}"]`);
- // ---------------------------------------------------------------------------
- // Building the page
- // ---------------------------------------------------------------------------
- function buildFilters() {
- const bar = document.getElementById('filters');
- const presets = $(bar, 'presets');
- for (const preset of RANGE_PRESETS) {
- const button = el('button', 'range', preset.label);
- button.type = 'button';
- button.dataset.days = String(preset.days);
- button.addEventListener('click', () => {
- state.preset = preset.days;
- syncFilters();
- render();
- });
- presets.append(button);
- }
- const from = $(bar, 'custom-from');
- const to = $(bar, 'custom-to');
- const apply = $(bar, 'custom-apply');
- apply.addEventListener('click', () => {
- if (!isDay(from.value) || !isDay(to.value)) {
- setRangeSummary('Enter both dates as YYYY-MM-DD.');
- return;
- }
- if (from.value > to.value) {
- setRangeSummary('The start date must come before the end date.');
- return;
- }
- state.preset = 'custom';
- state.custom = { from: from.value, to: to.value };
- syncFilters();
- render();
- });
- $(bar, 'refresh').addEventListener('click', () => {
- refreshMeta().finally(render);
- });
- }
- function syncFilters() {
- const bar = document.getElementById('filters');
- for (const button of bar.querySelectorAll('button.range')) {
- const selected = String(state.preset) === button.dataset.days;
- button.classList.toggle('is-selected', selected);
- button.setAttribute('aria-pressed', String(selected));
- }
- const { from, to } = currentRange();
- $(bar, 'custom-from').value = from;
- $(bar, 'custom-to').value = to;
- }
- function setRangeSummary(text) {
- document.getElementById('range-summary').textContent = text;
- }
- function buildPanels() {
- const grid = document.getElementById('grid');
- for (const panel of PANELS) {
- const section = el('section', `panel span-${panel.span}`);
- section.id = `panel-${panel.id}`;
- section.dataset.panel = panel.id;
- section.dataset.state = 'loading';
- const head = el('div', 'panel-head');
- head.append(el('h2', null, panel.title));
- const figure = el('p', 'panel-figure');
- figure.dataset.role = 'figure';
- head.append(figure);
- section.append(head);
- if (panel.note) section.append(el('p', 'panel-note', panel.note));
- const body = el('div', 'panel-body');
- body.dataset.role = 'body';
- if (panel.kind === 'chart') {
- const wrap = el('div', 'chart-wrap');
- const canvas = document.createElement('canvas');
- canvas.dataset.role = 'canvas';
- // Chart.js renders to canvas, so the accessible copy is the table twin
- // below — say so rather than leaving a bare graphic.
- canvas.setAttribute('role', 'img');
- canvas.setAttribute('aria-label', `${panel.title}. The same data is in the table below.`);
- wrap.append(canvas);
- body.append(wrap);
- } else if (panel.kind === 'stat') {
- const stat = el('div', 'stat');
- stat.dataset.role = 'stat';
- stat.append(el('p', 'stat-value'), el('p', 'stat-caption'));
- body.append(stat);
- } else if (panel.kind === 'funnel') {
- const funnel = el('div', 'funnel');
- funnel.dataset.role = 'funnel';
- body.append(funnel);
- }
- const status = el('p', 'panel-state');
- status.dataset.role = 'state';
- body.append(status);
- section.append(body);
- const toggle = el('button', 'link', 'Show numbers');
- toggle.type = 'button';
- toggle.dataset.role = 'toggle';
- toggle.setAttribute('aria-expanded', 'false');
- const table = el('div', 'table-wrap');
- table.dataset.role = 'table';
- table.hidden = true;
- toggle.addEventListener('click', () => {
- const open = table.hidden;
- table.hidden = !open;
- toggle.textContent = open ? 'Hide numbers' : 'Show numbers';
- toggle.setAttribute('aria-expanded', String(open));
- if (open) state.openTables.add(panel.id);
- else state.openTables.delete(panel.id);
- });
- section.append(toggle, table);
- grid.append(section);
- }
- }
- // ---------------------------------------------------------------------------
- // Drawing one panel
- // ---------------------------------------------------------------------------
- function setState(section, name, message) {
- section.dataset.state = name;
- $(section, 'state').textContent = message ?? '';
- }
- function drawTable(section, spec) {
- const host = $(section, 'table');
- host.replaceChildren();
- if (!spec) return;
- const table = el('table');
- const thead = el('thead');
- const headRow = el('tr');
- for (const column of spec.columns) {
- const th = el('th', null, column);
- th.scope = 'col';
- headRow.append(th);
- }
- thead.append(headRow);
- const tbody = el('tbody');
- for (const row of spec.rows) {
- const tr = el('tr');
- row.forEach((cell, i) => {
- const node = el(i === 0 ? 'th' : 'td', null, String(cell));
- if (i === 0) node.scope = 'row';
- tr.append(node);
- });
- tbody.append(tr);
- }
- table.append(thead, tbody);
- host.append(table);
- }
- function drawStat(section, stat) {
- const host = $(section, 'stat');
- host.querySelector('.stat-value').textContent = stat.value;
- host.querySelector('.stat-caption').textContent = stat.caption ?? '';
- }
- /**
- * The two-stage conversion funnel, drawn as proportional bars rather than a
- * chart: two bars and a percentage is the whole story, and a two-slice pie or a
- * two-bar chart would be more chrome than data.
- */
- function drawFunnel(section, funnel) {
- const host = $(section, 'funnel');
- host.replaceChildren();
- for (const stage of funnel.stages) {
- const row = el('div', 'funnel-stage');
- const head = el('div', 'funnel-label');
- head.append(el('span', null, stage.label), el('span', 'funnel-value', stage.value.toLocaleString('en-US')));
- const track = el('div', 'funnel-track');
- const fill = el('div', 'funnel-fill');
- // Width is the datum, so it is set from JS rather than a style attribute —
- // the CSP here allows no inline styles at all.
- fill.style.width = `${Math.max(0, Math.min(1, stage.share)) * 100}%`;
- track.append(fill);
- row.append(head, track);
- host.append(row);
- }
- const rate = funnel.rate === null ? '—' : `${(funnel.rate * 100).toFixed(1)}%`;
- host.append(
- el('p', 'funnel-summary', `${rate} converted · ${funnel.dropped.toLocaleString('en-US')} dropped off`),
- );
- }
- function drawChart(section, panel, config) {
- const canvas = $(section, 'canvas');
- const existing = charts.get(panel.id);
- if (existing) existing.destroy();
- charts.set(panel.id, new Chart(canvas, config));
- }
- async function drawPanel(panel, request, token) {
- const section = document.getElementById(`panel-${panel.id}`);
- section.dataset.stale = 'true';
- try {
- const data = await request;
- // A slower panel from a superseded render must never overwrite the current one.
- if (token !== state.renderToken) return;
- if (panel.empty?.(data)) {
- setState(section, 'empty', 'Nothing in this range.');
- drawTable(section, panel.table?.(data));
- return;
- }
- if (panel.kind === 'stat') drawStat(section, panel.stat(data));
- else if (panel.kind === 'funnel') drawFunnel(section, panel.funnel(data));
- else drawChart(section, panel, panel.chart(data));
- $(section, 'figure').textContent = panel.figure ? panel.figure(data) : '';
- drawTable(section, panel.table?.(data));
- setState(section, 'ready');
- } catch (err) {
- if (token !== state.renderToken) return;
- // One panel's failure is one panel's problem: the message lands in the
- // panel, the rest of the page keeps its data.
- setState(section, 'error', `Could not load this panel — ${err.message ?? err}`);
- const chart = charts.get(panel.id);
- if (chart) {
- chart.destroy();
- charts.delete(panel.id);
- }
- } finally {
- if (token === state.renderToken) section.dataset.stale = 'false';
- }
- }
- // ---------------------------------------------------------------------------
- // Rendering everything
- // ---------------------------------------------------------------------------
- async function refreshMeta() {
- try {
- const meta = await api('/api/meta');
- if (meta.latest_day) state.anchor = meta.latest_day;
- state.earliest = meta.earliest_day ?? null;
- syncFilters();
- } catch {
- // A meta failure is not fatal: the picker falls back to today's date and
- // every panel still answers. The banner is what says so.
- document.getElementById('data-through').textContent = 'Could not read the data range.';
- }
- }
- async function render() {
- const token = ++state.renderToken;
- const { from, to } = currentRange();
- const query = `from=${from}&to=${to}`;
- setRangeSummary(`${shortDay(from)} – ${shortDay(to)}, ${to.slice(0, 4)}`);
- document.getElementById('data-through').textContent = `Data through ${shortDay(state.anchor)}`;
- // Deduplicate identical URLs within THIS render only — the four stat tiles
- // share one /api/summary. Discarded when the render ends, so refresh refetches.
- const inFlight = new Map();
- const request = (path) => {
- if (!inFlight.has(path)) inFlight.set(path, api(path));
- return inFlight.get(path);
- };
- await Promise.allSettled(PANELS.map((panel) => drawPanel(panel, request(panel.source(query)), token)));
- if (token === state.renderToken) {
- document.getElementById('refreshed-at').textContent =
- `Last refreshed ${new Date().toLocaleTimeString('en-US')}`;
- document.body.dataset.ready = 'true';
- }
- }
- // ---------------------------------------------------------------------------
- // Start
- // ---------------------------------------------------------------------------
- if (!Chart) {
- document.getElementById('data-through').textContent =
- 'The chart library did not load — run `npm run vendor` and reload.';
- } else {
- applyChartDefaults(Chart);
- buildFilters();
- buildPanels();
- syncFilters();
- await refreshMeta();
- await render();
- }
|