Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -67,7 +67,6 @@ SHEETS_ENABLED=false
|
|||||||
# The long id from the sheet URL: docs.google.com/spreadsheets/d/<THIS PART>/edit
|
# The long id from the sheet URL: docs.google.com/spreadsheets/d/<THIS PART>/edit
|
||||||
SHEETS_SPREADSHEET_ID=
|
SHEETS_SPREADSHEET_ID=
|
||||||
# Append-only history of every sign in and sign out.
|
# 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
|
# Rewritten on every change: only the people currently on site. Open this one
|
||||||
# during an evacuation. Both tabs are created automatically if missing.
|
# during an evacuation. Both tabs are created automatically if missing.
|
||||||
SHEETS_ONSITE_TAB=On site now
|
SHEETS_ONSITE_TAB=On site now
|
||||||
|
|||||||
@@ -115,19 +115,23 @@ whatever space the clock leaves over. The setting applies to the site name too,
|
|||||||
uploaded. SVG is deliberately not accepted:
|
uploaded. SVG is deliberately not accepted:
|
||||||
it can carry script, and this file is served to every kiosk.
|
it can carry script, and this file is served to every kiosk.
|
||||||
|
|
||||||
**Colours.** Three are settable:
|
**Colours.** Four are settable:
|
||||||
|
|
||||||
| Setting | Where it shows |
|
| Setting | Where it shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Bar and buttons | The top bar, the Sign in door, primary buttons, the confirmation mark |
|
| 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 |
|
| Sign out | The Sign out door and the signed-out confirmation |
|
||||||
| Page background | Behind everything, with card and rule colours derived from it |
|
| 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
|
Two things are still worked out rather than set. **Text on a coloured background** is chosen by
|
||||||
background is chosen by contrast**, not fixed — pick a pale yellow for the bar and the text on
|
contrast, so a pale yellow bar gets dark text instead of unreadable white. And the **muted colour**
|
||||||
it turns dark automatically, instead of staying white and becoming unreadable. Card, border and
|
used for field labels and hints is your body text mixed towards the background only as far as it
|
||||||
body-text colours follow the page colour, so a dark background gives a usable dark theme rather
|
can go while still clearing WCAG AA at 4.5:1 — a fixed grey looks fine on the default background
|
||||||
than white boxes.
|
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
|
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.
|
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
|
6. Restart, then **Admin → System → Test the sheet connection**. Both tabs and their headers
|
||||||
are created the first time.
|
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
|
Rewriting rather than patching is deliberate: a failed update can never leave a stale name on the
|
||||||
currently in the building. No filtering, no scrolling to the bottom. The top row shows a head
|
evacuation list, because whatever is on the tab is what the database said at the time shown. If a
|
||||||
count and the time it was last updated, so you can tell at a glance whether it is live. This is
|
write fails the tab is marked stale and rewritten on the next pass, once a minute. It also
|
||||||
the tab to bookmark on the phones that matter and to open at the assembly point.
|
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.
|
The **full visit history stays in the application** — searchable under **Visit log** in the admin
|
||||||
This is the record you go back through weeks later.
|
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:
|
**Bookmark the sheet on the phones that would actually be used in an evacuation, and check it
|
||||||
if a write fails, the next one puts everything right. It also refreshes every 15 minutes on its
|
after setup.** A sheet nobody can find is not a safety measure.
|
||||||
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.
|
|
||||||
|
|
||||||
Anything you type into these tabs by hand will be overwritten. The sheet is a mirror, not the
|
*Upgrading from an earlier version:* the old "Visitor log" tab is left alone but no longer
|
||||||
source of truth — nothing is ever read back from it. Every row carries the site name, so one
|
written to. Delete it by hand when you are ready.
|
||||||
spreadsheet covers every site.
|
|
||||||
|
|
||||||
## Recurring visitors and PINs
|
## Recurring visitors and PINs
|
||||||
|
|
||||||
|
|||||||
+64
-30
@@ -777,8 +777,11 @@ function openSiteModal(site) {
|
|||||||
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
|
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
|
||||||
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
|
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Text colours are worked out from these, so a pale brand colour gets dark
|
${colourField('Body text', 'text', site.branding.text, site.branding.theme.ink)}
|
||||||
text rather than white. Clear a box to go back to the default.</p>`,
|
<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) => {
|
async (form) => {
|
||||||
const data = Object.fromEntries(form.entries());
|
const data = Object.fromEntries(form.entries());
|
||||||
try {
|
try {
|
||||||
@@ -800,6 +803,7 @@ function openSiteModal(site) {
|
|||||||
brand: data.brand || null,
|
brand: data.brand || null,
|
||||||
signout: data.signout || null,
|
signout: data.signout || null,
|
||||||
page: data.page || null,
|
page: data.page || null,
|
||||||
|
text: data.text || null,
|
||||||
bannerHeight: Number(data.bannerHeight) || 64,
|
bannerHeight: Number(data.bannerHeight) || 64,
|
||||||
bannerAlign: data.bannerAlign,
|
bannerAlign: data.bannerAlign,
|
||||||
},
|
},
|
||||||
@@ -844,6 +848,18 @@ function colourField(label, name, value, fallback) {
|
|||||||
</label>`;
|
</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 };
|
const bannerEditor = { dataUrl: null, remove: false };
|
||||||
|
|
||||||
function wireBannerEditor() {
|
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) => {
|
$('#banner-file').addEventListener('change', (event) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -1100,20 +1146,22 @@ async function loadSystem() {
|
|||||||
<dt>On site now</dt><dd>${s.onSite}</dd>
|
<dt>On site now</dt><dd>${s.onSite}</dd>
|
||||||
<dt>Google Sheet</dt><dd>${
|
<dt>Google Sheet</dt><dd>${
|
||||||
s.sheets.enabled
|
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.'
|
: 'Turned off in the environment file.'
|
||||||
}</dd>
|
}</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.enabled
|
||||||
s.sheets.onSiteCount === null ? 'not synced yet' : `${s.sheets.onSiteCount} on site`
|
? `<dt>On the sheet</dt><dd>${
|
||||||
}, last synced ${stamp(s.sheets.lastOnSiteSync)}${
|
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
|
||||||
s.sheets.onSiteError ? ` <span class="pill bad">${esc(s.sheets.onSiteError)}</span>` : ''
|
}, last written ${stamp(s.sheets.lastOk)}</dd>`
|
||||||
}</dd>
|
: ''
|
||||||
|
}
|
||||||
</dl>
|
</dl>
|
||||||
<div class="sys-actions">
|
<div class="sys-actions">
|
||||||
<button class="ghost" id="sheet-test">Test the sheet connection</button>
|
<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 sheet now</button>
|
||||||
<button class="ghost" id="sheet-resync">Rebuild the live list</button>
|
|
||||||
<button class="ghost danger" id="photo-purge">Purge photos past retention</button>
|
<button class="ghost danger" id="photo-purge">Purge photos past retention</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="section-gap">Certificate</h3>
|
<h3 class="section-gap">Certificate</h3>
|
||||||
@@ -1128,28 +1176,14 @@ async function loadSystem() {
|
|||||||
toast(err.message, true);
|
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 () => {
|
$('#sheet-resync').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api('/sheets/resync', { method: 'POST' });
|
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();
|
loadSystem();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ function applyTheme(theme, banner, align = 'left') {
|
|||||||
root.setProperty('--paper', theme.page);
|
root.setProperty('--paper', theme.page);
|
||||||
root.setProperty('--card', theme.card);
|
root.setProperty('--card', theme.card);
|
||||||
root.setProperty('--ink', theme.ink);
|
root.setProperty('--ink', theme.ink);
|
||||||
|
root.setProperty('--muted', theme.muted);
|
||||||
root.setProperty('--rule', theme.rule);
|
root.setProperty('--rule', theme.rule);
|
||||||
root.setProperty('--focus', theme.brand);
|
root.setProperty('--focus', theme.brand);
|
||||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);
|
||||||
|
|||||||
+37
-1
@@ -16,6 +16,7 @@ export const DEFAULT_THEME = {
|
|||||||
brand: '#0b4f4a',
|
brand: '#0b4f4a',
|
||||||
signout: '#2c4a6b',
|
signout: '#2c4a6b',
|
||||||
page: '#e7ecf0',
|
page: '#e7ecf0',
|
||||||
|
text: '#16202b',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BANNER_DIR = path.join(config.dataDir, 'branding');
|
const BANNER_DIR = path.join(config.dataDir, 'branding');
|
||||||
@@ -51,6 +52,36 @@ export function readableOn(hex) {
|
|||||||
return luminance(hex) > 0.45 ? '#16202b' : '#ffffff';
|
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). */
|
/** Shifts a colour towards black (negative) or white (positive). */
|
||||||
export function shade(hex, amount) {
|
export function shade(hex, amount) {
|
||||||
const channels = toRgb(hex).map((channel) => {
|
const channels = toRgb(hex).map((channel) => {
|
||||||
@@ -66,6 +97,8 @@ export function themeFor(site) {
|
|||||||
const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand);
|
const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand);
|
||||||
const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout);
|
const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout);
|
||||||
const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page);
|
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 {
|
return {
|
||||||
brand,
|
brand,
|
||||||
brandDark: shade(brand, -0.25),
|
brandDark: shade(brand, -0.25),
|
||||||
@@ -76,8 +109,11 @@ export function themeFor(site) {
|
|||||||
page,
|
page,
|
||||||
// A card needs to lift off the page whether the page is light or dark.
|
// 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),
|
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),
|
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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
colour_brand TEXT,
|
colour_brand TEXT,
|
||||||
colour_signout TEXT,
|
colour_signout TEXT,
|
||||||
colour_page TEXT,
|
colour_page TEXT,
|
||||||
|
colour_text TEXT,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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_brand', 'TEXT');
|
||||||
addColumn('sites', 'colour_signout', 'TEXT');
|
addColumn('sites', 'colour_signout', 'TEXT');
|
||||||
addColumn('sites', 'colour_page', '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_visits_site ON visits(site_id, signed_out_at)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
|
||||||
|
|||||||
+6
-14
@@ -388,8 +388,8 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||||
colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ?,
|
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
||||||
banner_align = ? WHERE id = ?`
|
banner_height = ?, banner_align = ? WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
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)
|
? normaliseColour(branding.signout, null)
|
||||||
: site.colour_signout,
|
: site.colour_signout,
|
||||||
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
|
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
|
||||||
|
branding.text !== undefined ? normaliseColour(branding.text, null) : site.colour_text,
|
||||||
branding.bannerHeight !== undefined
|
branding.bannerHeight !== undefined
|
||||||
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
|
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
|
||||||
: site.banner_height,
|
: site.banner_height,
|
||||||
@@ -953,7 +954,7 @@ router.post('/visits/:id/signout', (req, res) => {
|
|||||||
'admin',
|
'admin',
|
||||||
visit.id
|
visit.id
|
||||||
);
|
);
|
||||||
sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT');
|
sheets.mirror();
|
||||||
res.json({ ok: true });
|
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,
|
: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n,
|
||||||
sheets: {
|
sheets: {
|
||||||
enabled: sheets.isEnabled(),
|
enabled: sheets.isEnabled(),
|
||||||
queued: sheets.queueDepth(),
|
|
||||||
lastOk: sheets.status.lastOk,
|
lastOk: sheets.status.lastOk,
|
||||||
lastError: sheets.status.lastError,
|
lastError: sheets.status.lastError,
|
||||||
logTab: config.sheets.logTab,
|
tab: sheets.tabName(),
|
||||||
onSiteTab: config.sheets.onSiteTab,
|
stale: sheets.isStale(),
|
||||||
lastOnSiteSync: sheets.status.lastOnSiteSync,
|
lastOnSiteSync: sheets.status.lastOnSiteSync,
|
||||||
onSiteCount: sheets.status.onSiteCount,
|
onSiteCount: sheets.status.onSiteCount,
|
||||||
onSiteError: sheets.status.onSiteError,
|
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 */
|
/* ---------------------------------------------------------------- tls */
|
||||||
|
|
||||||
router.get('/tls', (req, res) => {
|
router.get('/tls', (req, res) => {
|
||||||
|
|||||||
+2
-2
@@ -208,7 +208,7 @@ router.post('/signin', signInLimiter, (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(info.lastInsertRowid);
|
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(info.lastInsertRowid);
|
||||||
mirror(visit, 'SIGN IN');
|
mirror();
|
||||||
delete req.session.frequentVisitorId;
|
delete req.session.frequentVisitorId;
|
||||||
|
|
||||||
// Lets this kiosk session fetch the badge for the visit it just created.
|
// Lets this kiosk session fetch the badge for the visit it just created.
|
||||||
@@ -300,7 +300,7 @@ router.post('/signout', signInLimiter, (req, res) => {
|
|||||||
'visitor',
|
'visitor',
|
||||||
visit.id
|
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 });
|
res.json({ ok: true, firstName: visit.first_name, signedOutAt });
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ if (config.autoSignOutTime) {
|
|||||||
'auto',
|
'auto',
|
||||||
visit.id
|
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`);
|
if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`);
|
||||||
}, 60000).unref();
|
}, 60000).unref();
|
||||||
|
|||||||
+70
-176
@@ -5,33 +5,17 @@ import db from './db.js';
|
|||||||
import { localStamp } from './util.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
|
* One tab, rewritten in full whenever anyone signs in or out, holding only the
|
||||||
* currently in the building. This is the evacuation list.
|
* people currently in the building. It is never appended to, so there is no
|
||||||
* "Visitor log" — appended to, never rewritten. Every sign in and sign out event,
|
* history to scroll past while standing in a car park counting heads.
|
||||||
* kept for the record.
|
*
|
||||||
|
* 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 = [
|
const 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 = [
|
|
||||||
'Site',
|
'Site',
|
||||||
'First name',
|
'First name',
|
||||||
'Last name',
|
'Last name',
|
||||||
@@ -44,17 +28,17 @@ const ONSITE_HEADER = [
|
|||||||
'Visit ID',
|
'Visit ID',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MAX_ROWS = 1000;
|
||||||
|
|
||||||
let client = null;
|
let client = null;
|
||||||
let tabsPromise = null;
|
let tabPromise = null;
|
||||||
let onSiteDirty = false;
|
let dirty = false;
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
|
|
||||||
export const status = {
|
export const status = {
|
||||||
lastOk: null,
|
lastOk: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
lastOnSiteSync: null,
|
|
||||||
onSiteCount: null,
|
onSiteCount: null,
|
||||||
onSiteError: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ----------------------------------------------------------- connection */
|
/* ----------------------------------------------------------- connection */
|
||||||
@@ -86,93 +70,35 @@ export function isEnabled() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates either tab if it is missing, and writes the header row once.
|
* Creates the tab if it is missing. Memoised as a promise rather than a boolean:
|
||||||
* Memoised as a promise, not a boolean: a sign in kicks off the log append and the
|
* two syncs starting at once would otherwise both decide it was missing.
|
||||||
* on-site rewrite at the same moment, and two concurrent checks would each decide
|
|
||||||
* the tabs were missing and try to create them twice.
|
|
||||||
*/
|
*/
|
||||||
function ensureTabs(sheets, { force = false } = {}) {
|
function ensureTab(sheets, { force = false } = {}) {
|
||||||
if (force) tabsPromise = null;
|
if (force) tabPromise = null;
|
||||||
if (!tabsPromise) {
|
if (!tabPromise) {
|
||||||
tabsPromise = doEnsureTabs(sheets).catch((err) => {
|
tabPromise = doEnsureTab(sheets).catch((err) => {
|
||||||
tabsPromise = null; // let the next attempt retry rather than caching the failure
|
tabPromise = null;
|
||||||
throw err;
|
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 meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
|
||||||
const existing = meta.data.sheets.map((s) => s.properties.title);
|
const titles = meta.data.sheets.map((s) => s.properties.title);
|
||||||
const wanted = [config.sheets.logTab, config.sheets.onSiteTab];
|
if (!titles.includes(config.sheets.onSiteTab)) {
|
||||||
const missing = wanted.filter((t) => !existing.includes(t));
|
|
||||||
|
|
||||||
if (missing.length) {
|
|
||||||
await sheets.spreadsheets.batchUpdate({
|
await sheets.spreadsheets.batchUpdate({
|
||||||
spreadsheetId: config.sheets.spreadsheetId,
|
spreadsheetId: config.sheets.spreadsheetId,
|
||||||
requestBody: {
|
requestBody: {
|
||||||
requests: missing.map((title) => ({ addSheet: { properties: { title } } })),
|
requests: [{ addSheet: { properties: { title: config.sheets.onSiteTab } } }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`[sheets] created tab(s): ${missing.join(', ')}`);
|
console.log(`[sheets] created tab "${config.sheets.onSiteTab}"`);
|
||||||
}
|
|
||||||
|
|
||||||
// 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] },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------- the log */
|
/* --------------------------------------------------------- who is here */
|
||||||
|
|
||||||
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 */
|
|
||||||
|
|
||||||
function humanDuration(fromIso) {
|
function humanDuration(fromIso) {
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000));
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000));
|
||||||
@@ -185,6 +111,7 @@ function onSiteRows() {
|
|||||||
return db
|
return db
|
||||||
.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at')
|
.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at')
|
||||||
.all()
|
.all()
|
||||||
|
.slice(0, MAX_ROWS)
|
||||||
.map((v) => [
|
.map((v) => [
|
||||||
v.site_name || '',
|
v.site_name || '',
|
||||||
v.first_name,
|
v.first_name,
|
||||||
@@ -200,113 +127,74 @@ function onSiteRows() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the whole on-site tab with the current state. Rewriting rather than
|
* Replaces the whole tab with the current state. Rewriting rather than patching
|
||||||
* patching means a missed update never leaves a stale name on the evacuation list.
|
* 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() {
|
export async function syncOnSite() {
|
||||||
if (!isEnabled()) return { skipped: true };
|
if (!isEnabled()) return { skipped: true };
|
||||||
if (syncing) return { skipped: true };
|
if (syncing) {
|
||||||
|
dirty = true;
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
syncing = true;
|
syncing = true;
|
||||||
try {
|
try {
|
||||||
const sheets = getClient();
|
const sheets = getClient();
|
||||||
await ensureTabs(sheets);
|
await ensureTab(sheets);
|
||||||
const rows = onSiteRows();
|
const rows = onSiteRows();
|
||||||
const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`;
|
const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`;
|
||||||
|
|
||||||
await sheets.spreadsheets.values.clear({
|
await sheets.spreadsheets.values.clear({
|
||||||
spreadsheetId: config.sheets.spreadsheetId,
|
spreadsheetId: config.sheets.spreadsheetId,
|
||||||
range: `${config.sheets.onSiteTab}!A1:J1000`,
|
range: `${config.sheets.onSiteTab}!A1:J${MAX_ROWS + 10}`,
|
||||||
});
|
});
|
||||||
await sheets.spreadsheets.values.update({
|
await sheets.spreadsheets.values.update({
|
||||||
spreadsheetId: config.sheets.spreadsheetId,
|
spreadsheetId: config.sheets.spreadsheetId,
|
||||||
range: `${config.sheets.onSiteTab}!A1`,
|
range: `${config.sheets.onSiteTab}!A1`,
|
||||||
valueInputOption: 'RAW',
|
valueInputOption: 'RAW',
|
||||||
requestBody: { values: [[banner], ONSITE_HEADER, ...rows] },
|
requestBody: { values: [[banner], HEADER, ...rows] },
|
||||||
});
|
});
|
||||||
|
|
||||||
onSiteDirty = false;
|
dirty = false;
|
||||||
status.lastOnSiteSync = new Date().toISOString();
|
status.lastOk = new Date().toISOString();
|
||||||
status.onSiteCount = rows.length;
|
|
||||||
status.lastOk = status.lastOnSiteSync;
|
|
||||||
status.lastError = null;
|
status.lastError = null;
|
||||||
status.onSiteError = null;
|
status.onSiteCount = rows.length;
|
||||||
return { rows: rows.length };
|
return { rows: rows.length };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onSiteDirty = true;
|
dirty = true;
|
||||||
status.lastError = err.message;
|
status.lastError = err.message;
|
||||||
status.onSiteError = err.message;
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
syncing = false;
|
syncing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------- mirroring */
|
/**
|
||||||
|
* Called after every sign in and sign out. Fire and forget: a Sheets outage must
|
||||||
/** Fire and forget: never let a Sheets outage block someone at the front desk. */
|
* never hold up someone standing at the front desk. A failure leaves the tab
|
||||||
export function mirror(visit, action) {
|
* marked stale and the worker retries.
|
||||||
|
*/
|
||||||
|
export function mirror() {
|
||||||
if (!isEnabled()) return;
|
if (!isEnabled()) return;
|
||||||
|
dirty = true;
|
||||||
appendLog(rowForVisit(visit, action))
|
syncOnSite().catch((err) => console.error('[sheets] sync failed, will retry:', err.message));
|
||||||
.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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testConnection() {
|
export async function testConnection() {
|
||||||
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
|
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
|
||||||
const sheets = getClient();
|
const sheets = getClient();
|
||||||
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
|
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
|
||||||
await ensureTabs(sheets, { force: true });
|
await ensureTab(sheets, { force: true });
|
||||||
await syncOnSite();
|
await syncOnSite();
|
||||||
status.lastOk = new Date().toISOString();
|
return { title: meta.data.properties.title, tab: config.sheets.onSiteTab };
|
||||||
status.lastError = null;
|
|
||||||
return {
|
|
||||||
title: meta.data.properties.title,
|
|
||||||
logTab: config.sheets.logTab,
|
|
||||||
onSiteTab: config.sheets.onSiteTab,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function queueDepth() {
|
export function tabName() {
|
||||||
return db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
|
return config.sheets.onSiteTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tabNames() {
|
export function isStale() {
|
||||||
return { log: config.sheets.logTab, onSite: config.sheets.onSiteTab };
|
return dirty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startWorker() {
|
export function startWorker() {
|
||||||
@@ -315,25 +203,31 @@ export function startWorker() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`[sheets] mirroring to ${config.sheets.spreadsheetId} ` +
|
`[sheets] mirroring who is on site to ${config.sheets.spreadsheetId} ("${config.sheets.onSiteTab}")`
|
||||||
`(log: "${config.sheets.logTab}", live: "${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(() => {
|
setInterval(() => {
|
||||||
flushQueue().catch((err) => console.error('[sheets] flush error:', err.message));
|
if (dirty) {
|
||||||
if (onSiteDirty) {
|
syncOnSite().catch((err) => console.error('[sheets] retry failed:', err.message));
|
||||||
syncOnSite().catch((err) => console.error('[sheets] on-site retry failed:', err.message));
|
|
||||||
}
|
}
|
||||||
}, config.sheets.retryIntervalMs).unref();
|
}, 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(() => {
|
setInterval(() => {
|
||||||
syncOnSite().catch(() => {
|
syncOnSite().catch(() => {
|
||||||
/* the retry above will pick it up */
|
/* the retry above will pick it up */
|
||||||
});
|
});
|
||||||
}, 15 * 60 * 1000).unref();
|
}, 15 * 60 * 1000).unref();
|
||||||
|
|
||||||
// Bring the live tab in line with the database at boot.
|
syncOnSite().catch((err) => console.error('[sheets] initial sync failed:', err.message));
|
||||||
syncOnSite().catch((err) => console.error('[sheets] initial on-site sync failed:', err.message));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function shapeSite(site) {
|
|||||||
brand: site.colour_brand,
|
brand: site.colour_brand,
|
||||||
signout: site.colour_signout,
|
signout: site.colour_signout,
|
||||||
page: site.colour_page,
|
page: site.colour_page,
|
||||||
|
text: site.colour_text,
|
||||||
theme: themeFor(site),
|
theme: themeFor(site),
|
||||||
},
|
},
|
||||||
badge: {
|
badge: {
|
||||||
|
|||||||
Reference in New Issue
Block a user