app.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. /**
  2. * The dashboard page: one filter row, a grid of panels, and a fetch per panel.
  3. *
  4. * Deliberate properties:
  5. * - **One filter row, above everything it scopes.** Changing the range or
  6. * hitting refresh re-queries every panel against the same slice; no panel
  7. * carries its own time control.
  8. * - **Panels fail alone.** Each one fetches, draws, and reports independently,
  9. * so a 503 on one query leaves the other eighteen on screen instead of
  10. * blanking the page.
  11. * - **No client-side cache.** The only reuse is deduplicating identical URLs
  12. * within a single render (four stat tiles read one /api/summary); that map is
  13. * thrown away afterwards, so refresh really does re-ask. Anything longer-lived
  14. * is the API's `Cache-Control` doing its job in the browser's own cache.
  15. * - **No skeleton flash.** A refetch dims the previous render instead of tearing
  16. * it down, so nothing jumps while new numbers land.
  17. * - **Every chart has a table twin.** "Show numbers" reveals the same data as
  18. * text, which is what keeps a value from being reachable only by hovering.
  19. */
  20. import { PANELS } from './panels.js';
  21. import { applyChartDefaults, shortDay } from './theme.js';
  22. const RANGE_PRESETS = [
  23. { days: 7, label: 'Last 7 days' },
  24. { days: 14, label: 'Last 14 days' },
  25. { days: 30, label: 'Last 30 days' },
  26. { days: 90, label: 'Last 90 days' },
  27. ];
  28. const DEFAULT_PRESET = 30;
  29. const DAY_MS = 86_400_000;
  30. const Chart = window.Chart;
  31. /** Every fetch goes through here so an expired session lands on /login instead
  32. * of failing silently mid-render. */
  33. export async function api(path) {
  34. const response = await fetch(path, { headers: { accept: 'application/json' } });
  35. if (response.status === 401) {
  36. window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
  37. throw new Error('session expired');
  38. }
  39. if (!response.ok) {
  40. const detail = await response.json().catch(() => null);
  41. throw new Error(detail?.error ?? `responded ${response.status}`);
  42. }
  43. return response.json();
  44. }
  45. // ---------------------------------------------------------------------------
  46. // Days
  47. // ---------------------------------------------------------------------------
  48. const utcDay = (atMs) => new Date(atMs).toISOString().slice(0, 10);
  49. const dayMs = (day) => Date.parse(`${day}T00:00:00Z`);
  50. const addDays = (day, delta) => utcDay(dayMs(day) + delta * DAY_MS);
  51. const isDay = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(dayMs(value));
  52. // ---------------------------------------------------------------------------
  53. // State
  54. // ---------------------------------------------------------------------------
  55. const state = {
  56. /** Latest day the nightly rollup has written; every preset ends here. */
  57. anchor: utcDay(Date.now()),
  58. earliest: null,
  59. preset: DEFAULT_PRESET,
  60. custom: { from: null, to: null },
  61. /** Panels whose table twin the reader has opened, kept across re-renders. */
  62. openTables: new Set(),
  63. renderToken: 0,
  64. };
  65. const charts = new Map();
  66. function currentRange() {
  67. if (state.preset === 'custom' && state.custom.from && state.custom.to) {
  68. return { from: state.custom.from, to: state.custom.to };
  69. }
  70. const to = state.anchor;
  71. return { from: addDays(to, -(state.preset - 1)), to };
  72. }
  73. // ---------------------------------------------------------------------------
  74. // DOM helpers
  75. // ---------------------------------------------------------------------------
  76. function el(tag, className, text) {
  77. const node = document.createElement(tag);
  78. if (className) node.className = className;
  79. if (text !== undefined) node.textContent = text;
  80. return node;
  81. }
  82. const $ = (root, role) => root.querySelector(`[data-role="${role}"]`);
  83. // ---------------------------------------------------------------------------
  84. // Building the page
  85. // ---------------------------------------------------------------------------
  86. function buildFilters() {
  87. const bar = document.getElementById('filters');
  88. const presets = $(bar, 'presets');
  89. for (const preset of RANGE_PRESETS) {
  90. const button = el('button', 'range', preset.label);
  91. button.type = 'button';
  92. button.dataset.days = String(preset.days);
  93. button.addEventListener('click', () => {
  94. state.preset = preset.days;
  95. syncFilters();
  96. render();
  97. });
  98. presets.append(button);
  99. }
  100. const from = $(bar, 'custom-from');
  101. const to = $(bar, 'custom-to');
  102. const apply = $(bar, 'custom-apply');
  103. apply.addEventListener('click', () => {
  104. if (!isDay(from.value) || !isDay(to.value)) {
  105. setRangeSummary('Enter both dates as YYYY-MM-DD.');
  106. return;
  107. }
  108. if (from.value > to.value) {
  109. setRangeSummary('The start date must come before the end date.');
  110. return;
  111. }
  112. state.preset = 'custom';
  113. state.custom = { from: from.value, to: to.value };
  114. syncFilters();
  115. render();
  116. });
  117. $(bar, 'refresh').addEventListener('click', () => {
  118. refreshMeta().finally(render);
  119. });
  120. }
  121. function syncFilters() {
  122. const bar = document.getElementById('filters');
  123. for (const button of bar.querySelectorAll('button.range')) {
  124. const selected = String(state.preset) === button.dataset.days;
  125. button.classList.toggle('is-selected', selected);
  126. button.setAttribute('aria-pressed', String(selected));
  127. }
  128. const { from, to } = currentRange();
  129. $(bar, 'custom-from').value = from;
  130. $(bar, 'custom-to').value = to;
  131. }
  132. function setRangeSummary(text) {
  133. document.getElementById('range-summary').textContent = text;
  134. }
  135. function buildPanels() {
  136. const grid = document.getElementById('grid');
  137. for (const panel of PANELS) {
  138. const section = el('section', `panel span-${panel.span}`);
  139. section.id = `panel-${panel.id}`;
  140. section.dataset.panel = panel.id;
  141. section.dataset.state = 'loading';
  142. const head = el('div', 'panel-head');
  143. head.append(el('h2', null, panel.title));
  144. const figure = el('p', 'panel-figure');
  145. figure.dataset.role = 'figure';
  146. head.append(figure);
  147. section.append(head);
  148. if (panel.note) section.append(el('p', 'panel-note', panel.note));
  149. const body = el('div', 'panel-body');
  150. body.dataset.role = 'body';
  151. if (panel.kind === 'chart') {
  152. const wrap = el('div', 'chart-wrap');
  153. const canvas = document.createElement('canvas');
  154. canvas.dataset.role = 'canvas';
  155. // Chart.js renders to canvas, so the accessible copy is the table twin
  156. // below — say so rather than leaving a bare graphic.
  157. canvas.setAttribute('role', 'img');
  158. canvas.setAttribute('aria-label', `${panel.title}. The same data is in the table below.`);
  159. wrap.append(canvas);
  160. body.append(wrap);
  161. } else if (panel.kind === 'stat') {
  162. const stat = el('div', 'stat');
  163. stat.dataset.role = 'stat';
  164. stat.append(el('p', 'stat-value'), el('p', 'stat-caption'));
  165. body.append(stat);
  166. } else if (panel.kind === 'funnel') {
  167. const funnel = el('div', 'funnel');
  168. funnel.dataset.role = 'funnel';
  169. body.append(funnel);
  170. }
  171. const status = el('p', 'panel-state');
  172. status.dataset.role = 'state';
  173. body.append(status);
  174. section.append(body);
  175. const toggle = el('button', 'link', 'Show numbers');
  176. toggle.type = 'button';
  177. toggle.dataset.role = 'toggle';
  178. toggle.setAttribute('aria-expanded', 'false');
  179. const table = el('div', 'table-wrap');
  180. table.dataset.role = 'table';
  181. table.hidden = true;
  182. toggle.addEventListener('click', () => {
  183. const open = table.hidden;
  184. table.hidden = !open;
  185. toggle.textContent = open ? 'Hide numbers' : 'Show numbers';
  186. toggle.setAttribute('aria-expanded', String(open));
  187. if (open) state.openTables.add(panel.id);
  188. else state.openTables.delete(panel.id);
  189. });
  190. section.append(toggle, table);
  191. grid.append(section);
  192. }
  193. }
  194. // ---------------------------------------------------------------------------
  195. // Drawing one panel
  196. // ---------------------------------------------------------------------------
  197. function setState(section, name, message) {
  198. section.dataset.state = name;
  199. $(section, 'state').textContent = message ?? '';
  200. }
  201. function drawTable(section, spec) {
  202. const host = $(section, 'table');
  203. host.replaceChildren();
  204. if (!spec) return;
  205. const table = el('table');
  206. const thead = el('thead');
  207. const headRow = el('tr');
  208. for (const column of spec.columns) {
  209. const th = el('th', null, column);
  210. th.scope = 'col';
  211. headRow.append(th);
  212. }
  213. thead.append(headRow);
  214. const tbody = el('tbody');
  215. for (const row of spec.rows) {
  216. const tr = el('tr');
  217. row.forEach((cell, i) => {
  218. const node = el(i === 0 ? 'th' : 'td', null, String(cell));
  219. if (i === 0) node.scope = 'row';
  220. tr.append(node);
  221. });
  222. tbody.append(tr);
  223. }
  224. table.append(thead, tbody);
  225. host.append(table);
  226. }
  227. function drawStat(section, stat) {
  228. const host = $(section, 'stat');
  229. host.querySelector('.stat-value').textContent = stat.value;
  230. host.querySelector('.stat-caption').textContent = stat.caption ?? '';
  231. }
  232. /**
  233. * The two-stage conversion funnel, drawn as proportional bars rather than a
  234. * chart: two bars and a percentage is the whole story, and a two-slice pie or a
  235. * two-bar chart would be more chrome than data.
  236. */
  237. function drawFunnel(section, funnel) {
  238. const host = $(section, 'funnel');
  239. host.replaceChildren();
  240. for (const stage of funnel.stages) {
  241. const row = el('div', 'funnel-stage');
  242. const head = el('div', 'funnel-label');
  243. head.append(el('span', null, stage.label), el('span', 'funnel-value', stage.value.toLocaleString('en-US')));
  244. const track = el('div', 'funnel-track');
  245. const fill = el('div', 'funnel-fill');
  246. // Width is the datum, so it is set from JS rather than a style attribute —
  247. // the CSP here allows no inline styles at all.
  248. fill.style.width = `${Math.max(0, Math.min(1, stage.share)) * 100}%`;
  249. track.append(fill);
  250. row.append(head, track);
  251. host.append(row);
  252. }
  253. const rate = funnel.rate === null ? '—' : `${(funnel.rate * 100).toFixed(1)}%`;
  254. host.append(
  255. el('p', 'funnel-summary', `${rate} converted · ${funnel.dropped.toLocaleString('en-US')} dropped off`),
  256. );
  257. }
  258. function drawChart(section, panel, config) {
  259. const canvas = $(section, 'canvas');
  260. const existing = charts.get(panel.id);
  261. if (existing) existing.destroy();
  262. charts.set(panel.id, new Chart(canvas, config));
  263. }
  264. async function drawPanel(panel, request, token) {
  265. const section = document.getElementById(`panel-${panel.id}`);
  266. section.dataset.stale = 'true';
  267. try {
  268. const data = await request;
  269. // A slower panel from a superseded render must never overwrite the current one.
  270. if (token !== state.renderToken) return;
  271. if (panel.empty?.(data)) {
  272. setState(section, 'empty', 'Nothing in this range.');
  273. drawTable(section, panel.table?.(data));
  274. return;
  275. }
  276. if (panel.kind === 'stat') drawStat(section, panel.stat(data));
  277. else if (panel.kind === 'funnel') drawFunnel(section, panel.funnel(data));
  278. else drawChart(section, panel, panel.chart(data));
  279. $(section, 'figure').textContent = panel.figure ? panel.figure(data) : '';
  280. drawTable(section, panel.table?.(data));
  281. setState(section, 'ready');
  282. } catch (err) {
  283. if (token !== state.renderToken) return;
  284. // One panel's failure is one panel's problem: the message lands in the
  285. // panel, the rest of the page keeps its data.
  286. setState(section, 'error', `Could not load this panel — ${err.message ?? err}`);
  287. const chart = charts.get(panel.id);
  288. if (chart) {
  289. chart.destroy();
  290. charts.delete(panel.id);
  291. }
  292. } finally {
  293. if (token === state.renderToken) section.dataset.stale = 'false';
  294. }
  295. }
  296. // ---------------------------------------------------------------------------
  297. // Rendering everything
  298. // ---------------------------------------------------------------------------
  299. async function refreshMeta() {
  300. try {
  301. const meta = await api('/api/meta');
  302. if (meta.latest_day) state.anchor = meta.latest_day;
  303. state.earliest = meta.earliest_day ?? null;
  304. syncFilters();
  305. } catch {
  306. // A meta failure is not fatal: the picker falls back to today's date and
  307. // every panel still answers. The banner is what says so.
  308. document.getElementById('data-through').textContent = 'Could not read the data range.';
  309. }
  310. }
  311. async function render() {
  312. const token = ++state.renderToken;
  313. const { from, to } = currentRange();
  314. const query = `from=${from}&to=${to}`;
  315. setRangeSummary(`${shortDay(from)} – ${shortDay(to)}, ${to.slice(0, 4)}`);
  316. document.getElementById('data-through').textContent = `Data through ${shortDay(state.anchor)}`;
  317. // Deduplicate identical URLs within THIS render only — the four stat tiles
  318. // share one /api/summary. Discarded when the render ends, so refresh refetches.
  319. const inFlight = new Map();
  320. const request = (path) => {
  321. if (!inFlight.has(path)) inFlight.set(path, api(path));
  322. return inFlight.get(path);
  323. };
  324. await Promise.allSettled(PANELS.map((panel) => drawPanel(panel, request(panel.source(query)), token)));
  325. if (token === state.renderToken) {
  326. document.getElementById('refreshed-at').textContent =
  327. `Last refreshed ${new Date().toLocaleTimeString('en-US')}`;
  328. document.body.dataset.ready = 'true';
  329. }
  330. }
  331. // ---------------------------------------------------------------------------
  332. // Start
  333. // ---------------------------------------------------------------------------
  334. if (!Chart) {
  335. document.getElementById('data-through').textContent =
  336. 'The chart library did not load — run `npm run vendor` and reload.';
  337. } else {
  338. applyChartDefaults(Chart);
  339. buildFilters();
  340. buildPanels();
  341. syncFilters();
  342. await refreshMeta();
  343. await render();
  344. }