Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-02 16:45:48 +10:00
parent ff9f51eaf9
commit 8c97c314e0
11 changed files with 211 additions and 247 deletions
-1
View File
@@ -67,7 +67,6 @@ SHEETS_ENABLED=false
# The long id from the sheet URL: docs.google.com/spreadsheets/d/<THIS PART>/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
+27 -22
View File
@@ -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
+64 -30
View File
@@ -777,8 +777,11 @@ function openSiteModal(site) {
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
</div>
<p class="hint">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.</p>`,
${colourField('Body text', 'text', site.branding.text, site.branding.theme.ink)}
<p class="hint" id="contrast-note"></p>
<p class="hint">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.</p>`,
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) {
</label>`;
}
/** 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() {
<dt>On site now</dt><dd>${s.onSite}</dd>
<dt>Google Sheet</dt><dd>${
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 ? ' <span class="pill warn">waiting to retry</span>' : ''
}${s.sheets.lastError ? `<br><span class="pill bad">${esc(s.sheets.lastError)}</span>` : ''}`
: 'Turned off in the environment file.'
}</dd>
<dt>History tab</dt><dd>${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}</dd>
<dt>Live tab</dt><dd>${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 ? ` <span class="pill bad">${esc(s.sheets.onSiteError)}</span>` : ''
}</dd>
${
s.sheets.enabled
? `<dt>On the sheet</dt><dd>${
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
}, last written ${stamp(s.sheets.lastOk)}</dd>`
: ''
}
</dl>
<div class="sys-actions">
<button class="ghost" id="sheet-test">Test the sheet connection</button>
<button class="ghost" id="sheet-flush">Send queued rows now</button>
<button class="ghost" id="sheet-resync">Rebuild the live list</button>
<button class="ghost" id="sheet-resync">Rebuild the sheet now</button>
<button class="ghost danger" id="photo-purge">Purge photos past retention</button>
</div>
<h3 class="section-gap">Certificate</h3>
@@ -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);
+1
View File
@@ -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);
+37 -1
View File
@@ -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)),
};
}
+2
View File
@@ -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)');
+6 -14
View File
@@ -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) => {
+2 -2
View File
@@ -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 });
});
+1 -1
View File
@@ -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();
+70 -176
View File
@@ -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));
}
+1
View File
@@ -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: {