# Visitor sign in A self-hosted visitor kiosk for sites that need a WWCC/VIT record and a photo at the door. Runs in one Docker container, stores everything locally in SQLite, and mirrors every sign in and sign out to a Google Sheet so someone outside the building can see who is on site during an evacuation. - **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out. No link to the admin console: a kiosk is a public terminal and administration does not belong on it. Reach the console from a staff machine at `/admin`. - **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a visitor can ask for, sites, admin accounts, and system status. Handles several sites from one container, prints a badge after sign in if you want one, warns admins before a WWCC or VIT lapses, and gives each admin their own account with two factor. ## What it collects | | Guest sign in | Recurring visitor | |---|---|---| | First and last name | typed each visit | on file | | Company or organisation | optional, typed each visit | on file | | Person being visited | picked from the list | picked each visit | | Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk | | WWCC / VIT / none | typed each visit | on file | | Mobile and/or email | at least one required | on file | Sign out only needs a **last name** plus a **mobile number or email**, which works for both. The company field is optional and clearly marked as such — plenty of visitors are not from anywhere in particular. When it is filled in it appears in the on-site list, on the evacuation sheet, and in the visit log, and the log search matches on it, so you can pull up every visit from one contractor. It is **not** printed on the badge — the label stays name, host, time and check status. ## Quick start ```bash git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git cd visitor-signin cp .env.example .env # Generate a secret and set a real admin password before you start. openssl rand -hex 32 # paste into APP_SECRET $EDITOR .env docker compose up -d --build ``` The kiosk is then on `https://:8443` and the admin console on `https://:8443/admin`. Expect a browser warning until you install the authority certificate — see below. Set `ADMIN_BOOTSTRAP_EMAIL` and `ADMIN_BOOTSTRAP_PASSWORD` in `.env` before the first start — they create the first admin account, once. You'll be asked to enrol two factor and set a real password at first sign in. Then, in the admin console: 1. **Sites** → rename the first site, add more if you have them, and turn badge printing on. 2. **People to visit** → pick a site, then paste or upload your staff CSV (see `docs/hosts-sample.csv`). 3. **Recurring visitors** → add anyone who comes regularly. A PIN is generated and a printable card opens straight away. 4. **Admins** → invite the rest of the front office. ## Working from Windows, deploying to Ubuntu Develop on Windows, run the container on the Ubuntu host. Two scripts are included: ```powershell # in PowerShell, inside the visitor-signin folder .\push-to-gitea.ps1 ``` Or double-click `push-to-gitea.bat`. If PowerShell blocks the script, run `powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1`. On the Ubuntu host, `./push-to-gitea.sh` does the same thing. `.gitattributes` forces LF endings for everything except the `.ps1` and `.bat` helpers, so the shell scripts and the Dockerfile still work after a round trip through a Windows checkout — otherwise `gen-cert.sh` fails on the host with a confusing `\r: command not found`. Deploy on the Ubuntu host with: ```bash git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git cd visitor-signin && cp .env.example .env && nano .env docker compose up -d --build ``` `docker compose` on Windows works too if you have Docker Desktop, but the camera, printing and the `data/` permissions all behave more predictably on the Ubuntu host. ## Several sites, one container Each site has its own name, its own list of people to visit, and its own badge settings. Recurring visitors are either tied to one site or welcome at all of them. Point a kiosk at a site once, during setup, with `http://host:8088/?site=front-gate`. The tablet remembers the choice, so the address bar no longer matters. If you skip that, the kiosk asks which site it is on first use — and with only one site set up, it never asks at all. Sign in, sign out and the "already signed in" check are all scoped to the kiosk's site, so the same person can be signed in at two sites at once without the system arguing about it. An admin account can be limited to a single site. Those admins see only that site's visitors, staff list and log, and cannot touch the others. ## Branding the kiosk Each site can carry its own banner and colours, set under **Sites → Edit**. **Banner.** Upload a PNG, JPEG or WebP up to 2 MB. A PNG with a transparent background is the one to use — it sits straight on the bar colour with nothing painted behind it, so it works whatever colour you pick. The admin preview shows it on a checkerboard so you can see the transparency. When a banner is set it replaces the site name in the kiosk header. Set the on-screen height in pixels to suit the shape of your logo, and choose whether it sits **left** or **centred** — the header is a three-column layout, so a centred logo is centred on the page rather than centred in whatever space the clock leaves over. The setting applies to the site name too, when no banner is uploaded. SVG is deliberately not accepted: it can carry script, and this file is served to every kiosk. **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 | 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. The admin console keeps its own neutral look, so it stays recognisable when you are switching between sites. Printed badges stay black on white — a label printer has no colours to give. ## Badge printing Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows the site, the visitor's name, who they are visiting, the time in, their WWCC/VIT number or a boxed **No WWCC / VIT**, the photo if you want it, and an optional line of your own text. The photo is square, matching the crop taken at the kiosk, and sits vertically centred. Pick your stock from the **Label stock** list and the dimensions fill themselves in. A label noticeably taller than it is wide gets a stacked layout — photo on top, name beneath — which is what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type scales with the constraining dimension, so small labels stay readable. Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again" button on the confirmation screen if the first one jams. ### Brother QL-820NWB The default for a new site is 62 × 100 mm, which matches the DK-11202 die-cut label. For a continuous roll, 62 × 90 mm is a good visitor badge. It takes media 12 to 62 mm wide and prints up to 60.96 mm across at 300 × 300 dpi, so 62 mm is the widest roll it will accept — the console warns you if you enter anything wider. Useful rolls: | Roll | Size | Good for | |---|---|---| | DK-22205 | 62 mm continuous | The default. Cut to any length; 90 mm suits a visitor badge | | DK-11202 | 62 × 100 mm die-cut | Pre-cut, no length to choose | | DK-22251 | 62 mm continuous, black/red | Same as DK-22205 but supports the red option below | | DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off | | DK-11209 | 29 × 62 mm die-cut | Name and host only | **A DK-22251 must be set as the black/red roll even if nothing on the badge is red.** The printer refuses a monochrome job on two-colour tape, saying *Black/Red on White paper is installed now. Change it to Monochrome media.* The roll setting controls how the job is built, not just its colour. **Tell it which roll is loaded.** *Roll loaded in the printer* under **Sites → Edit → Printer** must match what is physically in the machine. A two-colour job sent to a plain roll is refused outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate from the red styling option on purpose — a design choice should not silently change the media type the printer is told to expect. **The red option.** Tick *Print the heading and the no-check warning in red* and the site name and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check obvious across a room. It needs the roll setting above to be the DK-22251; with a plain roll selected the badge is drawn in black instead and the console says so. Two-colour printing is also far slower than black alone (Brother rate it at roughly 15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing. ### Printing from the server Set the printer's IP address under **Sites → Edit → Printer** and tick *Print from the server*. The server then renders the badge itself and pushes it to the printer over the network, so: - a kiosk tablet needs no printer driver, no default printer and no print dialog - adding a second kiosk means plugging in a tablet, nothing else - the badge prints automatically the moment someone completes their sign in The QL-820NWB has Ethernet and Wi-Fi, so it lives on the network rather than tethered to a tablet. Give it a **fixed IP** — a DHCP lease change would silently stop badges printing. Rendering happens at 300 dpi and 696 dots across, which is the printer's fixed head width on a 62 mm roll. **Bitmap preview** on the site card shows the exact image that will be sent, and **Test print** sends a sample badge. Use both before committing a roll. **Rotation.** At 0° the badge is laid out across the 62 mm width and runs down the label. At 90° it is laid out along the length and turned before printing, which is what you want when the label hangs from its short edge. Set it per site and check the bitmap preview — the two look very different and only one will suit how you hang them. **Label stock** at the top of the badge settings is a shortcut that fills in the size, the roll type and the colour option. It is not itself a saved setting — the three fields it fills are what get stored, which is why it can appear to "revert" when the same dimensions describe two rolls. **Ask the printer what it has loaded.** This is the first thing to run when a job is refused: ```bash docker compose exec visitor-signin node scripts/printer-status.mjs ``` It reports the media width, whether the roll is continuous or die-cut, any error the printer is holding, and the roll id that matches. **It does not work on every printer.** Many Brother network print servers are write-only on port 9100: they accept jobs but never answer a status request, even though the same printer reports happily over USB. The QL-820NWB is one of them. When that happens, **Brother Status Monitor on a PC with the printer installed is the thing to use** — it gives the exact reason a job was refused, in plain words, which is more than anything on the server can tell you. `brother_ql`'s network backend never reads the socket at all, so it reports success no matter what the printer does. If the printer will not answer, work through the possibilities one at a time: ```bash docker compose exec -it visitor-signin node scripts/print-probe.mjs ``` It sends one label per roll id and waits for you to say whether anything came out, then moves on. The `-it` matters: it asks questions and needs a terminal. **Diagnostics from the command line.** When the console is not enough: ```bash docker compose exec visitor-signin node scripts/print-test.mjs # render and print docker compose exec visitor-signin node scripts/print-test.mjs --dry # render only docker compose exec visitor-signin node scripts/print-test.mjs --label 62x100 ``` It prints the settings it is using, the exact `brother_ql` command, and the printer's full reply. `--label` and `--rotate` override the saved settings for one run, so alternatives can be tried without saving anything. **Continuous versus die-cut matters.** `62` means a continuous roll cut to length; `62x100` means pre-cut labels. Sending one id to the other kind of roll is refused as a wrong roll, and this is the most common cause of that error after the two-colour setting. **Diagnostics.** The site card has a **Diagnostics** button showing exactly what would be sent, including the `brother_ql` command line, so it can be run by hand on the host. A failed test print shows the printer's own words rather than a summary. **If the printer cannot be reached**, sign in still completes. The kiosk falls back to its own browser print dialog, and the failure is shown against the site in **Admin → Sites** with the reason. Admins can reprint any badge from the **On site** list. Leave *Print from the server* off and the kiosk prints through the browser as before: set the QL-820NWB as the browser's default printer, margins to none, scaling 100%. ## WWCC and VIT expiry warnings Give a recurring visitor an expiry date and the console watches it. Inside `EXPIRY_WARNING_DAYS` (28 by default) their row turns amber; past the date it turns red, a count appears on the **Recurring visitors** tab, and a banner sits across the top of every screen. Nothing is blocked automatically — an expired check is a conversation to have at the desk, not a door the software should slam. Site-scoped admins only see warnings for their own site. ## Admin accounts and two factor Each admin signs in with their own email address and password. - **Roles.** *Owner* manages admins and sites. *Admin* handles day to day work, optionally limited to one site. - **Two factor.** With `ADMIN_REQUIRE_2FA=true` (the default) every admin enrols an authenticator app at first sign in — a QR code appears, they scan it, and eight one-shot recovery codes are issued. Standard TOTP, so Google Authenticator, Authy, 1Password, Bitwarden and the rest all work. - **Domain limits.** Set `ADMIN_ALLOWED_DOMAINS=yourschool.vic.edu.au` and both invitations and sign in refuse anything else. Subdomains of a listed domain are accepted. - **Recovery.** An owner can reset another admin's password (a temporary one is shown on screen, and they must change it at next sign in) or clear their two factor so they can re-enrol on a new phone. - **Lockout.** Six wrong passwords locks that email address for 15 minutes. If every owner loses access, stop the container, clear the `admin_users` table with `sqlite3 data/visitors.db "DELETE FROM admin_users;"`, and start it again — the bootstrap account is recreated from `.env`. ## HTTPS and the certificate The kiosk needs HTTPS: browsers block camera access on plain http unless the address is `localhost`. Since this never faces the internet, it runs its own certificate authority. With `HTTPS_ENABLED=true` (the default) the container creates two things at first start: - **A certificate authority**, valid for ten years. Install this on each kiosk device, once. - **A server certificate**, valid for about 13 months, signed by that authority. The server certificate renews itself before it lapses and reloads without a restart. Because the authority is what the devices trust, renewal never means touching the tablets again. This is why it isn't one plain self-signed certificate: Apple and Chrome reject server certificates valid for much more than a year, so a single self-signed file would have to be reinstalled everywhere every year. ### Set it up List every name and address staff might type, in `.env`: ``` HTTPS_ENABLED=true SECURE_COOKIES=true HTTPS_HOSTNAMES=visitors.local,visitors.hideaway.lan,192.168.1.50 HOST_PORT=8443 HTTPS_PUBLIC_PORT=8443 ``` An address that isn't listed produces a browser warning. After changing anything in `.env`, bring the container back with **`docker compose up -d`**, not `docker compose restart`. Restart reuses the running container along with the environment it started with, so the edit appears to do nothing; `up -d` recreates it and picks the new values up. Confirm with: ```bash docker compose exec visitor-signin printenv HTTPS_HOSTNAMES docker compose logs --tail=20 visitor-signin | grep tls ``` The log should say `renewing the server certificate: HTTPS_HOSTNAMES changed` and then list every name it now covers. If it lists only `localhost`, `visitors.local` and a `172.x` address, the variable never reached the container — those are the defaults plus the container's own docker bridge address. Reissuing does **not** touch the certificate authority, so devices that already trust it keep working and no MDM profile needs redeploying. The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`. ### Trusting the authority on each device Port 8080 runs a small plain-http helper that does exactly two things: hands out the authority certificate, and redirects everything else to https. It exists to solve the chicken-and-egg problem of fetching the certificate you don't yet trust. On each tablet, browse to `http://visitors.local:8080/ca.crt` and install the downloaded file: | Device | Where | |---|---| | Windows | Double-click → Install Certificate → Local Machine → *Trusted Root Certification Authorities* | | Android | Settings → Security → Encryption & credentials → Install a certificate → **CA certificate** | | iPad / iPhone | Open in Safari → install the profile → then Settings → General → About → **Certificate Trust Settings** and switch it on. The second step is separate and easy to miss | | macOS | Double-click → Keychain Access → System → set to *Always Trust* | | Ubuntu | `sudo cp ca.crt /usr/local/share/ca-certificates/visitor-signin.crt && sudo update-ca-certificates` | Admins can also download it from **Admin → System → Certificate**, which shows the expiry dates and the authority's fingerprint — check that fingerprint matches what the tablet shows during installation. ### Managing it later **Admin → System → Certificate** has *Renew the server certificate* (safe, no device changes) and *Start a new authority* (every device must trust the new one, so only for a suspected key leak). From a shell on the docker host, `./scripts/gen-cert.sh` and `./scripts/gen-cert.sh --force` do the same two jobs. The private keys live in `data/certs/` with `0600` permissions. They are in `.gitignore` and must never be committed. ### If you'd rather use a reverse proxy Set `HTTPS_ENABLED=false` and `TRUST_PROXY=true`, terminate TLS at Nginx Proxy Manager, Traefik or Caddy, and drop the port 8080 mapping from `docker-compose.yml`. ## Google Sheet mirroring Every sign in and sign out appends a row. If Google is unreachable the row is queued in the database and retried every minute, so a dropped internet connection never blocks the front desk. 1. In [Google Cloud Console](https://console.cloud.google.com/), create a project and enable the **Google Sheets API**. 2. Create a **service account**, then create a **JSON key** for it and download the file. 3. Create the spreadsheet you want to use. **Share it with the service account's email address** (it ends in `.iam.gserviceaccount.com`) with **Editor** access. This step is the one people forget — without it every write returns a permission error. 4. Copy the spreadsheet id out of the URL: `docs.google.com/spreadsheets/d/`**`THIS_PART`**`/edit`. 5. Put the key file at `./secrets/google-service-account.json` (compose mounts `./secrets` read-only), then in `.env`: ``` SHEETS_ENABLED=true SHEETS_SPREADSHEET_ID=THIS_PART SHEETS_LOG_TAB=Visitor log SHEETS_ONSITE_TAB=On site now GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json ``` If you would rather not mount a file, base64 the key instead — `base64 -w0 key.json` — and put the result in `GOOGLE_CREDENTIALS_B64`. 6. Restart, then **Admin → System → Test the sheet connection**. Both tabs and their headers are created the first time. ### What the sheet holds **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. 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. 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. Every row carries the site name, so one spreadsheet covers every site. **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. *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 Each saved person is one record: **mobile number, email address and PIN are all unique**, checked when a record is added or edited and enforced by the database. If an existing database already contains duplicates, the startup log names who collides and the checks stay at the application level until you fix them. ### A photo on file Give a recurring visitor a photo in the admin console — from the machine's camera or an uploaded file — and the kiosk stops asking them to pose. They enter their PIN, pick who they are visiting, and the sign in completes with their pass printing immediately. The stored photo is *copied* onto each visit rather than referenced, so the visit log stays a true snapshot: replacing someone's photo later does not change what past visits show, and photo retention cleaning up old visits can never delete a live profile photo. Leave the photo blank and they are asked at the kiosk as before. ### Removing someone **Remove** on the recurring visitors list deletes the saved record for good — the PIN stops working, the stored photo is deleted, and their mobile number, email and PIN become available for someone else. Their **visit history is kept**. Visits store the name, contact details and host as their own columns, so the log remains a complete record of who was in the building regardless of whether the person is still on file. Removing someone who is currently signed in does not sign them out; the confirmation says so, and their visit can still be closed with their last name and mobile number at the kiosk. If they might come back, untick **Active** in Edit instead. That keeps the record, the PIN and the history, but stops the PIN working at the kiosk. ### PINs The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with `APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy of the database — the real protection is the lockout: five wrong PINs on a number locks it for 15 minutes. Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on simultaneous recurring visitors. Deactivating someone frees theirs. **Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue PINs from the admin console afterwards. ## Where the data lives Everything is under `./data` on the docker host: ``` data/ ├── visitors.db SQLite: sites, visits, recurring visitors, hosts, admins, retry queue ├── visitors.db-wal write-ahead log — back this up alongside the .db ├── certs/ the local authority and the server certificate (keys are 0600) └── photos/2026/08/ JPEGs, foldered by year and month ``` Photos never leave the host. The sheet records only whether a photo exists. They are deleted automatically after `PHOTO_RETENTION_DAYS` (90 by default), and only the admin console can view them. To back up: `docker compose stop && tar czf visitor-backup-$(date +%F).tar.gz data/ && docker compose start`. ## Settings worth knowing | Variable | Does what | |---|---| | `REQUIRE_PHOTO` | `false` lets a visitor continue if the camera fails | | `AUTO_SIGNOUT_TIME` | e.g. `18:30` — closes off anyone still shown as on site. Blank to disable | | `PHOTO_RETENTION_DAYS` | `0` keeps photos forever | | `HOST_PORT` | https port on the docker host, default `8443` | | `HOST_HTTP_PORT` | http helper port, default `8080` — serves the CA and redirects | | `HTTPS_HOSTNAMES` | every name and IP the certificate should cover | | `EXPIRY_WARNING_DAYS` | how far ahead to warn about a WWCC or VIT, default `28` | | `ADMIN_REQUIRE_2FA` | `false` makes two factor optional per admin | | `ADMIN_ALLOWED_DOMAINS` | comma separated; blank allows any email domain | The kiosk returns to the home screen after two minutes of inactivity so the next visitor never sees the last one's details. ## Troubleshooting **`pull access denied for visitor-signin`** — something ran `docker compose pull`. The image is built here, not fetched from a registry. Use `docker compose up -d --build`. The compose file sets `pull_policy: build` so this should not come back. **`EACCES: permission denied, mkdir '/data/photos'`** — the bind-mounted `./data` on the host is owned by root, and the app runs as an unprivileged user. The container's entrypoint fixes this itself on start; if you are on an older build, do it by hand: ```bash sudo chown -R 1000:1000 data secrets docker compose up -d --build ``` **Changes to the code do nothing** — Compose reuses the existing image. Always `docker compose up -d --build` after a `git pull`. **Google Sheet says "The caller does not have permission"** — the app authenticated fine and Google refused the spreadsheet. Work through these in order: 1. **Admin → System** shows the service account address. Open the sheet, press Share, paste that address, set it to **Editor**, and untick "Notify people". This is the cause about nine times in ten. 2. If your Google Workspace blocks sharing outside the organisation, the share will silently fail or be refused — a service account address is external. Ask your Workspace admin to allow it, or create the sheet in an account that permits external sharing. 3. If the sheet lives in a **Shared drive**, share the drive with the service account, not just the file. 4. Check the spreadsheet ID matches the one in the sheet's URL. A wrong ID usually gives a 404, but a valid ID for someone else's sheet gives this same 403. 5. Confirm the **Google Sheets API** is enabled on the project the key belongs to. A key from project A cannot use an API enabled only on project B. Press **Test the sheet connection** after each step. **Browser still warns about the certificate** — the authority is installed but not trusted. On iOS that is a second, separate step under Settings → General → About → Certificate Trust Settings. On Android, use a hostname rather than a bare IP. ## Running without Docker ```bash npm install DATA_DIR=./data APP_SECRET=$(openssl rand -hex 32) \ ADMIN_BOOTSTRAP_EMAIL=you@example.com ADMIN_BOOTSTRAP_PASSWORD=ChangeMe12345 npm start ``` Node 20 or newer, and `openssl` on PATH if you want the container to issue its own certificate. ## A note on evacuation use The **On site now** tab is the offsite copy, and it only helps if someone can open it on a phone while standing in a car park. Bookmark it on the relevant phones and check it actually loads for them, not just for you. Check it again occasionally. A revoked service account key, or a sheet whose sharing was changed, will queue rows silently — the head count and *Updated* time in the first row are the giveaway, and **Admin → System** shows the last successful write and any error. Worth deciding now: the kiosk is on your internal network, so if the network or the container is down, the sheet stops updating while people keep walking in. `AUTO_SIGNOUT_TIME` limits how stale the list can get overnight, but a printed fallback at the front desk is still worth having. --- Created by: Jess Rogerson (yelling commands at Claude.AI)