← back to Ga4 Dashboard
server.js
734 lines
/**
* GA4 Fleet Traffic Dashboard — local server, port 9710
* Serves cached data from cache/data.json. Never makes live API calls on page load.
* Basic-auth: credentials from env (GA4_DASH_USER / GA4_DASH_PASS); dev fallback only.
*
* Cost: $0 local (cache reads, no external API calls per page hit)
*/
const express = require('express');
const basicAuth = require('basic-auth');
const fs = require('fs');
const path = require('path');
const PORT = 9710;
const CACHE_FILE = path.join(__dirname, 'cache', 'data.json');
// Credentials from the environment; the literals are a dev-only fallback so a fresh
// checkout still runs. Set GA4_DASH_USER / GA4_DASH_PASS in the pm2/launchd env for real use.
const DASH_USER = process.env.GA4_DASH_USER || 'admin';
const DASH_PASS = process.env.GA4_DASH_PASS || 'DW2024!';
const app = express();
// Basic auth middleware
app.use((req, res, next) => {
const creds = basicAuth(req);
if (!creds || creds.name !== DASH_USER || creds.pass !== DASH_PASS) {
res.set('WWW-Authenticate', 'Basic realm="GA4 Dashboard"');
return res.status(401).send('Unauthorized');
}
next();
});
// API: return raw cache as JSON
app.get('/api/data', (req, res) => {
if (!fs.existsSync(CACHE_FILE)) {
return res.status(503).json({ error: 'Cache not built yet. Run: python3 etl.py' });
}
try {
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
res.json(raw);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: cache freshness check
app.get('/api/status', (req, res) => {
if (!fs.existsSync(CACHE_FILE)) {
return res.json({ cache: 'missing', message: 'Run: python3 etl.py' });
}
// Guarded: a corrupt/half-written cache must NOT crash the process here.
try {
const stat = fs.statSync(CACHE_FILE);
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
res.json({
cache: 'ready',
fetched_at: raw.fetched_at,
property_count: raw.property_count,
error_count: raw.error_count,
file_mtime: stat.mtime,
file_size_kb: Math.round(stat.size / 1024),
});
} catch (e) {
res.status(500).json({ cache: 'corrupt', error: e.message });
}
});
// Main dashboard HTML
app.get('/', (req, res) => {
res.send(getDashboardHTML());
});
app.listen(PORT, '127.0.0.1', () => {
console.log(`GA4 Fleet Dashboard running at http://127.0.0.1:${PORT}`);
console.log(`Cost: $0 local — no live API calls on page load`);
if (!fs.existsSync(CACHE_FILE)) {
console.log('WARNING: cache not found. Run: python3 etl.py first');
}
});
function getDashboardHTML() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GA4 Fleet Traffic Dashboard</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--accent: #4f8ef7;
--text: #e2e8f0;
--text-dim: #8892a4;
--green: #34d399;
--yellow: #fbbf24;
--red: #f87171;
--cols: 3;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
font-size: 14px;
min-height: 100vh;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 20px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 100;
}
header h1 { font-size: 16px; font-weight: 600; color: var(--accent); flex: 1; min-width: 200px; }
.meta { font-size: 11px; color: var(--text-dim); }
.meta span { color: var(--green); }
.controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.controls label { font-size: 12px; color: var(--text-dim); }
select, input[type=text] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
}
input[type=range] {
width: 80px;
accent-color: var(--accent);
}
.badge {
background: var(--border);
border-radius: 3px;
padding: 2px 6px;
font-size: 11px;
color: var(--text-dim);
}
.badge.ok { background: #1a3a2a; color: var(--green); }
.badge.warn { background: #3a2a1a; color: var(--yellow); }
.badge.err { background: #3a1a1a; color: var(--red); }
#search {
width: 200px;
}
main {
padding: 16px 20px;
}
.summary-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
min-width: 140px;
}
.stat-card .label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
.stat-card .val { font-size: 22px; font-weight: 700; color: var(--accent); }
#grid {
display: grid;
grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
gap: 12px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
transition: border-color 0.15s;
cursor: pointer;
}
.card:hover { border-color: var(--accent); }
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
gap: 8px;
}
.card-name {
font-weight: 600;
font-size: 13px;
color: var(--text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-name a {
color: inherit;
text-decoration: none;
}
.card-name a:hover { color: var(--accent); text-decoration: underline; }
.card-domain {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-domain a {
color: var(--text-dim);
text-decoration: none;
}
.card-domain a:hover { color: var(--accent); }
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 8px;
}
.metric {
background: var(--bg);
border-radius: 4px;
padding: 6px 8px;
}
.metric .m-label { font-size: 10px; color: var(--text-dim); }
.metric .m-val { font-size: 15px; font-weight: 700; color: var(--text); }
.metric .m-val a { color: inherit; text-decoration: none; }
.metric .m-val a:hover { color: var(--accent); text-decoration: underline; }
.metric .m-sub { font-size: 10px; color: var(--text-dim); }
.card-footer {
font-size: 10px;
color: var(--text-dim);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-footer a { color: var(--text-dim); text-decoration: none; }
.card-footer a:hover { color: var(--accent); text-decoration: underline; }
.when {
font-size: 10px;
color: var(--text-dim);
margin-top: 6px;
}
#empty { color: var(--text-dim); text-align: center; padding: 40px; }
.loading { color: var(--text-dim); text-align: center; padding: 60px; font-size: 16px; }
.density-label { font-size: 12px; color: var(--text-dim); white-space: nowrap; }
/* Mode toggle: grid vs table */
.mode-btn {
background: var(--border);
border: none;
color: var(--text-dim);
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.mode-btn.active { background: var(--accent); color: #fff; }
/* Table mode */
#table-wrap { display: none; overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
color: var(--text-dim);
position: sticky;
top: 58px;
cursor: pointer;
white-space: nowrap;
user-select: none;
}
th:hover { color: var(--accent); }
th .sort-arrow { opacity: 0.4; }
th.sorted .sort-arrow { opacity: 1; color: var(--accent); }
td {
border-bottom: 1px solid var(--border);
padding: 7px 10px;
color: var(--text);
}
tr:hover td { background: var(--surface); }
td a { color: var(--accent); text-decoration: none; }
td a:hover { text-decoration: underline; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.no-data { color: var(--text-dim); }
/* Duplicate-domain visual treatment (Fix A) */
.dup-badge {
font-size: 10px;
color: var(--text-dim);
background: var(--border);
border-radius: 3px;
padding: 1px 5px;
opacity: 0.7;
margin-left: 4px;
cursor: default;
}
.card.is-dup { opacity: 0.6; }
tr.is-dup td { opacity: 0.6; }
</style>
</head>
<body>
<header>
<h1>GA4 Fleet Traffic</h1>
<div class="meta" id="meta">Loading...</div>
<div class="controls">
<input type="text" id="search" placeholder="Filter sites..." oninput="render()">
<label>Sort:
<select id="sort" onchange="render()">
<option value="sessions_7d_desc">Sessions 7d (high)</option>
<option value="sessions_30d_desc">Sessions 30d (high)</option>
<option value="users_7d_desc">Users 7d (high)</option>
<option value="pageviews_7d_desc">Pageviews 7d (high)</option>
<option value="alpha">Name A→Z</option>
<option value="domain">Domain A→Z</option>
<option value="created_desc">Created (newest)</option>
<option value="created_asc">Created (oldest)</option>
</select>
</label>
<label>Window:
<select id="window" onchange="render()">
<option value="7d">7 days</option>
<option value="30d">30 days</option>
<option value="both">Both</option>
</select>
</label>
<span class="density-label">Density:</span>
<input type="range" id="density" min="1" max="6" value="3"
oninput="updateDensity(this.value)">
<span id="density-val" style="font-size:11px;color:var(--text-dim)">3 col</span>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-dim);cursor:pointer">
<input type="checkbox" id="hide-dups" onchange="render()" style="accent-color:var(--accent)">
Hide duplicates
</label>
<button class="mode-btn active" id="btn-grid" onclick="setMode('grid')">Grid</button>
<button class="mode-btn" id="btn-table" onclick="setMode('table')">Table</button>
</div>
</header>
<main>
<div class="summary-row" id="summary"></div>
<div id="grid"></div>
<div id="table-wrap">
<table id="tbl">
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" style="display:none">No matching properties.</div>
<p class="loading" id="loading">Loading cache...</p>
</main>
<script>
let ALL = [];
let sortState = { col: null, dir: 1 };
let viewMode = 'grid';
// Persist preferences in localStorage
const PREFS_KEY = 'ga4dash_prefs';
function loadPrefs() {
try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); } catch { return {}; }
}
function savePrefs() {
const p = {
sort: qs('sort').value,
window: qs('window').value,
density: qs('density').value,
mode: viewMode,
hideDups: qs('hide-dups').checked,
};
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
}
function applyPrefs() {
const p = loadPrefs();
if (p.sort) qs('sort').value = p.sort;
if (p.window) qs('window').value = p.window;
if (p.density) { qs('density').value = p.density; updateDensity(p.density, false); }
if (p.mode) setMode(p.mode, false);
// Hide-duplicates checkbox: default CHECKED (true) when no stored pref yet
qs('hide-dups').checked = (p.hideDups !== undefined) ? p.hideDups : true;
}
function qs(id) { return document.getElementById(id); }
function updateDensity(val, save = true) {
document.documentElement.style.setProperty('--cols', val);
qs('density-val').textContent = val + ' col';
if (save) savePrefs();
}
function setMode(mode, save = true) {
viewMode = mode;
qs('btn-grid').classList.toggle('active', mode === 'grid');
qs('btn-table').classList.toggle('active', mode === 'table');
qs('grid').style.display = mode === 'grid' ? '' : 'none';
qs('table-wrap').style.display = mode === 'table' ? '' : 'none';
if (save) { savePrefs(); render(); }
}
function fmtNum(n) {
if (!n && n !== 0) return '-';
return n.toLocaleString();
}
function fmtDate(iso) {
if (!iso) return '-';
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
});
}
function ga4ReportUrl(propertyId) {
return \`https://analytics.google.com/analytics/web/#/p\${propertyId}/reports/explorer\`;
}
function sortData(data) {
const sortKey = qs('sort').value;
return [...data].sort((a, b) => {
const s7a = a.stats_7d?.sessions || 0, s7b = b.stats_7d?.sessions || 0;
const s30a = a.stats_30d?.sessions || 0, s30b = b.stats_30d?.sessions || 0;
const u7a = a.stats_7d?.activeUsers || 0, u7b = b.stats_7d?.activeUsers || 0;
const pv7a = a.stats_7d?.screenPageViews || 0, pv7b = b.stats_7d?.screenPageViews || 0;
switch (sortKey) {
case 'sessions_7d_desc': return s7b - s7a;
case 'sessions_30d_desc': return s30b - s30a;
case 'users_7d_desc': return u7b - u7a;
case 'pageviews_7d_desc': return pv7b - pv7a;
case 'alpha': return (a.display_name || '').localeCompare(b.display_name || '');
case 'domain': return (a.domain || '').localeCompare(b.domain || '');
case 'created_desc': return new Date(b.create_time || 0) - new Date(a.create_time || 0);
case 'created_asc': return new Date(a.create_time || 0) - new Date(b.create_time || 0);
default: return s7b - s7a;
}
});
}
function filterData(data) {
// Duplicate filter (Fix A): when checked, exclude non-canonical entries
const hideDups = qs('hide-dups').checked;
let filtered = hideDups ? data.filter(p => !p.duplicate_domain) : data;
const q = qs('search').value.trim().toLowerCase();
if (!q) return filtered;
const terms = q.split(' ').filter(Boolean);
return filtered.filter(p => {
const haystack = [p.display_name, p.domain, p.property_id, p.measurement_id, p.account_name]
.join(' ').toLowerCase();
return terms.every(t => haystack.includes(t));
});
}
function renderSummary(data) {
const total_7d = data.reduce((s, p) => s + (p.stats_7d?.sessions || 0), 0);
const total_30d = data.reduce((s, p) => s + (p.stats_30d?.sessions || 0), 0);
const total_users_7d = data.reduce((s, p) => s + (p.stats_7d?.activeUsers || 0), 0);
// Active count: exclude non-canonical duplicates (Fix A — dedup)
const active_7d = data.filter(p => !p.duplicate_domain && (p.stats_7d?.sessions || 0) > 0).length;
qs('summary').innerHTML = \`
<div class="stat-card"><div class="label">Sites</div><div class="val">\${data.length}</div></div>
<div class="stat-card"><div class="label">Active (7d)</div><div class="val">\${active_7d}</div></div>
<div class="stat-card"><div class="label">Sessions (7d)</div><div class="val">\${fmtNum(total_7d)}</div></div>
<div class="stat-card"><div class="label">Users (7d)</div><div class="val">\${fmtNum(total_users_7d)}</div></div>
<div class="stat-card"><div class="label">Sessions (30d)</div><div class="val">\${fmtNum(total_30d)}</div></div>
\`;
}
function render() {
savePrefs();
const win = qs('window').value;
// In table mode with an active column sort, use the column sort order;
// otherwise use the dropdown sort (used for grid and initial table state).
const sorted = (viewMode === 'table' && sortState.col)
? sortByState(ALL)
: sortData(ALL);
const filtered = filterData(sorted);
renderSummary(filtered);
const empty = qs('empty');
empty.style.display = filtered.length === 0 ? '' : 'none';
if (viewMode === 'grid') {
renderGrid(filtered, win);
} else {
renderTable(filtered, win);
}
}
function renderGrid(data, win) {
const grid = qs('grid');
grid.innerHTML = data.map(p => {
const pid = p.property_id;
const s7 = p.stats_7d || {};
const s30 = p.stats_30d || {};
const reportUrl = ga4ReportUrl(pid);
const domainDisplay = p.domain ? p.domain.replace(/^https?:\\/\\//, '') : '-';
const domainLink = p.domain ? \`<a href="\${p.domain}" target="_blank" rel="noopener">\${domainDisplay}</a>\` : domainDisplay;
const errBadge = p.error ? \`<span class="badge err" title="\${p.error}">ERR</span>\` : '';
// Duplicate-domain badge (Fix A)
const dupBadge = p.duplicate_domain ? \`<span class="dup-badge" title="Non-canonical duplicate of this domain — another property has more sessions">⧉ dup</span>\` : '';
const hasSessions = (s7.sessions || 0) > 0 || (s30.sessions || 0) > 0;
let metricsHtml = '';
if (win === '7d' || win === 'both') {
metricsHtml += \`
<div class="metric">
<div class="m-label">Sessions (7d)</div>
<div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s7.sessions || 0)}</a></div>
</div>
<div class="metric">
<div class="m-label">Users (7d)</div>
<div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s7.activeUsers || 0)}</a></div>
</div>
<div class="metric">
<div class="m-label">Pageviews (7d)</div>
<div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s7.screenPageViews || 0)}</a></div>
<div class="m-sub"></div>
</div>\`;
}
if (win === '30d' || win === 'both') {
metricsHtml += \`
<div class="metric">
<div class="m-label">Sessions (30d)</div>
<div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s30.sessions || 0)}</a></div>
</div>
<div class="metric">
<div class="m-label">Users (30d)</div>
<div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s30.activeUsers || 0)}</a></div>
</div>
<div class="metric">
<div class="m-label">Pageviews (30d)</div>
<div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s30.screenPageViews || 0)}</a></div>
</div>\`;
}
const createTs = p.create_time ? new Date(p.create_time).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
}) : null;
return \`
<div class="card\${p.duplicate_domain ? ' is-dup' : ''}" onclick="window.open('\${reportUrl}','_blank')">
<div class="card-header">
<div class="card-name"><a href="\${reportUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation()">\${p.display_name || pid}</a>\${dupBadge}</div>
\${errBadge}
\${!hasSessions ? '<span class="badge">0 traffic</span>' : ''}
</div>
<div class="card-domain">\${domainLink}</div>
<div class="metrics">\${metricsHtml}</div>
<div class="card-footer">
<span>\${p.measurement_id || ''}</span>
<a href="\${reportUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation()">GA4 Report</a>
</div>
\${createTs ? \`<div class="when" title="\${p.create_time}">Created \${createTs}</div>\` : ''}
</div>\`;
}).join('');
}
function renderTable(data, win) {
const thead = qs('thead');
const tbody = qs('tbody');
const cols = [
{ label: 'Property', key: 'display_name' },
{ label: 'Domain', key: 'domain' },
...(win !== '30d' ? [
{ label: 'Sessions 7d', key: 'sessions_7d', num: true },
{ label: 'Users 7d', key: 'users_7d', num: true },
{ label: 'PVs 7d', key: 'pvs_7d', num: true },
] : []),
...(win !== '7d' ? [
{ label: 'Sessions 30d', key: 'sessions_30d', num: true },
{ label: 'Users 30d', key: 'users_30d', num: true },
{ label: 'PVs 30d', key: 'pvs_30d', num: true },
] : []),
{ label: 'Measurement ID', key: 'measurement_id' },
{ label: 'Created', key: 'create_time' },
{ label: 'GA4', key: '_link' },
];
// Fix B: wire th onclick for real column sort
thead.innerHTML = '<tr>' + cols.map(c => {
const isActive = sortState.col === c.key;
const arrow = isActive ? (sortState.dir === 1 ? ' ▲' : ' ▼') : ' ⇅';
const activeClass = isActive ? ' class="sorted"' : '';
return \`<th\${activeClass} onclick="tableSort('\${c.key}')">\${c.label}<span class="sort-arrow">\${arrow}</span></th>\`;
}).join('') + '</tr>';
tbody.innerHTML = data.map(p => {
const pid = p.property_id;
const s7 = p.stats_7d || {};
const s30 = p.stats_30d || {};
const reportUrl = ga4ReportUrl(pid);
const domainDisplay = p.domain ? p.domain.replace(/^https?:\\/\\//, '') : '-';
const domainHtml = p.domain ? \`<a href="\${p.domain}" target="_blank" rel="noopener">\${domainDisplay}</a>\` : '-';
const createStr = p.create_time ? fmtDate(p.create_time) : '-';
// Duplicate row badge (Fix A)
const dupLabel = p.duplicate_domain ? \` <span class="dup-badge" title="Non-canonical duplicate">⧉ dup</span>\` : '';
const rowClass = p.duplicate_domain ? ' class="is-dup"' : '';
const cells = [
\`<td><a href="\${reportUrl}" target="_blank" rel="noopener">\${p.display_name || pid}</a>\${dupLabel}</td>\`,
\`<td>\${domainHtml}</td>\`,
...(win !== '30d' ? [
\`<td class="num">\${fmtNum(s7.sessions || 0)}</td>\`,
\`<td class="num">\${fmtNum(s7.activeUsers || 0)}</td>\`,
\`<td class="num">\${fmtNum(s7.screenPageViews || 0)}</td>\`,
] : []),
...(win !== '7d' ? [
\`<td class="num">\${fmtNum(s30.sessions || 0)}</td>\`,
\`<td class="num">\${fmtNum(s30.activeUsers || 0)}</td>\`,
\`<td class="num">\${fmtNum(s30.screenPageViews || 0)}</td>\`,
] : []),
\`<td class="no-data">\${p.measurement_id || '-'}</td>\`,
\`<td title="\${p.create_time || ''}">\${createStr}</td>\`,
\`<td><a href="\${reportUrl}" target="_blank" rel="noopener">Open</a></td>\`,
];
return \`<tr\${rowClass}>\` + cells.join('') + '</tr>';
}).join('');
}
// --- Fix B: table column sort ---
// Map col key → a comparable value for a property row.
function colValue(p, key) {
const s7 = p.stats_7d || {};
const s30 = p.stats_30d || {};
switch (key) {
case 'sessions_7d': return s7.sessions || 0;
case 'users_7d': return s7.activeUsers || 0;
case 'pvs_7d': return s7.screenPageViews || 0;
case 'sessions_30d': return s30.sessions || 0;
case 'users_30d': return s30.activeUsers || 0;
case 'pvs_30d': return s30.screenPageViews || 0;
case 'create_time': return p.create_time || '';
case 'measurement_id': return (p.measurement_id || '').toLowerCase();
case 'display_name': return (p.display_name || '').toLowerCase();
case 'domain': return (p.domain || '').toLowerCase();
case '_link': return p.property_id || '';
default: return (p[key] || '').toString().toLowerCase();
}
}
// Numeric keys sort numerically; everything else lexicographically.
const NUMERIC_COLS = new Set(['sessions_7d','users_7d','pvs_7d','sessions_30d','users_30d','pvs_30d']);
function sortByState(data) {
if (!sortState.col) return data;
const key = sortState.col;
const dir = sortState.dir;
return [...data].sort((a, b) => {
const va = colValue(a, key), vb = colValue(b, key);
if (NUMERIC_COLS.has(key)) return (va - vb) * dir;
return va.localeCompare(vb) * dir;
});
}
function tableSort(key) {
if (sortState.col === key) {
sortState.dir = -sortState.dir; // toggle direction
} else {
sortState.col = key;
sortState.dir = -1; // default: descending for numeric, A→Z for text
if (!NUMERIC_COLS.has(key)) sortState.dir = 1;
}
const win = qs('window').value;
const filtered = filterData(sortByState(ALL));
renderTable(filtered, win);
}
// --- Boot ---
async function load() {
try {
const [dataRes, statusRes] = await Promise.all([
fetch('/api/data'),
fetch('/api/status'),
]);
const data = await dataRes.json();
const status = await statusRes.json();
if (data.error) {
qs('loading').textContent = 'Error: ' + data.error;
return;
}
ALL = data.properties || [];
qs('loading').style.display = 'none';
const age = status.fetched_at
? Math.round((Date.now() - new Date(status.fetched_at)) / 60000) + ' min ago'
: 'unknown';
qs('meta').innerHTML =
\`<span>\${status.property_count}</span> properties | \` +
\`cache refreshed <span>\${age}</span> | \` +
\`\${status.error_count} errors | $0 cost\`;
applyPrefs();
render();
} catch (e) {
qs('loading').textContent = 'Failed to load: ' + e.message;
}
}
load();
</script>
</body>
</html>`;
}