Sortable per-county downloaded files table that refreshes when jobs finish, and web push notifications for job completion

This commit is contained in:
zyphlar
2026-10-16 20:18:39 -07:00
parent 96d5b9bac8
commit 4aca4c96fd
7 changed files with 539 additions and 39 deletions
+328 -30
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OSM Import Tools</title>
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#1a1a2e">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -255,22 +257,60 @@
margin-bottom: 5px;
}
.file-links {
display: flex;
flex-wrap: wrap;
gap: 4px;
.files-strip { max-height: 38vh; overflow-y: auto; }
.files-strip[hidden] { display: none; }
.files-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.file-link {
.files-table th {
position: sticky;
top: 0;
background: #f8f9fa;
padding: 0;
text-align: left;
font-size: 10px;
padding: 2px 7px;
border-radius: 3px;
color: white;
text-decoration: none;
font-weight: 500;
font-weight: 700;
color: #888;
border-bottom: 1px solid #d8dbe0;
}
.file-link { background: var(--link-color, #888); }
.files-table th button {
width: 100%;
padding: 3px 4px;
border: none;
background: none;
font: inherit;
color: inherit;
text-align: inherit;
cursor: pointer;
white-space: nowrap;
}
.files-table th button:hover { color: #333; }
.files-table th[aria-sort] button { color: #333; }
.files-table th[aria-sort="ascending"] button::after { content: ' ▲'; }
.files-table th[aria-sort="descending"] button::after { content: ' ▼'; }
.files-table th.col-date, .files-table td.col-date { text-align: right; width: 1%; white-space: nowrap; }
.files-table td { padding: 3px 4px; border-bottom: 1px solid #eceef1; }
.files-table td.col-name { word-break: break-all; }
.files-table td.col-date { color: #777; font-variant-numeric: tabular-nums; }
.files-table tbody.county-files tr.county-row td {
padding: 6px 4px 3px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--county-color, #555);
border-bottom: 2px solid var(--county-color, #ccc);
}
.files-table a { color: #1a5fb4; text-decoration: none; }
.files-table a:hover { text-decoration: underline; }
/* ── Right panel (console) ── */
.right {
@@ -312,6 +352,48 @@
.btn-cancel.active { display: inline-block; }
.console-actions { display: flex; align-items: center; gap: 6px; }
.btn-notify {
padding: 3px 10px;
background: #2a2a2a;
color: #aaa;
border: 1px solid #3a3a3a;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
}
.btn-notify:hover { color: #ddd; }
.btn-notify.on { background: #1e3a24; color: #6fcf7f; border-color: #2e5a36; }
.btn-notify[hidden] { display: none; }
/* One-time offer to turn on job-finished notifications */
#notifyPrompt {
display: none;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
padding: 7px 14px;
font-size: 12px;
background: #26233a;
color: #c9c2f0;
flex-shrink: 0;
}
#notifyPrompt.show { display: flex; }
#notifyPrompt span { flex: 1; min-width: 180px; }
#notifyPrompt button {
padding: 3px 10px;
border-radius: 4px;
border: 1px solid #4a4570;
background: none;
color: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
#notifyPrompt button.primary { background: #6c5ce7; border-color: #6c5ce7; color: white; }
#status {
padding: 7px 14px;
font-size: 12px;
@@ -492,7 +574,11 @@
.upload-input { width: 100%; max-width: 200px; }
.files-strip { max-height: 160px; overflow-y: auto; }
.files-strip { max-height: none; overflow: visible; }
.files-table { font-size: 13px; }
.files-table td { padding: 6px 4px; }
.right {
min-height: 60vh;
@@ -659,24 +745,18 @@
</div><!-- /workflows -->
<!-- Data files strip -->
{% if data_files %}
<div class="files-strip">
<!-- Data files strip (rows rendered by renderFiles()) -->
<div class="files-strip" id="filesStrip" hidden>
<div class="files-strip-title">Downloaded files</div>
<div class="file-links">
{% for county in counties %}
{% if county.id in data_files %}
{% for file in data_files[county.id] %}
<a class="file-link"
style="--link-color: {{ county.color }}"
href="/data/{{ county.id }}/{{ file.name }}"
title="{{ county.id }}/{{ file.name }} — {{ file.mtime }}">{{ file.name }}</a>
{% endfor %}
{% endif %}
{% endfor %}
</div>
<table class="files-table" id="filesTable">
<thead>
<tr>
<th class="col-name"><button type="button" data-sort="name">File</button></th>
<th class="col-date" aria-sort="descending"><button type="button" data-sort="ts">Modified</button></th>
</tr>
</thead>
</table>
</div>
{% endif %}
</div><!-- /left -->
@@ -684,9 +764,17 @@
<div class="right">
<div class="console-header">
<span>Output</span>
<button id="cancelButton" class="btn-cancel" onclick="cancelJob()">Cancel</button>
<div class="console-actions">
<button id="notifyButton" class="btn-notify" type="button" hidden onclick="toggleNotifications()">🔔 Notify me</button>
<button id="cancelButton" class="btn-cancel" onclick="cancelJob()">Cancel</button>
</div>
</div>
<div id="jobBanner" role="status" aria-live="polite"></div>
<div id="notifyPrompt">
<span>Get a notification when jobs finish, even with this page closed?</span>
<button type="button" class="primary" onclick="enableNotifications()">Turn on</button>
<button type="button" onclick="dismissNotifyPrompt()">Not now</button>
</div>
<div id="status"></div>
<div id="sys-stats">
<div class="stat-row">
@@ -721,6 +809,7 @@
let lastSeenLatestId = null;
let jobsTimer = null;
let uploading = false;
let finishedJobsKey = null; // finished job ids last seen, to spot completions
function requireLogin() {
showStatus('Not authenticated – please log in.', 'error');
@@ -786,6 +875,79 @@
// Init
if (activeCounty) selectCounty(activeCounty);
/* ── Downloaded files table: grouped by county, sorted within each ── */
let dataFiles = {{ data_files_json | safe }};
let fileSortKey = 'ts', fileSortDir = -1;
function fmtFileTime(ts) {
const d = new Date(ts * 1000), pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function renderFiles() {
const table = document.getElementById('filesTable');
table.querySelectorAll('tbody').forEach(tb => tb.remove());
table.querySelectorAll('thead th').forEach(th => {
if (th.querySelector('button').dataset.sort === fileSortKey) {
th.setAttribute('aria-sort', fileSortDir < 0 ? 'descending' : 'ascending');
} else {
th.removeAttribute('aria-sort');
}
});
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
let any = false;
COUNTY_CONFIG.forEach(county => {
const files = (dataFiles[county.id] || []).slice();
if (!files.length) return;
any = true;
files.sort((a, b) => fileSortKey === 'ts'
? (a.ts - b.ts) * fileSortDir || byName(a, b)
: byName(a, b) * fileSortDir);
const tbody = document.createElement('tbody');
tbody.className = 'county-files';
tbody.style.setProperty('--county-color', county.color);
const head = tbody.insertRow();
head.className = 'county-row';
const hc = head.insertCell();
hc.colSpan = 2;
hc.textContent = county.name;
files.forEach(f => {
const row = tbody.insertRow();
row.className = 'file-row';
const link = document.createElement('a');
link.href = `/data/${encodeURIComponent(county.id)}/${encodeURIComponent(f.name)}`;
link.textContent = f.name;
const nameCell = row.insertCell();
nameCell.className = 'col-name';
nameCell.appendChild(link);
const dateCell = row.insertCell();
dateCell.className = 'col-date';
dateCell.textContent = fmtFileTime(f.ts);
dateCell.title = new Date(f.ts * 1000).toLocaleString();
});
table.appendChild(tbody);
});
document.getElementById('filesStrip').hidden = !any;
}
function refreshFiles() {
return fetch('/api/data-files')
.then(r => r.json())
.then(data => { dataFiles = data; renderFiles(); })
.catch(err => console.error('File list refresh error:', err));
}
document.querySelectorAll('#filesTable thead button').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.sort;
// Same column toggles direction; a new column starts with its natural order
if (key === fileSortKey) fileSortDir = -fileSortDir;
else { fileSortKey = key; fileSortDir = key === 'ts' ? -1 : 1; }
renderFiles();
});
});
renderFiles();
/* ── Script runner ── */
function runScript(scriptName) {
if (!IS_AUTHENTICATED) return requireLogin();
@@ -805,7 +967,12 @@
fetch('/api/run-script', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script: scriptName, county: scriptName === 'ls' ? '' : activeCounty, forceDownload })
body: JSON.stringify({
script: scriptName,
county: scriptName === 'ls' ? '' : activeCounty,
label: jobLabel({ script: scriptName }),
forceDownload
})
})
.then(r => r.json())
.then(data => {
@@ -817,6 +984,7 @@
followLatest = true;
lastSeenLatestId = data.job_id;
viewJob(data.job_id, true);
maybeOfferNotifications();
})
.catch(err => {
showStatus(`Error: ${err.message}`, 'error');
@@ -951,6 +1119,11 @@
.then(r => r.json())
.then(data => {
if (data.error) return;
// Any job finishing (here or on another device) may have written files
const finished = Object.values(data.steps).filter(j => j.status !== 'running')
.map(j => j.id).sort().join();
if (finishedJobsKey !== null && finished !== finishedJobsKey) refreshFiles();
finishedJobsKey = finished;
jobsData = data;
renderStepStatus();
if (data.running.length) disableButtons(); else if (!uploading) enableButtons();
@@ -979,7 +1152,131 @@
jobsTimer = setTimeout(refreshJobs, busy ? 1000 : 5000);
}
/* ── Web push: browser notifications when jobs finish ── */
const PUSH_DISMISS_KEY = 'notifyPromptDismissed';
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const pushSupported = window.isSecureContext && 'serviceWorker' in navigator &&
'PushManager' in window && 'Notification' in window;
let pushSubscription = null;
function pushUnsupportedReason() {
if (!window.isSecureContext) return 'Notifications need this site to be opened over HTTPS (or on localhost).';
if (IS_IOS) return 'On iPhone/iPad, add this site to your Home Screen (Share → Add to Home Screen) and open it from there to get notifications.';
return 'This browser does not support web push notifications.';
}
function renderNotifyButton() {
const btn = document.getElementById('notifyButton');
btn.hidden = false;
const on = !!pushSubscription;
btn.classList.toggle('on', on);
btn.textContent = on ? '🔔 Notifications on' : '🔔 Notify me';
btn.title = !pushSupported ? pushUnsupportedReason()
: on ? 'You get a notification when a job finishes. Click to turn off.'
: 'Get a browser notification when a job finishes';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
function urlBase64ToUint8Array(b64) {
const raw = atob((b64 + '='.repeat((4 - b64.length % 4) % 4)).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, c => c.charCodeAt(0));
}
function sendSubscription(sub) {
return fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: sub.toJSON() })
}).then(r => r.json()).then(data => { if (data.error) throw new Error(data.error); });
}
async function enableNotifications() {
hideNotifyPrompt();
if (!pushSupported) { showStatus(pushUnsupportedReason(), 'error'); return; }
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
showStatus('Notifications are blocked for this site. Allow them in your browser\'s site settings to turn them on.', 'error');
return;
}
const keyResp = await fetch('/api/push/key').then(r => r.json());
if (keyResp.error) throw new Error(keyResp.error);
const reg = await navigator.serviceWorker.ready;
const key = urlBase64ToUint8Array(keyResp.publicKey);
let sub = await reg.pushManager.getSubscription();
// A subscription made with a different server key can't receive our pushes
if (sub && sub.options.applicationServerKey &&
new Uint8Array(sub.options.applicationServerKey).join() !== key.join()) {
await sub.unsubscribe();
sub = null;
}
sub = sub || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
await sendSubscription(sub);
pushSubscription = sub;
showStatus('Notifications on: this browser will be notified when jobs finish.', 'success');
} catch (err) {
showStatus(`Could not turn on notifications: ${err.message}`, 'error');
}
renderNotifyButton();
}
async function disableNotifications() {
const sub = pushSubscription;
pushSubscription = null;
renderNotifyButton();
if (!sub) return;
try {
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint })
});
await sub.unsubscribe();
showStatus('Notifications off for this browser.', 'info');
} catch (err) {
showStatus(`Could not turn off notifications: ${err.message}`, 'error');
}
}
function toggleNotifications() {
if (pushSubscription) disableNotifications(); else enableNotifications();
}
// After starting a job is when a "tell me when it's done" offer makes sense
function maybeOfferNotifications() {
if (!pushSupported || pushSubscription || Notification.permission === 'denied') return;
try { if (localStorage.getItem(PUSH_DISMISS_KEY)) return; } catch (e) { /* storage blocked */ }
document.getElementById('notifyPrompt').classList.add('show');
}
function hideNotifyPrompt() {
document.getElementById('notifyPrompt').classList.remove('show');
}
function dismissNotifyPrompt() {
hideNotifyPrompt();
try { localStorage.setItem(PUSH_DISMISS_KEY, '1'); } catch (e) { /* storage blocked */ }
}
async function initNotifications() {
if (!pushSupported) { renderNotifyButton(); return; }
try {
const reg = await navigator.serviceWorker.register('/sw.js');
const sub = await reg.pushManager.getSubscription();
if (sub && Notification.permission === 'granted') {
// Re-register so the server has it even if its list was reset
await sendSubscription(sub);
pushSubscription = sub;
}
} catch (err) {
console.error('Notification setup failed:', err);
}
renderNotifyButton();
}
if (IS_AUTHENTICATED) {
initNotifications();
refreshJobs();
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshJobs(); });
} else {
@@ -1009,6 +1306,7 @@
showStatus(`Error: ${data.error}`, 'error');
} else {
showStatus(`Uploaded to ${data.path}`, 'success');
refreshFiles();
input.value = '';
}
})