'use strict';
// ── State ──────────────────────────────────────────────────────────────────
let currentUser = null;
let editingUserId = null;
let users = [];
let companyFilter = '';
let roleFilter = new Set(); // empty = all roles
let statusFilter = null; // null | 'active' | 'inactive'
let groupBy = 'company'; // 'company' | 'role' | 'status' | null
let syncroCustomers = []; // [{ id, name }] loaded once from Syncro
// ── Init ───────────────────────────────────────────────────────────────────
(async function init() {
try {
const r = await fetch('/auth/me');
if (!r.ok) { location.replace('/login.html?next=' + encodeURIComponent(location.pathname)); return; }
const { user } = await r.json();
currentUser = user;
document.getElementById('user-name').textContent = user.name;
const roleBadge = document.getElementById('user-role');
roleBadge.textContent = user.role;
roleBadge.dataset.role = user.role;
// Show server section only for superduperadmin
if (user.role === 'superduperadmin') {
document.getElementById('section-server').style.display = '';
}
// Hide elevated role options for non-superduperadmin
if (user.role !== 'superduperadmin') {
document.querySelector('#f-role option[value="superduperadmin"]').remove();
document.querySelector('#f-role option[value="admin"]').remove();
}
// Load users and Syncro customers in parallel
await Promise.all([loadUsers(), loadSyncroCustomers()]);
bindEvents();
loadApiUsage();
setInterval(loadApiUsage, 10_000);
} catch (e) {
console.error(e);
}
})();
// ── API helpers ────────────────────────────────────────────────────────────
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(path, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
// ── Syncro customers ───────────────────────────────────────────────────────
async function loadSyncroCustomers() {
const hint = document.getElementById('f-customer-hint');
const listbox = document.getElementById('f-syncro-customer');
try {
let page = 1, all = [];
while (true) {
const r = await fetch(`/syncro-api/customers?per_page=100&page=${page}`);
const data = await r.json();
const batch = data.customers ?? [];
const totalPages = data.meta?.total_pages ?? 1;
all = all.concat(batch);
if (page >= totalPages) break;
page++;
}
syncroCustomers = all
.map(c => ({ id: c.id, name: c.business_name ?? c.name ?? `Customer ${c.id}` }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
populateCustomerListbox('');
hint.textContent = 'Links this user to a Syncro customer for access control.';
} catch (e) {
hint.textContent = 'Could not load Syncro customers.';
console.error('Syncro customer load failed:', e);
}
}
function populateCustomerListbox(filter) {
const listbox = document.getElementById('f-syncro-customer');
const current = listbox.value;
const q = filter.toLowerCase();
// Keep the "None" option then add matching customers
const matches = q
? syncroCustomers.filter(c => c.name.toLowerCase().includes(q))
: syncroCustomers;
listbox.innerHTML = '';
for (const c of matches) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
listbox.appendChild(opt);
}
// Restore selection if still present
if (current && listbox.querySelector(`option[value="${current}"]`)) {
listbox.value = current;
}
}
// ── Load & render users ────────────────────────────────────────────────────
async function loadUsers() {
try {
const { users: list } = await api('GET', '/admin/users');
users = list;
rebuildCompanyFilter();
renderUsers();
} catch (e) {
document.getElementById('user-tbody').innerHTML =
`
| Failed to load users: ${e.message} |
`;
}
}
function rebuildCompanyFilter() {
// Collect unique non-empty companies, sorted
const companies = [...new Set(
users.map(u => u.company).filter(Boolean)
)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
// Filter dropdown
const select = document.getElementById('company-filter');
const prev = select.value;
select.innerHTML = '';
for (const c of companies) {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
select.appendChild(opt);
}
select.value = companies.includes(prev) ? prev : '';
companyFilter = select.value;
}
function renderUsers() {
const tbody = document.getElementById('user-tbody');
const visible = users.filter(u => {
if (companyFilter && u.company !== companyFilter) return false;
if (roleFilter.size > 0 && !roleFilter.has(u.role)) return false;
if (statusFilter === 'active' && !u.active) return false;
if (statusFilter === 'inactive' && u.active) return false;
return true;
});
const hasFilter = companyFilter || roleFilter.size > 0 || statusFilter !== null;
if (!visible.length) {
tbody.innerHTML = `| ${hasFilter ? 'No users match the current filters.' : 'No users found.'} |
`;
return;
}
const rows = [];
if (groupBy) {
const keyOf = u => {
if (groupBy === 'company') return u.company || '';
if (groupBy === 'role') return u.role;
if (groupBy === 'status') return u.active ? 'active' : 'inactive';
return '';
};
const labelOf = key => {
if (groupBy === 'company') return key || 'No Company';
if (groupBy === 'role') return { client: 'Client', tech: 'Tech', admin: 'Admin', superduperadmin: 'Super Admin' }[key] ?? key;
if (groupBy === 'status') return key === 'active' ? 'Active' : 'Inactive';
return key;
};
const sortKeys = keys => {
if (groupBy === 'company') return keys.sort((a, b) => !a && b ? 1 : a && !b ? -1 : a.localeCompare(b, undefined, { sensitivity: 'base' }));
if (groupBy === 'role') return keys.sort((a, b) => (['superduperadmin','admin','tech','client'].indexOf(a) - ['superduperadmin','admin','tech','client'].indexOf(b)));
if (groupBy === 'status') return keys.sort((a, b) => a === 'active' ? -1 : 1);
return keys;
};
const grouped = new Map();
for (const u of visible) {
const key = keyOf(u);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(u);
}
for (const key of sortKeys([...grouped.keys()])) {
rows.push(`| ${esc(labelOf(key))} |
`);
for (const u of grouped.get(key)) rows.push(userRow(u));
}
} else {
for (const u of visible) rows.push(userRow(u));
}
tbody.innerHTML = rows.join('');
}
function userRow(u) {
const activeClass = u.active ? 'active' : 'inactive';
const activeLabel = u.active ? 'Active' : 'Inactive';
const created = new Date(u.created_at + 'Z').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
const isSelf = u.id === currentUser.id;
const toggleBtn = u.active
? ``
: ``;
return `
| ${esc(u.name)}${isSelf ? ' (you)' : ''} |
${esc(u.username)} |
${u.company ? esc(u.company) : '—'} |
${esc(u.role)} |
${activeLabel} |
${created} |
${toggleBtn}
|
`;
}
// ── App menu ───────────────────────────────────────────────────────────────
function initAdminMenu() {
const toggle = document.getElementById('btn-menu-toggle');
const menu = document.getElementById('app-menu');
if (!toggle || !menu) return;
toggle.addEventListener('click', e => {
e.stopPropagation();
const opening = menu.hidden;
menu.hidden = !opening;
toggle.setAttribute('aria-expanded', String(opening));
});
document.addEventListener('click', e => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== toggle && !toggle.contains(e.target)) {
menu.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !menu.hidden) {
menu.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
});
}
// ── User filter panel ──────────────────────────────────────────────────────
function bindFilterPanel() {
const panel = document.getElementById('user-filter-panel');
const filterBtn = document.getElementById('btn-user-filter');
filterBtn?.addEventListener('click', () => {
panel.hidden = !panel.hidden;
filterBtn.classList.toggle('active', !panel.hidden);
});
panel?.addEventListener('click', e => {
const chip = e.target.closest('.af-chip');
if (!chip) return;
const group = chip.closest('[data-filter-group]')?.dataset.filterGroup;
if (group === 'role') {
const val = chip.dataset.value;
if (roleFilter.has(val)) { roleFilter.delete(val); chip.classList.remove('active'); }
else { roleFilter.add(val); chip.classList.add('active'); }
} else if (group === 'status') {
statusFilter = chip.dataset.value === '' ? null : chip.dataset.value;
chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === (chip.dataset.value))
);
} else if (group === 'groupby') {
groupBy = chip.dataset.value === '' ? null : chip.dataset.value;
chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === chip.dataset.value)
);
}
updateFilterBadge();
renderUsers();
});
document.getElementById('af-clear')?.addEventListener('click', () => {
roleFilter.clear();
statusFilter = null;
companyFilter = '';
document.getElementById('company-filter').value = '';
panel?.querySelectorAll('[data-filter-group="role"] .af-chip').forEach(c => c.classList.remove('active'));
panel?.querySelectorAll('[data-filter-group="status"] .af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === '')
);
groupBy = 'company';
panel?.querySelectorAll('[data-filter-group="groupby"] .af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === 'company')
);
updateFilterBadge();
renderUsers();
});
document.getElementById('company-filter')?.addEventListener('change', e => {
companyFilter = e.target.value;
updateFilterBadge();
renderUsers();
});
}
function updateFilterBadge() {
const count = (companyFilter ? 1 : 0) + roleFilter.size + (statusFilter !== null ? 1 : 0);
const badge = document.getElementById('user-filter-badge');
if (badge) { badge.textContent = count; badge.hidden = count === 0; }
}
// ── Bind events ────────────────────────────────────────────────────────────
function bindEvents() {
initAdminMenu();
bindFilterPanel();
document.getElementById('btn-new-user').addEventListener('click', openCreate);
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/auth/logout', { method: 'POST' });
location.replace('/login.html');
});
// Table actions — delegated so they work after innerHTML re-renders
document.getElementById('user-tbody').addEventListener('click', e => {
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const id = Number(btn.dataset.id);
if (btn.dataset.action === 'edit') {
openEdit(id);
} else if (btn.dataset.action === 'toggle') {
confirmToggleActive(id, btn.dataset.activate === 'true');
}
});
// Customer picker search
document.getElementById('f-customer-search').addEventListener('input', e => {
populateCustomerListbox(e.target.value);
});
// Modal close / cancel
document.getElementById('modal-close').addEventListener('click', closeUserModal);
document.getElementById('modal-cancel').addEventListener('click', closeUserModal);
document.getElementById('user-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeUserModal();
});
// Confirm modal
document.getElementById('confirm-close').addEventListener('click', closeConfirm);
document.getElementById('confirm-cancel').addEventListener('click', closeConfirm);
document.getElementById('confirm-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeConfirm();
});
document.getElementById('user-form').addEventListener('submit', handleUserFormSubmit);
const btnRestart = document.getElementById('btn-restart');
if (btnRestart) {
btnRestart.addEventListener('click', () => {
showConfirm(
'Restart Server',
'The server process will exit and PM2 will restart it automatically. This takes about 1–2 seconds. Proceed?',
'Restart',
'btn-accent',
doRestart
);
});
}
document.getElementById('btn-refresh-usage')?.addEventListener('click', loadApiUsage);
// History panel toggle
document.getElementById('btn-history-toggle')?.addEventListener('click', () => {
_historyPanelVisible = !_historyPanelVisible;
const panel = document.getElementById('usage-history-panel');
if (panel) panel.hidden = !_historyPanelVisible;
document.getElementById('btn-history-toggle')?.classList.toggle('active', _historyPanelVisible);
_setHistoryWindowDescription();
loadApiUsage(); // immediate refresh so bar + chart update
});
// Timeframe chips
document.querySelectorAll('.usage-tf-chip').forEach(chip => {
chip.addEventListener('click', () => {
_historyWindowMs = parseInt(chip.dataset.window, 10);
document.querySelectorAll('.usage-tf-chip').forEach(c =>
c.classList.toggle('active', c === chip)
);
_setHistoryWindowDescription();
loadApiUsage();
});
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeUserModal(); closeConfirm(); }
});
}
// ── User modal ─────────────────────────────────────────────────────────────
function openCreate() {
editingUserId = null;
document.getElementById('modal-title').textContent = 'New User';
document.getElementById('modal-submit').textContent = 'Create User';
document.getElementById('f-password-label').textContent = 'Password';
document.getElementById('f-password-hint').textContent = 'Minimum 12 characters.';
document.getElementById('f-password').required = true;
document.getElementById('user-form').reset();
document.getElementById('modal-error').style.display = 'none';
document.getElementById('f-username').disabled = false;
document.getElementById('f-role').disabled = false;
document.getElementById('f-customer-search').value = '';
populateCustomerListbox('');
document.getElementById('f-syncro-customer').value = '';
document.getElementById('user-modal').style.display = 'flex';
document.getElementById('f-name').focus();
}
function openEdit(id) {
const u = users.find(x => x.id === id);
if (!u) return;
editingUserId = id;
document.getElementById('modal-title').textContent = 'Edit User';
document.getElementById('modal-submit').textContent = 'Save Changes';
document.getElementById('f-password-label').textContent = 'New Password';
document.getElementById('f-password-hint').textContent = 'Leave blank to keep current password. Minimum 12 characters if changing.';
document.getElementById('f-password').required = false;
document.getElementById('modal-error').style.display = 'none';
const isSelf = u.id === currentUser.id;
document.getElementById('f-name').value = u.name;
document.getElementById('f-username').value = u.username;
document.getElementById('f-username').disabled = true;
document.getElementById('f-role').disabled = isSelf;
document.getElementById('f-password').value = '';
// Pre-select Syncro customer
document.getElementById('f-customer-search').value = '';
populateCustomerListbox('');
document.getElementById('f-syncro-customer').value = u.syncro_customer_id ?? '';
const roleSelect = document.getElementById('f-role');
if (!roleSelect.querySelector(`option[value="${u.role}"]`)) {
const opt = document.createElement('option');
opt.value = u.role;
opt.textContent = u.role;
opt.dataset.temp = '1';
roleSelect.insertBefore(opt, roleSelect.firstChild);
}
roleSelect.value = u.role;
document.getElementById('user-modal').style.display = 'flex';
document.getElementById('f-name').focus();
}
function closeUserModal() {
document.getElementById('user-modal').style.display = 'none';
document.getElementById('f-role').disabled = false;
document.querySelectorAll('#f-role option[data-temp]').forEach(o => o.remove());
}
async function handleUserFormSubmit(e) {
e.preventDefault();
const errEl = document.getElementById('modal-error');
const submit = document.getElementById('modal-submit');
errEl.style.display = 'none';
submit.disabled = true;
const name = document.getElementById('f-name').value.trim();
const username = document.getElementById('f-username').value.trim();
const role = document.getElementById('f-role').value;
const password = document.getElementById('f-password').value;
const customerSelect = document.getElementById('f-syncro-customer');
const syncroCustomerId = customerSelect.value ? Number(customerSelect.value) : null;
const company = syncroCustomerId
? (customerSelect.options[customerSelect.selectedIndex]?.text ?? '')
: '';
try {
if (editingUserId === null) {
await api('POST', '/admin/users', { name, username, company, syncro_customer_id: syncroCustomerId, role, password });
toast('User created successfully.', 'success');
} else {
const body = { name, company, syncro_customer_id: syncroCustomerId, role };
if (password) body.password = password;
await api('PATCH', `/admin/users/${editingUserId}`, body);
toast('User updated successfully.', 'success');
}
closeUserModal();
await loadUsers();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = '';
} finally {
submit.disabled = false;
}
}
// ── Toggle active ──────────────────────────────────────────────────────────
function confirmToggleActive(id, activate) {
const u = users.find(x => x.id === id);
if (!u) return;
const action = activate ? 'activate' : 'deactivate';
showConfirm(
`${activate ? 'Activate' : 'Deactivate'} User`,
`Are you sure you want to ${action} ${esc(u.name)} (${esc(u.username)})?${!activate ? ' They will be unable to log in.' : ''}`,
activate ? 'Activate' : 'Deactivate',
activate ? 'btn-primary' : 'btn-danger',
async () => {
try {
await api('PATCH', `/admin/users/${id}`, { active: activate });
toast(`User ${activate ? 'activated' : 'deactivated'}.`, activate ? 'success' : 'info');
await loadUsers();
} catch (err) {
toast(err.message, 'error');
}
}
);
}
// ── Restart ────────────────────────────────────────────────────────────────
async function doRestart() {
const btn = document.getElementById('btn-restart');
btn.disabled = true;
btn.innerHTML = `
Restarting…`;
try {
await api('POST', '/admin/restart');
} catch {
// Server closed before responding — expected
}
toast('Server is restarting… page will refresh in a few seconds.', 'info');
setTimeout(() => location.reload(), 4000);
}
// ── Confirm modal ──────────────────────────────────────────────────────────
function showConfirm(title, message, okLabel, okClass, callback) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-message').innerHTML = message;
const okBtn = document.getElementById('confirm-ok');
okBtn.textContent = okLabel;
okBtn.className = `btn btn-sm ${okClass}`;
okBtn.onclick = async () => { closeConfirm(); await callback(); };
document.getElementById('confirm-modal').style.display = 'flex';
}
function closeConfirm() {
document.getElementById('confirm-modal').style.display = 'none';
}
// ── Toast ──────────────────────────────────────────────────────────────────
function toast(msg, type = 'info') {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── API usage ──────────────────────────────────────────────────────────────
let _historyWindowMs = 3_600_000; // 1h — active only when panel is visible
let _historyPanelVisible = false;
async function loadApiUsage() {
const bar = document.getElementById('api-usage-bar');
const label = document.getElementById('api-usage-label');
const sub = document.getElementById('api-usage-sub');
if (!bar) return;
try {
// Always fetch the current 60s count + 7-day limit-hit tally
const { requests, limit, limitHits7d = 0 } = await api('GET', '/admin/syncro-usage');
// Update limit-hit badge on the History button
const badge = document.getElementById('history-badge');
if (badge) {
badge.textContent = limitHits7d > 999 ? '999+' : String(limitHits7d);
badge.hidden = limitHits7d === 0;
}
let displayReq, displayLimit, pct;
if (_historyPanelVisible) {
// Fetch per-minute history for the selected window
const { buckets } = await api('GET', `/admin/syncro-history?window=${_historyWindowMs}`);
const windowTotal = (buckets ?? []).reduce((s, b) => s + b.count, 0);
displayReq = windowTotal;
displayLimit = Math.round(_historyWindowMs / 60_000) * limit; // minutes × 180
pct = Math.min(100, (windowTotal / displayLimit) * 100);
drawUsageChart(buckets ?? []);
} else {
displayReq = requests;
displayLimit = limit;
pct = Math.min(100, (requests / limit) * 100);
}
bar.style.width = pct + '%';
bar.className = 'api-usage-bar' + (pct >= 80 ? ' crit' : pct >= 50 ? ' warn' : '');
label.textContent = displayReq === 0
? `— / ${displayLimit.toLocaleString()}`
: `${displayReq.toLocaleString()} / ${displayLimit.toLocaleString()}`;
const timeStr = new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
sub.textContent = `Updated ${timeStr} · auto-refreshes every 10s`;
} catch (e) {
if (sub) sub.textContent = 'Failed to load usage data.';
}
}
function _setHistoryWindowDescription() {
const el = document.getElementById('api-usage-desc');
if (!el) return;
if (!_historyPanelVisible) {
el.textContent = 'Rolling 60-second request window';
return;
}
const labels = { 3600000: '1-hour', 86400000: '24-hour', 604800000: '7-day' };
el.textContent = `Rolling ${labels[_historyWindowMs] ?? ''} request window`;
}
function drawUsageChart(buckets) {
const canvas = document.getElementById('usage-chart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Size the backing store to physical pixels for crispness
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.offsetWidth;
const displayH = canvas.offsetHeight;
if (displayW === 0 || displayH === 0) return;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
ctx.scale(dpr, dpr);
const W = displayW, H = displayH;
const now = Date.now();
// Aggregation block size
let blockMs;
if (_historyWindowMs <= 3_600_000) blockMs = 60_000; // 1h → 1-min blocks (60 pts)
else if (_historyWindowMs <= 86_400_000) blockMs = 10 * 60_000; // 24h → 10-min blocks (144 pts)
else blockMs = 60 * 60_000; // 7d → 1-hour blocks (168 pts)
// Build lookup map from raw per-minute buckets
const bucketMap = new Map(buckets.map(b => [b.ts, b.count]));
// Generate aggregated points (avg req/min in each block)
const windowStart = now - _historyWindowMs;
const firstBlockStart = Math.floor(windowStart / blockMs) * blockMs;
const lastBlockStart = Math.floor(now / blockMs) * blockMs;
const points = [];
for (let t = firstBlockStart; t <= lastBlockStart; t += blockMs) {
let total = 0, mins = 0;
for (let m = t; m < t + blockMs; m += 60_000) { total += bucketMap.get(m) ?? 0; mins++; }
points.push({ t, value: mins > 0 ? total / mins : 0 });
}
if (points.length === 0) return;
// Layout padding
const padL = 28, padR = 8, padT = 10, padB = 20;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const maxY = 180;
const toX = i => padL + (points.length > 1 ? i / (points.length - 1) : 0.5) * chartW;
const toY = v => padT + chartH * (1 - Math.min(v, maxY) / maxY);
ctx.clearRect(0, 0, W, H);
// Grid lines + Y-axis labels
[0, 60, 120, 180].forEach(v => {
const y = toY(v);
ctx.strokeStyle = v === 0 ? '#d1d5db' : '#e5e7eb';
ctx.lineWidth = 1;
ctx.setLineDash(v === 0 ? [] : [3, 3]);
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + chartW, y); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#9ca3af';
ctx.font = '9px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(String(v), padL - 4, y);
});
// Filled area gradient
const gradient = ctx.createLinearGradient(0, padT, 0, padT + chartH);
gradient.addColorStop(0, 'rgba(43,84,153,.22)');
gradient.addColorStop(1, 'rgba(43,84,153,.02)');
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(toX(i), toY(p.value));
else ctx.lineTo(toX(i), toY(p.value));
});
ctx.lineTo(toX(points.length - 1), padT + chartH);
ctx.lineTo(toX(0), padT + chartH);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Line
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(toX(i), toY(p.value));
else ctx.lineTo(toX(i), toY(p.value));
});
ctx.strokeStyle = '#2b5499';
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.stroke();
// X-axis labels (5 evenly spaced)
const labelCount = Math.min(5, points.length);
ctx.fillStyle = '#9ca3af';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (let li = 0; li < labelCount; li++) {
const idx = Math.round(li * (points.length - 1) / (labelCount - 1));
const d = new Date(points[idx].t);
const label = blockMs >= 60 * 60_000
? d.toLocaleDateString([], { month: 'short', day: 'numeric' })
: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
ctx.fillText(label, toX(idx), H - 3);
}
}
// ── Utility ───────────────────────────────────────────────────────────────
function esc(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}