From 8c97c314e08bd7baf9384e11553d1fb19673c14d Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 2 Sep 2026 16:45:48 +1000 Subject: [PATCH] Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA --- .env.example | 1 - README.md | 49 +++++---- public/js/admin.js | 94 +++++++++++------ public/js/kiosk.js | 1 + src/branding.js | 38 ++++++- src/db.js | 2 + src/routes/admin.js | 20 ++-- src/routes/kiosk.js | 4 +- src/server.js | 2 +- src/sheets.js | 246 +++++++++++++------------------------------- src/sites.js | 1 + 11 files changed, 211 insertions(+), 247 deletions(-) diff --git a/.env.example b/.env.example index 1f50f58..b4bee68 100644 --- a/.env.example +++ b/.env.example @@ -67,7 +67,6 @@ SHEETS_ENABLED=false # The long id from the sheet URL: docs.google.com/spreadsheets/d//edit SHEETS_SPREADSHEET_ID= # Append-only history of every sign in and sign out. -SHEETS_LOG_TAB=Visitor log # Rewritten on every change: only the people currently on site. Open this one # during an evacuation. Both tabs are created automatically if missing. SHEETS_ONSITE_TAB=On site now diff --git a/README.md b/README.md index 84531ba..a926be9 100644 --- a/README.md +++ b/README.md @@ -115,19 +115,23 @@ whatever space the clock leaves over. The setting applies to the site name too, uploaded. SVG is deliberately not accepted: it can carry script, and this file is served to every kiosk. -**Colours.** Three are settable: +**Colours.** Four are settable: | Setting | Where it shows | |---|---| | Bar and buttons | The top bar, the Sign in door, primary buttons, the confirmation mark | | Sign out | The Sign out door and the signed-out confirmation | | Page background | Behind everything, with card and rule colours derived from it | +| Body text | Headings, answers, and the source for the softer label colour | -Everything else is worked out from those three. In particular the **text colour on a coloured -background is chosen by contrast**, not fixed — pick a pale yellow for the bar and the text on -it turns dark automatically, instead of staying white and becoming unreadable. Card, border and -body-text colours follow the page colour, so a dark background gives a usable dark theme rather -than white boxes. +Two things are still worked out rather than set. **Text on a coloured background** is chosen by +contrast, so a pale yellow bar gets dark text instead of unreadable white. And the **muted colour** +used for field labels and hints is your body text mixed towards the background only as far as it +can go while still clearing WCAG AA at 4.5:1 — a fixed grey looks fine on the default background +and vanishes on a custom one, which is the usual cause of text that blends in. + +The site editor shows the contrast ratio as you type and warns below 4.5:1. Aim for 7:1 or better +on a kiosk people read standing up. Leave a colour box empty to fall back to the default. Anything that is not a six-digit hex value is ignored rather than applied. @@ -310,28 +314,29 @@ database and retried every minute, so a dropped internet connection never blocks 6. Restart, then **Admin → System → Test the sheet connection**. Both tabs and their headers are created the first time. -### Two tabs, two jobs +### What the sheet holds -The spreadsheet gets two tabs, both created automatically: +**Only the people currently on site.** One tab, rewritten in full whenever anyone signs in or +out. Nothing is appended, so there is no history to scroll past while you are standing in a car +park counting heads — the top row says `On site now — 3 people — updated 31/08/26, 14:12`, and +everything under it is someone still in the building. -**On site now** — rewritten every time anyone signs in or out, so it only ever lists the people -currently in the building. No filtering, no scrolling to the bottom. The top row shows a head -count and the time it was last updated, so you can tell at a glance whether it is live. This is -the tab to bookmark on the phones that matter and to open at the assembly point. +Rewriting rather than patching is deliberate: a failed update can never leave a stale name on the +evacuation list, because whatever is on the tab is what the database said at the time shown. If a +write fails the tab is marked stale and rewritten on the next pass, once a minute. It also +refreshes every 15 minutes on its own to keep the "on site for" column honest. -**Visitor log** — append only. Every sign in and sign out, forever, with times in and out. -This is the record you go back through weeks later. +The **full visit history stays in the application** — searchable under **Visit log** in the admin +console, and downloadable as CSV. It is not sent to Google, which keeps visitor contact details +and movement history off a cloud service that only exists here for the evacuation case. -Rename them with `SHEETS_LOG_TAB` and `SHEETS_ONSITE_TAB`. Names with spaces are fine. +Every row carries the site name, so one spreadsheet covers every site. -The live tab is rebuilt from the database rather than edited row by row, so it is self-healing: -if a write fails, the next one puts everything right. It also refreshes every 15 minutes on its -own to keep the *On site for* column honest, and rebuilds at startup in case anyone signed out -while the container was down. **Admin → System → Rebuild the live list** forces it. +**Bookmark the sheet on the phones that would actually be used in an evacuation, and check it +after setup.** A sheet nobody can find is not a safety measure. -Anything you type into these tabs by hand will be overwritten. The sheet is a mirror, not the -source of truth — nothing is ever read back from it. Every row carries the site name, so one -spreadsheet covers every site. +*Upgrading from an earlier version:* the old "Visitor log" tab is left alone but no longer +written to. Delete it by hand when you are ready. ## Recurring visitors and PINs diff --git a/public/js/admin.js b/public/js/admin.js index 89a001b..3e5b3ea 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -777,8 +777,11 @@ function openSiteModal(site) { ${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')} ${colourField('Page background', 'page', site.branding.page, '#e7ecf0')} -

Text colours are worked out from these, so a pale brand colour gets dark - text rather than white. Clear a box to go back to the default.

`, + ${colourField('Body text', 'text', site.branding.text, site.branding.theme.ink)} +

+

Text on the bar and on buttons is chosen automatically for contrast, so a + pale brand colour gets dark text rather than white. Labels and hints are a softened version + of the body text, kept readable against the background. Clear a box for the default.

`, async (form) => { const data = Object.fromEntries(form.entries()); try { @@ -800,6 +803,7 @@ function openSiteModal(site) { brand: data.brand || null, signout: data.signout || null, page: data.page || null, + text: data.text || null, bannerHeight: Number(data.bannerHeight) || 64, bannerAlign: data.bannerAlign, }, @@ -844,6 +848,18 @@ function colourField(label, name, value, fallback) { `; } +/** WCAG contrast ratio, mirroring the server so the console can warn as you type. */ +function contrastRatio(a, b) { + const lum = (hex) => { + const [r, g, bl] = [1, 3, 5] + .map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; + }; + const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + const bannerEditor = { dataUrl: null, remove: false }; function wireBannerEditor() { @@ -861,6 +877,36 @@ function wireBannerEditor() { }); }); + // Live contrast readout, because a colour that looks fine in a swatch can be + // unreadable as body text. + const pageInput = $('#modal-form [name="page"]'); + const textInput = $('#modal-form [name="text"]'); + const note = $('#contrast-note'); + + const showContrast = () => { + const page = pageInput.value.trim() || pageInput.placeholder; + const text = textInput.value.trim() || textInput.placeholder; + if (!/^#[0-9a-fA-F]{6}$/.test(page) || !/^#[0-9a-fA-F]{6}$/.test(text)) { + note.hidden = true; + return; + } + const ratio = contrastRatio(text, page); + note.hidden = false; + if (ratio >= 7) { + note.className = 'hint'; + note.textContent = `Contrast ${ratio.toFixed(1)}:1 — comfortable at arm's length.`; + } else if (ratio >= 4.5) { + note.className = 'hint'; + note.textContent = `Contrast ${ratio.toFixed(1)}:1 — readable, but aim for 7:1 on a kiosk people read standing up.`; + } else { + note.className = 'hint warn'; + note.textContent = `Contrast only ${ratio.toFixed(1)}:1. This will be hard to read — pick a darker or lighter body text.`; + } + }; + + [pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast)); + showContrast(); + $('#banner-file').addEventListener('change', (event) => { const file = event.target.files[0]; if (!file) return; @@ -1100,20 +1146,22 @@ async function loadSystem() {
On site now
${s.onSite}
Google Sheet
${ s.sheets.enabled - ? `Connected. ${s.sheets.queued} row(s) waiting to send.${s.sheets.lastError ? ` Last error: ${esc(s.sheets.lastError)}` : ''}` + ? `Mirroring who is on site to the "${esc(s.sheets.tab)}" tab${ + s.sheets.stale ? ' waiting to retry' : '' + }${s.sheets.lastError ? `
${esc(s.sheets.lastError)}` : ''}` : 'Turned off in the environment file.' }
-
History tab
${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}
-
Live tab
${esc(s.sheets.onSiteTab)} — ${ - s.sheets.onSiteCount === null ? 'not synced yet' : `${s.sheets.onSiteCount} on site` - }, last synced ${stamp(s.sheets.lastOnSiteSync)}${ - s.sheets.onSiteError ? ` ${esc(s.sheets.onSiteError)}` : '' - }
+ ${ + s.sheets.enabled + ? `
On the sheet
${ + s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site` + }, last written ${stamp(s.sheets.lastOk)}
` + : '' + }
- - +

Certificate

@@ -1128,28 +1176,14 @@ async function loadSystem() { toast(err.message, true); } }); - $('#sheet-resync').addEventListener('click', async () => { - try { - const r = await api('/sheets/sync', { method: 'POST' }); - toast(r.skipped ? 'Sheet mirroring is off.' : `Live tab rewritten with ${r.rows} on site.`); - loadSystem(); - } catch (err) { - toast(err.message, true); - } - }); - $('#sheet-flush').addEventListener('click', async () => { - try { - const r = await api('/sheets/flush', { method: 'POST' }); - toast(`${r.sent} sent, ${r.remaining} still queued.`); - loadSystem(); - } catch (err) { - toast(err.message, true); - } - }); $('#sheet-resync').addEventListener('click', async () => { try { const r = await api('/sheets/resync', { method: 'POST' }); - toast(`Live list rebuilt with ${r.rows} ${r.rows === 1 ? 'person' : 'people'}.`); + toast( + r.skipped + ? 'Sheet mirroring is off.' + : `Sheet rewritten with ${r.rows} ${r.rows === 1 ? 'person' : 'people'} on site.` + ); loadSystem(); } catch (err) { toast(err.message, true); diff --git a/public/js/kiosk.js b/public/js/kiosk.js index 7ec863f..001e0a0 100644 --- a/public/js/kiosk.js +++ b/public/js/kiosk.js @@ -452,6 +452,7 @@ function applyTheme(theme, banner, align = 'left') { root.setProperty('--paper', theme.page); root.setProperty('--card', theme.card); root.setProperty('--ink', theme.ink); + root.setProperty('--muted', theme.muted); root.setProperty('--rule', theme.rule); root.setProperty('--focus', theme.brand); document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand); diff --git a/src/branding.js b/src/branding.js index 287e138..779148c 100644 --- a/src/branding.js +++ b/src/branding.js @@ -16,6 +16,7 @@ export const DEFAULT_THEME = { brand: '#0b4f4a', signout: '#2c4a6b', page: '#e7ecf0', + text: '#16202b', }; const BANNER_DIR = path.join(config.dataDir, 'branding'); @@ -51,6 +52,36 @@ export function readableOn(hex) { return luminance(hex) > 0.45 ? '#16202b' : '#ffffff'; } +/** WCAG contrast ratio between two colours, from 1 (identical) to 21. */ +export function contrastRatio(a, b) { + const la = luminance(a); + const lb = luminance(b); + const [hi, lo] = la > lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +function mix(a, b, amount) { + const [ar, ag, ab] = toRgb(a); + const [br, bg, bb] = toRgb(b); + const channel = (x, y) => Math.round(x + (y - x) * amount); + return `#${[channel(ar, br), channel(ag, bg), channel(ab, bb)] + .map((c) => c.toString(16).padStart(2, '0')) + .join('')}`; +} + +/** + * A softer version of the body text for labels and hints. It is mixed towards the + * background only as far as it can go while still clearing WCAG AA at 4.5:1 — + * a fixed grey looks fine on the default background and disappears on a custom one. + */ +export function mutedFor(text, page) { + for (const amount of [0.45, 0.38, 0.3, 0.22, 0.14]) { + const candidate = mix(text, page, amount); + if (contrastRatio(candidate, page) >= 4.5) return candidate; + } + return text; +} + /** Shifts a colour towards black (negative) or white (positive). */ export function shade(hex, amount) { const channels = toRgb(hex).map((channel) => { @@ -66,6 +97,8 @@ export function themeFor(site) { const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand); const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout); const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page); + // Body text: whatever was chosen, or readable-by-default against the page. + const ink = normaliseColour(site?.colour_text, readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b'); return { brand, brandDark: shade(brand, -0.25), @@ -76,8 +109,11 @@ export function themeFor(site) { page, // A card needs to lift off the page whether the page is light or dark. card: luminance(page) > 0.5 ? '#ffffff' : shade(page, 0.12), - ink: readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b', + ink, + muted: mutedFor(ink, page), rule: luminance(page) > 0.5 ? shade(page, -0.12) : shade(page, 0.2), + // Surfaced so the admin console can warn about an unreadable combination. + textContrast: Number(contrastRatio(ink, page).toFixed(2)), }; } diff --git a/src/db.js b/src/db.js index b5c0845..b7d0e30 100644 --- a/src/db.js +++ b/src/db.js @@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS sites ( colour_brand TEXT, colour_signout TEXT, colour_page TEXT, + colour_text TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -166,6 +167,7 @@ addColumn('sites', 'banner_align', "TEXT NOT NULL DEFAULT 'left'"); addColumn('sites', 'colour_brand', 'TEXT'); addColumn('sites', 'colour_signout', 'TEXT'); addColumn('sites', 'colour_page', 'TEXT'); +addColumn('sites', 'colour_text', 'TEXT'); db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)'); db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)'); diff --git a/src/routes/admin.js b/src/routes/admin.js index 18aba4b..f5484ef 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -388,8 +388,8 @@ router.patch('/sites/:id', (req, res) => { db.prepare( `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, - colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ?, - banner_align = ? WHERE id = ?` + colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?, + banner_height = ?, banner_align = ? WHERE id = ?` ).run( clean(req.body?.name ?? site.name, 100) || site.name, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, @@ -409,6 +409,7 @@ router.patch('/sites/:id', (req, res) => { ? normaliseColour(branding.signout, null) : site.colour_signout, branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page, + branding.text !== undefined ? normaliseColour(branding.text, null) : site.colour_text, branding.bannerHeight !== undefined ? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64)) : site.banner_height, @@ -953,7 +954,7 @@ router.post('/visits/:id/signout', (req, res) => { 'admin', visit.id ); - sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT'); + sheets.mirror(); res.json({ ok: true }); }); @@ -1100,11 +1101,10 @@ router.get('/status', (req, res) => { : db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n, sheets: { enabled: sheets.isEnabled(), - queued: sheets.queueDepth(), lastOk: sheets.status.lastOk, lastError: sheets.status.lastError, - logTab: config.sheets.logTab, - onSiteTab: config.sheets.onSiteTab, + tab: sheets.tabName(), + stale: sheets.isStale(), lastOnSiteSync: sheets.status.lastOnSiteSync, onSiteCount: sheets.status.onSiteCount, onSiteError: sheets.status.onSiteError, @@ -1129,14 +1129,6 @@ router.post('/sheets/test', async (req, res) => { } }); -router.post('/sheets/flush', async (req, res) => { - try { - res.json(await sheets.flushQueue()); - } catch (err) { - res.status(400).json({ error: err.message }); - } -}); - /* ---------------------------------------------------------------- tls */ router.get('/tls', (req, res) => { diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js index f3031a5..1b4d35e 100644 --- a/src/routes/kiosk.js +++ b/src/routes/kiosk.js @@ -208,7 +208,7 @@ router.post('/signin', signInLimiter, (req, res) => { ); const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(info.lastInsertRowid); - mirror(visit, 'SIGN IN'); + mirror(); delete req.session.frequentVisitorId; // Lets this kiosk session fetch the badge for the visit it just created. @@ -300,7 +300,7 @@ router.post('/signout', signInLimiter, (req, res) => { 'visitor', visit.id ); - mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT'); + mirror(); res.json({ ok: true, firstName: visit.first_name, signedOutAt }); }); diff --git a/src/server.js b/src/server.js index a805987..ffb5740 100644 --- a/src/server.js +++ b/src/server.js @@ -102,7 +102,7 @@ if (config.autoSignOutTime) { 'auto', visit.id ); - sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT (AUTO)'); + sheets.mirror(); } if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`); }, 60000).unref(); diff --git a/src/sheets.js b/src/sheets.js index 3ef5ac3..900307a 100644 --- a/src/sheets.js +++ b/src/sheets.js @@ -5,33 +5,17 @@ import db from './db.js'; import { localStamp } from './util.js'; /** - * Two tabs, doing different jobs. + * The spreadsheet is an evacuation list and nothing else. * - * "On site now" — rewritten whenever someone signs in or out. Only the people - * currently in the building. This is the evacuation list. - * "Visitor log" — appended to, never rewritten. Every sign in and sign out event, - * kept for the record. + * One tab, rewritten in full whenever anyone signs in or out, holding only the + * people currently in the building. It is never appended to, so there is no + * history to scroll past while standing in a car park counting heads. + * + * The full visit history stays in the application's own database, where it is + * searchable in the admin console and exportable as CSV. */ -const LOG_HEADER = [ - 'Timestamp', - 'Site', - 'Action', - 'Visitor type', - 'First name', - 'Last name', - 'Phone', - 'Email', - 'Check type', - 'Check number', - 'Visiting', - 'Signed in', - 'Signed out', - 'Photo on file', - 'Visit ID', -]; - -const ONSITE_HEADER = [ +const HEADER = [ 'Site', 'First name', 'Last name', @@ -44,17 +28,17 @@ const ONSITE_HEADER = [ 'Visit ID', ]; +const MAX_ROWS = 1000; + let client = null; -let tabsPromise = null; -let onSiteDirty = false; +let tabPromise = null; +let dirty = false; let syncing = false; export const status = { lastOk: null, lastError: null, - lastOnSiteSync: null, onSiteCount: null, - onSiteError: null, }; /* ----------------------------------------------------------- connection */ @@ -86,93 +70,35 @@ export function isEnabled() { } /** - * Creates either tab if it is missing, and writes the header row once. - * Memoised as a promise, not a boolean: a sign in kicks off the log append and the - * on-site rewrite at the same moment, and two concurrent checks would each decide - * the tabs were missing and try to create them twice. + * Creates the tab if it is missing. Memoised as a promise rather than a boolean: + * two syncs starting at once would otherwise both decide it was missing. */ -function ensureTabs(sheets, { force = false } = {}) { - if (force) tabsPromise = null; - if (!tabsPromise) { - tabsPromise = doEnsureTabs(sheets).catch((err) => { - tabsPromise = null; // let the next attempt retry rather than caching the failure +function ensureTab(sheets, { force = false } = {}) { + if (force) tabPromise = null; + if (!tabPromise) { + tabPromise = doEnsureTab(sheets).catch((err) => { + tabPromise = null; throw err; }); } - return tabsPromise; + return tabPromise; } -async function doEnsureTabs(sheets) { +async function doEnsureTab(sheets) { const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId }); - const existing = meta.data.sheets.map((s) => s.properties.title); - const wanted = [config.sheets.logTab, config.sheets.onSiteTab]; - const missing = wanted.filter((t) => !existing.includes(t)); - - if (missing.length) { + const titles = meta.data.sheets.map((s) => s.properties.title); + if (!titles.includes(config.sheets.onSiteTab)) { await sheets.spreadsheets.batchUpdate({ spreadsheetId: config.sheets.spreadsheetId, requestBody: { - requests: missing.map((title) => ({ addSheet: { properties: { title } } })), + requests: [{ addSheet: { properties: { title: config.sheets.onSiteTab } } }], }, }); - console.log(`[sheets] created tab(s): ${missing.join(', ')}`); - } - - // Header on the log tab only. The on-site tab gets its header on every rewrite. - const range = `${config.sheets.logTab}!A1:O1`; - const head = await sheets.spreadsheets.values.get({ - spreadsheetId: config.sheets.spreadsheetId, - range, - }); - if (!head.data.values?.length) { - await sheets.spreadsheets.values.update({ - spreadsheetId: config.sheets.spreadsheetId, - range, - valueInputOption: 'RAW', - requestBody: { values: [LOG_HEADER] }, - }); + console.log(`[sheets] created tab "${config.sheets.onSiteTab}"`); } } -/* -------------------------------------------------------------- the log */ - -export function rowForVisit(visit, action) { - return [ - localStamp(new Date().toISOString()), - visit.site_name || '', - action, - visit.visitor_type === 'frequent' ? 'Recurring' : 'Guest', - visit.first_name, - visit.last_name, - visit.phone || '', - visit.email || '', - visit.check_type === 'NONE' ? 'None' : visit.check_type, - visit.check_number || '', - visit.host_name, - localStamp(visit.signed_in_at), - visit.signed_out_at ? localStamp(visit.signed_out_at) : '', - visit.photo_path ? 'Yes' : 'No', - String(visit.id), - ]; -} - -async function appendLog(row) { - const sheets = getClient(); - await ensureTabs(sheets); - await sheets.spreadsheets.values.append({ - spreadsheetId: config.sheets.spreadsheetId, - range: `${config.sheets.logTab}!A:O`, - valueInputOption: 'USER_ENTERED', - insertDataOption: 'INSERT_ROWS', - requestBody: { values: [row] }, - }); -} - -function enqueue(row) { - db.prepare('INSERT INTO sheet_queue (payload) VALUES (?)').run(JSON.stringify(row)); -} - -/* --------------------------------------------------------- who's on site */ +/* --------------------------------------------------------- who is here */ function humanDuration(fromIso) { const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000)); @@ -185,6 +111,7 @@ function onSiteRows() { return db .prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at') .all() + .slice(0, MAX_ROWS) .map((v) => [ v.site_name || '', v.first_name, @@ -200,113 +127,74 @@ function onSiteRows() { } /** - * Replaces the whole on-site tab with the current state. Rewriting rather than - * patching means a missed update never leaves a stale name on the evacuation list. + * Replaces the whole tab with the current state. Rewriting rather than patching + * means a missed update can never leave a stale name on the evacuation list: + * whatever is on the tab is what the database says right now. */ export async function syncOnSite() { if (!isEnabled()) return { skipped: true }; - if (syncing) return { skipped: true }; + if (syncing) { + dirty = true; + return { skipped: true }; + } syncing = true; try { const sheets = getClient(); - await ensureTabs(sheets); + await ensureTab(sheets); const rows = onSiteRows(); const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`; await sheets.spreadsheets.values.clear({ spreadsheetId: config.sheets.spreadsheetId, - range: `${config.sheets.onSiteTab}!A1:J1000`, + range: `${config.sheets.onSiteTab}!A1:J${MAX_ROWS + 10}`, }); await sheets.spreadsheets.values.update({ spreadsheetId: config.sheets.spreadsheetId, range: `${config.sheets.onSiteTab}!A1`, valueInputOption: 'RAW', - requestBody: { values: [[banner], ONSITE_HEADER, ...rows] }, + requestBody: { values: [[banner], HEADER, ...rows] }, }); - onSiteDirty = false; - status.lastOnSiteSync = new Date().toISOString(); - status.onSiteCount = rows.length; - status.lastOk = status.lastOnSiteSync; + dirty = false; + status.lastOk = new Date().toISOString(); status.lastError = null; - status.onSiteError = null; + status.onSiteCount = rows.length; return { rows: rows.length }; } catch (err) { - onSiteDirty = true; + dirty = true; status.lastError = err.message; - status.onSiteError = err.message; throw err; } finally { syncing = false; } } -/* -------------------------------------------------------------- mirroring */ - -/** Fire and forget: never let a Sheets outage block someone at the front desk. */ -export function mirror(visit, action) { +/** + * Called after every sign in and sign out. Fire and forget: a Sheets outage must + * never hold up someone standing at the front desk. A failure leaves the tab + * marked stale and the worker retries. + */ +export function mirror() { if (!isEnabled()) return; - - appendLog(rowForVisit(visit, action)) - .then(() => { - status.lastOk = new Date().toISOString(); - status.lastError = null; - }) - .catch((err) => { - status.lastError = err.message; - console.error('[sheets] log append failed, queued for retry:', err.message); - enqueue(rowForVisit(visit, action)); - }); - - onSiteDirty = true; - syncOnSite().catch((err) => console.error('[sheets] on-site sync failed:', err.message)); -} - -export async function flushQueue() { - if (!isEnabled()) return { sent: 0, remaining: 0 }; - const rows = db.prepare('SELECT * FROM sheet_queue ORDER BY id LIMIT 50').all(); - let sent = 0; - for (const item of rows) { - try { - await appendLog(JSON.parse(item.payload)); - db.prepare('DELETE FROM sheet_queue WHERE id = ?').run(item.id); - sent += 1; - status.lastOk = new Date().toISOString(); - status.lastError = null; - } catch (err) { - db.prepare('UPDATE sheet_queue SET attempts = attempts + 1, last_error = ? WHERE id = ?').run( - err.message, - item.id - ); - status.lastError = err.message; - break; // Sheets is still unhappy; try again on the next tick. - } - } - const remaining = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n; - return { sent, remaining }; + dirty = true; + syncOnSite().catch((err) => console.error('[sheets] sync failed, will retry:', err.message)); } export async function testConnection() { if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.'); const sheets = getClient(); const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId }); - await ensureTabs(sheets, { force: true }); + await ensureTab(sheets, { force: true }); await syncOnSite(); - status.lastOk = new Date().toISOString(); - status.lastError = null; - return { - title: meta.data.properties.title, - logTab: config.sheets.logTab, - onSiteTab: config.sheets.onSiteTab, - }; + return { title: meta.data.properties.title, tab: config.sheets.onSiteTab }; } -export function queueDepth() { - return db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n; +export function tabName() { + return config.sheets.onSiteTab; } -export function tabNames() { - return { log: config.sheets.logTab, onSite: config.sheets.onSiteTab }; +export function isStale() { + return dirty; } export function startWorker() { @@ -315,25 +203,31 @@ export function startWorker() { return; } console.log( - `[sheets] mirroring to ${config.sheets.spreadsheetId} ` + - `(log: "${config.sheets.logTab}", live: "${config.sheets.onSiteTab}")` + `[sheets] mirroring who is on site to ${config.sheets.spreadsheetId} ("${config.sheets.onSiteTab}")` ); + // Rows left over from the older append-only log are no longer sent anywhere. + const stale = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n; + if (stale) { + db.prepare('DELETE FROM sheet_queue').run(); + console.log( + `[sheets] discarded ${stale} queued history row(s): the sheet now holds only who is on site. ` + + 'The full history is still in the visit log.' + ); + } + + // Retry anything that failed, and keep the "on site for" column honest. setInterval(() => { - flushQueue().catch((err) => console.error('[sheets] flush error:', err.message)); - if (onSiteDirty) { - syncOnSite().catch((err) => console.error('[sheets] on-site retry failed:', err.message)); + if (dirty) { + syncOnSite().catch((err) => console.error('[sheets] retry failed:', err.message)); } }, config.sheets.retryIntervalMs).unref(); - // Refresh the "on site for" column so the live tab does not go stale while - // someone sits in a meeting all afternoon. setInterval(() => { syncOnSite().catch(() => { /* the retry above will pick it up */ }); }, 15 * 60 * 1000).unref(); - // Bring the live tab in line with the database at boot. - syncOnSite().catch((err) => console.error('[sheets] initial on-site sync failed:', err.message)); + syncOnSite().catch((err) => console.error('[sheets] initial sync failed:', err.message)); } diff --git a/src/sites.js b/src/sites.js index 20915c6..390b62b 100644 --- a/src/sites.js +++ b/src/sites.js @@ -56,6 +56,7 @@ export function shapeSite(site) { brand: site.colour_brand, signout: site.colour_signout, page: site.colour_page, + text: site.colour_text, theme: themeFor(site), }, badge: {