From 8f409eef97cadd58f46f7e809d056d6c220294c3 Mon Sep 17 00:00:00 2001 From: Scripps Sandbox Makerspace Date: Mon, 10 Aug 2026 15:29:59 -0700 Subject: [PATCH 1/3] Add shared Raspberry Pi signage --- signage/.env.example | 14 + signage/.gitignore | 5 + signage/CONTINUE_HERE.md | 117 +++++ signage/PHASE2_SETUP.md | 51 ++ signage/PHASE3_DEPLOYMENT.md | 48 ++ signage/README.md | 69 +++ signage/apps-script/Code.gs | 162 ++++++ signage/apps-script/appsscript.json | 6 + signage/assets/ucsd-sio-horizontal-black.png | Bin 0 -> 31511 bytes signage/data/sample-google-feed.json | 44 ++ signage/data/sample-mezzanine-feed.json | 35 ++ signage/deploy/install-pi.sh | 131 +++++ signage/deploy/launch-kiosk.sh | 40 ++ signage/index.html | 27 + signage/package.json | 11 + signage/scripts/deploy-to-pi.sh | 38 ++ signage/server.mjs | 74 +++ signage/server/data-service.mjs | 509 +++++++++++++++++++ signage/server/env.mjs | 18 + signage/src/app.js | 267 ++++++++++ signage/src/data-client.js | 38 ++ signage/src/model.js | 153 ++++++ signage/src/scenarios.js | 153 ++++++ signage/src/styles.css | 484 ++++++++++++++++++ signage/tests/data-client.test.mjs | 41 ++ signage/tests/data-service.test.mjs | 172 +++++++ signage/tests/model.test.mjs | 76 +++ 27 files changed, 2783 insertions(+) create mode 100644 signage/.env.example create mode 100644 signage/.gitignore create mode 100644 signage/CONTINUE_HERE.md create mode 100644 signage/PHASE2_SETUP.md create mode 100644 signage/PHASE3_DEPLOYMENT.md create mode 100644 signage/README.md create mode 100644 signage/apps-script/Code.gs create mode 100644 signage/apps-script/appsscript.json create mode 100644 signage/assets/ucsd-sio-horizontal-black.png create mode 100644 signage/data/sample-google-feed.json create mode 100644 signage/data/sample-mezzanine-feed.json create mode 100755 signage/deploy/install-pi.sh create mode 100755 signage/deploy/launch-kiosk.sh create mode 100644 signage/index.html create mode 100644 signage/package.json create mode 100755 signage/scripts/deploy-to-pi.sh create mode 100644 signage/server.mjs create mode 100644 signage/server/data-service.mjs create mode 100644 signage/server/env.mjs create mode 100644 signage/src/app.js create mode 100644 signage/src/data-client.js create mode 100644 signage/src/model.js create mode 100644 signage/src/scenarios.js create mode 100644 signage/src/styles.css create mode 100644 signage/tests/data-client.test.mjs create mode 100644 signage/tests/data-service.test.mjs create mode 100644 signage/tests/model.test.mjs diff --git a/signage/.env.example b/signage/.env.example new file mode 100644 index 0000000000..9204961bf8 --- /dev/null +++ b/signage/.env.example @@ -0,0 +1,14 @@ +# Deployed Google Apps Script /exec URL. Leave blank to use the bundled sample feed. +SIGNAGE_GOOGLE_FEED_URL= + +# Must match the Apps Script property SIGNAGE_FEED_TOKEN. +SIGNAGE_FEED_TOKEN= + +# Selects the room, scheduling rules, and visual theme for this device. +# Valid values: classroom or mezzanine +SIGNAGE_DISPLAY=classroom + +# Optional local settings. +PORT=4173 +SIGNAGE_REFRESH_SECONDS=60 +SIGNAGE_VISIBILITY_REFRESH_MINUTES=30 diff --git a/signage/.gitignore b/signage/.gitignore new file mode 100644 index 0000000000..f1b3078e52 --- /dev/null +++ b/signage/.gitignore @@ -0,0 +1,5 @@ +.env +cache/ +node_modules/ +outputs/ +work/ diff --git a/signage/CONTINUE_HERE.md b/signage/CONTINUE_HERE.md new file mode 100644 index 0000000000..19807362dd --- /dev/null +++ b/signage/CONTINUE_HERE.md @@ -0,0 +1,117 @@ +# Continue work on the Scripps Sandbox signage + +This file is the durable handoff for continuing the project without the original Codex conversation. + +## Project identity + +- Local source folder: `/Users/ritterratter/Documents/Codex/2026-08-06/referenced-chatgpt-conversation-this-is-an` +- Displays: Sandbox Classroom and Mezzanine Conference Table signage, 3840 × 2160, 16:9 +- Classroom Raspberry Pi: `sandbox@sandbox-signage-classroom.local`, profile `classroom` +- Mezzanine Raspberry Pi: hostname to be assigned, profile `mezzanine` +- Installed Pi application: `/home/sandbox/sandbox-signage` +- Pi service: `sandbox-signage.service` +- Local preview: `http://127.0.0.1:4173/` +- Review controls: `http://127.0.0.1:4173/?controls=1` + +## Start a future Codex task + +Open this folder as the Codex workspace and begin with: + +> This is an existing Raspberry Pi digital-signage project. Read `CONTINUE_HERE.md`, `README.md`, and the phase documents before changing anything. Preserve the approved visual system and current data rules. Inspect the implementation, make the requested change, run all tests, and show me the local result before deploying to the Pi. + +Then describe the desired change. The previous chat is not required. + +## Approved information design + +- Fixed 3840 × 2160 canvas scaled to the available screen +- Source Sans 3 for most text +- Jost for numbers and the primary availability word +- Orange environmental rail on the left +- Aqua primary field +- Current time and date +- Current classroom status and today's graphical schedule +- Five-workday summary on the right +- Water temperature, visibility, and tide conditions +- Quiet source and stale-data indicators +- One event per line when a workday contains multiple bookings +- Actual time ranges on separate lines when a day contains multiple open windows +- Mezzanine theme: navy `#182B49` rail, warm cream `#F5F0E6` field, black main text, white rail text + +## Data sources and rules + +- `H-Lab - 1 - Sandbox Classroom (24)` is authoritative for classroom bookings. +- `H-Lab - 2 - Mezzanine Conference Table (12)` is authoritative for mezzanine reservations. +- `Sandbox Access` events titled/classified as `door_open` define public access windows. +- An empty access-calendar day is closed. +- Time outside a `door_open` event is closed. +- The mezzanine ignores Sandbox Access; an empty reservation day means available. +- Access events classified as closed or maintenance override open windows. +- Google Apps Script provides a display-safe public feed; deployment details are in `PHASE2_SETUP.md`. +- NOAA station 9410230 provides La Jolla tide predictions and water temperature. +- Just Get Wet provides supplemental reported underwater visibility. It is cached and attributed. +- The server and browser retain last reliable data when a source or network is unavailable. + +## Local change workflow + +1. Ask Codex to inspect this folder and make the change. +2. Keep `http://127.0.0.1:4173/?controls=1` open for state and time testing. +3. Run the complete test suite before deployment. +4. Review the live-data screen without `?controls=1`. +5. Deploy only after the local result is approved. + +Tests: + +```sh +npm test +``` + +Local server: + +```sh +npm run serve +``` + +If `npm` is unavailable on the Mac, ask Codex to run the local server and tests using its bundled Node runtime. + +## Update the Raspberry Pi + +Run from the Mac Terminal: + +```sh +cd /Users/ritterratter/Documents/Codex/2026-08-06/referenced-chatgpt-conversation-this-is-an && sh scripts/deploy-to-pi.sh sandbox@sandbox-signage-classroom.local +``` + +For the mezzanine, substitute its hostname and append `mezzanine` as the second argument. The explicit classroom form is: + +```sh +sh scripts/deploy-to-pi.sh sandbox@sandbox-signage-classroom.local classroom +``` + +The installer preserves the previous Pi installation in a timestamped backup, replaces the application, restarts the service, and offers to reboot. Reboot so the kiosk browser loads new JavaScript and styling. + +## Useful Pi diagnostics + +```sh +ssh sandbox@sandbox-signage-classroom.local +systemctl status sandbox-signage.service +journalctl -u sandbox-signage.service -n 50 --no-pager +rpi-connect status +rpi-connect doctor +``` + +On networks that block `.local` discovery or local SSH, use the Raspberry Pi Connect remote shell. + +## Files to read before changing behavior + +- `README.md`: application overview and live-data policy +- `PHASE2_SETUP.md`: Google feed and source classification +- `PHASE3_DEPLOYMENT.md`: Pi kiosk installation and update process +- `server/data-service.mjs`: data normalization, access rules, and caching +- `src/model.js`: current room state and schedule segmentation +- `src/app.js`: display rendering +- `src/styles.css`: fixed-canvas layout and visual system +- `tests/`: executable behavior specifications + +## Backup requirement + +Back up this entire folder somewhere other than this Mac. The `.env` file is hidden and excluded from Git, but contains the deployed feed configuration required by a new installation. A private Git repository is appropriate for the source, with `.env` backed up separately in a secure location. diff --git a/signage/PHASE2_SETUP.md b/signage/PHASE2_SETUP.md new file mode 100644 index 0000000000..259513a86f --- /dev/null +++ b/signage/PHASE2_SETUP.md @@ -0,0 +1,51 @@ +# Phase 2 setup + +The application-side integration is complete. The Google-hosted feed has been deployed and this workspace's `.env` already points to it. The steps below are retained for rebuilding or moving the feed later. + +## 1. Create the read-only Google feed + +1. Open [Google Apps Script](https://script.google.com/) with the account that can read the Sandbox calendars and settings workbook. +2. Create a new project named **Sandbox Signage Feed**. +3. Replace its `Code.gs` with the contents of `apps-script/Code.gs`. +4. Confirm **Project Settings → Time zone** is **Pacific Time – Los Angeles**. +5. Choose **Deploy → New deployment → Web app**. +6. Set **Execute as** to yourself and **Who has access** to **Anyone**. The schedule is already public, and this allows the Raspberry Pi to refresh without an interactive Google login. +7. Authorize the script's read access to the spreadsheet and calendars, deploy it, and copy the `/exec` URL. + +The feed returns only display-ready room titles, times, access classifications, and settings. It does not return attendees, descriptions, email addresses, or Google OAuth tokens. + +## 2. Configure the local server + +Copy `.env.example` to `.env`, then add the web-app URL: + +```text +SIGNAGE_GOOGLE_FEED_URL=https://script.google.com/macros/s/DEPLOYMENT_ID/exec +SIGNAGE_FEED_TOKEN= +SIGNAGE_DISPLAY=classroom +PORT=4173 +SIGNAGE_REFRESH_SECONDS=60 +``` + +`.env` and the disk cache are excluded from source control. + +## 3. Run and verify + +```sh +npm run serve +``` + +Open [http://127.0.0.1:4173](http://127.0.0.1:4173). The footer should change from **SETUP FEED · USING SAMPLE CALENDAR DATA** to **DATA CURRENT** once both the Google feed and NOAA respond. + +Set `SIGNAGE_DISPLAY=mezzanine` on the second Pi. The server adds `display=classroom` or `display=mezzanine` to the feed request automatically. + +The health endpoint at [http://127.0.0.1:4173/api/health](http://127.0.0.1:4173/api/health) reports whether the server is currently using a successful refresh or a cached fallback. + +## Source policy + +- Classroom bookings come only from **H-Lab - 1 - Sandbox Classroom (24)**. +- Sandbox Access `door_open` events define the hours when the space is open. Empty days and time outside those events are closed. +- Sandbox Access `closed` and `maintenance` events override open windows and block the classroom timeline. +- Sandbox Access `pickup_only` and `poster_pickup` events do not create general classroom availability. +- An active `closed` or `maintenance` LiveStatus override temporarily blocks the classroom until its expiration time. +- Mezzanine reservations come only from **H-Lab - 2 - Mezzanine Conference Table (12)**. The access calendar and classroom LiveStatus do not restrict that display; no reservation means the table is available during its displayed day. +- NOAA station **9410230 La Jolla** supplies tide predictions and water temperature. diff --git a/signage/PHASE3_DEPLOYMENT.md b/signage/PHASE3_DEPLOYMENT.md new file mode 100644 index 0000000000..30e31c98b7 --- /dev/null +++ b/signage/PHASE3_DEPLOYMENT.md @@ -0,0 +1,48 @@ +# Phase 3: Raspberry Pi deployment + +The deployment script packages the current local application, transfers it to the Raspberry Pi, installs the required runtime and Chromium, and configures the display to recover automatically after a reboot or application failure. + +## Deploy from the Mac + +Run the matching command from the project folder on the Mac. + +Classroom Pi: + +```sh +sh scripts/deploy-to-pi.sh sandbox@sandbox-signage-classroom.local classroom +``` + +Mezzanine Pi (replace the hostname with the name assigned when that Pi is flashed): + +```sh +sh scripts/deploy-to-pi.sh sandbox@sandbox-signage-mezzanine.local mezzanine +``` + +The second argument writes the correct profile into the packaged `.env`; it does not alter the Mac's local profile. + +The first connection may ask whether to trust the Raspberry Pi's SSH host key. It may also request the `sandbox` account password for SSH and `sudo`. At the end, choose whether to reboot immediately. + +## What the installer configures + +- application files at `/home/sandbox/sandbox-signage` +- the live Google, NOAA, and visibility configuration from `.env` +- a `sandbox-signage.service` system service that restarts after failures +- Chromium kiosk mode through `~/.config/labwc/autostart` +- Chromium's local basic password store, so the unattended kiosk does not wait for a keyring unlock +- a standard desktop-autostart fallback for older Raspberry Pi OS desktops +- desktop autologin and disabled screen blanking through `raspi-config` +- user lingering so Raspberry Pi Connect's remote shell can remain available across reboots +- a timestamped backup of any previous signage installation + +The application remains local to the Raspberry Pi at `http://127.0.0.1:4173/`. It is not exposed to other computers on the network. + +## Update later + +Run the same profile-specific deployment command again. The installer saves the previous version before activating the replacement. + +## Useful checks on the Pi + +```sh +systemctl status sandbox-signage.service +journalctl -u sandbox-signage.service -n 50 --no-pager +``` diff --git a/signage/README.md b/signage/README.md new file mode 100644 index 0000000000..9dafe8fde8 --- /dev/null +++ b/signage/README.md @@ -0,0 +1,69 @@ +# Scripps Sandbox signage + +Returning to this project after a break? Start with [CONTINUE_HERE.md](./CONTINUE_HERE.md). + +This is the shared, data-driven 3840 × 2160 application for the Sandbox classroom and mezzanine conference-table displays. One codebase supports both Raspberry Pis; the `SIGNAGE_DISPLAY` setting selects the room, scheduling rules, copy, and color theme. + +| Profile | Calendar | Empty calendar means | Palette | +| --- | --- | --- | --- | +| `classroom` | H-Lab - 1 - Sandbox Classroom (24) | Closed unless Sandbox Access has a Door Open event | Orange, aqua, cream, black | +| `mezzanine` | H-Lab - 2 - Mezzanine Conference Table (12) | Available | Navy `#182B49`, warm cream `#F5F0E6`, black, white | + +## Run it locally + +From this folder: + +```sh +npm run serve +``` + +Then open [http://localhost:4173](http://localhost:4173). + +Open [http://localhost:4173/?controls=1](http://localhost:4173/?controls=1) to show the scenario picker and time-of-day slider. The picker includes: + +- standard day +- available all day +- multiple bookings +- closing soon +- class in progress +- closed all day +- offline fallback +- mezzanine conference table + +## Test the display logic + +This project has no production dependencies. With Node.js 20 or newer installed: + +```sh +npm test +``` + +## Live-data behavior + +The local server exposes `/api/display-data`. Depending on the selected profile, it combines: + +- **H-Lab - 1 - Sandbox Classroom (24):** authoritative classroom bookings +- **H-Lab - 2 - Mezzanine Conference Table (12):** authoritative conference-table reservations +- **Sandbox Access:** authoritative public-access windows; a day is closed unless it contains a `Door Open` event, and closures or maintenance override those windows +- **Sandbox Summer Access Widget Settings:** timezone, refresh rate, temporary closed/maintenance overrides, and keyword classification +- **NOAA station 9410230:** La Jolla tide predictions and observed water temperature +- **Just Get Wet dive reports:** the latest locally reported underwater visibility; refreshed no more than every 30 minutes and cached if the page is unavailable + +The renderer receives one normalized display object containing: + +- the selected room and timezone +- today's open and close times +- today's classroom bookings +- the next five workday summaries +- water temperature, today's La Jolla tide points, and locally reported underwater visibility +- connectivity, staleness, and last-updated state + +The server caches its last successful normalized response in `cache/display-data.json`. The browser also caches its last successful response in local storage. If either Google or NOAA is unavailable, the screen keeps showing the most recent reliable information and quietly marks it as stale. + +Without a configured Google feed, the server deliberately labels the screen **SETUP FEED · USING SAMPLE CALENDAR DATA**. Scenario previews remain available with `?scenario=standard` and the review controls with `?controls=1&scenario=standard`. + +See [PHASE2_SETUP.md](./PHASE2_SETUP.md) for the one-time Google Apps Script deployment and local configuration. Visibility is an informal third-party observation, so the display attributes it and treats it as supplemental rather than authoritative operating data. + +## Raspberry Pi deployment + +Phase 3 packages either profile as a self-starting Raspberry Pi kiosk. See [PHASE3_DEPLOYMENT.md](./PHASE3_DEPLOYMENT.md). Re-running the same deployment command installs future updates while retaining a timestamped copy of the previous version. diff --git a/signage/apps-script/Code.gs b/signage/apps-script/Code.gs new file mode 100644 index 0000000000..e6d9b23eef --- /dev/null +++ b/signage/apps-script/Code.gs @@ -0,0 +1,162 @@ +const SIGNAGE_CONFIG = Object.freeze({ + spreadsheetId: "1W0lUm4QmL98VgyqOpkUpgVs44Bk0zqAX5aUgIt7U5CQ", + accessCalendarId: "c_fc2079bbb28085c0a4914fa0162ebcb2e296caf2c399b14caa5e58d9e90e24f3@group.calendar.google.com", + lookaheadDays: 9, + displays: Object.freeze({ + classroom: Object.freeze({ + id: "classroom", + mode: "access", + roomCalendarId: "c_dd95fbe62277cbffcd34e78e3eadb8e5a32bb7bc08402ee7cb8bbbf525ad8c32@group.calendar.google.com", + roomName: "H-Lab - 1 - Sandbox Classroom (24)", + }), + mezzanine: Object.freeze({ + id: "mezzanine", + mode: "reservations", + roomCalendarId: "c_70d4tfts95ja4gct1tlphi3qec@group.calendar.google.com", + roomName: "H-Lab - 2 - Mezzanine Conference Table (12)", + }), + }), +}); + +function doGet(e) { + const expectedToken = PropertiesService.getScriptProperties().getProperty("SIGNAGE_FEED_TOKEN"); + if (expectedToken && (!e || !e.parameter || e.parameter.token !== expectedToken)) { + return json_({ error: "Unauthorized" }); + } + + try { + const displayKey = e && e.parameter && e.parameter.display ? e.parameter.display : "classroom"; + const display = SIGNAGE_CONFIG.displays[displayKey]; + if (!display) return json_({ error: "Unknown display profile", display: displayKey }); + return json_(buildSignageFeed_(display)); + } catch (error) { + console.error(error); + return json_({ error: "Feed generation failed", detail: String(error.message || error) }); + } +} + +function buildSignageFeed_(display) { + const spreadsheet = SpreadsheetApp.openById(SIGNAGE_CONFIG.spreadsheetId); + const settings = keyValueSheet_(spreadsheet.getSheetByName("Settings")); + const liveStatus = keyValueSheet_(spreadsheet.getSheetByName("LiveStatus")); + const modes = tableSheet_(spreadsheet.getSheetByName("Modes")); + const keywords = tableSheet_(spreadsheet.getSheetByName("Calendar Keywords")); + const timezone = settings.timezone || "America/Los_Angeles"; + const window = dateWindow_(SIGNAGE_CONFIG.lookaheadDays); + + const roomCalendar = CalendarApp.getCalendarById(display.roomCalendarId); + if (!roomCalendar) throw new Error(display.roomName + " calendar is unavailable"); + + const roomEvents = roomCalendar.getEvents(window.start, window.end).map(function(event) { + return eventRecord_(event, timezone); + }); + let accessEvents = []; + if (display.mode === "access") { + const accessCalendar = CalendarApp.getCalendarById(settings.access_calendar_id || SIGNAGE_CONFIG.accessCalendarId); + if (!accessCalendar) throw new Error("Sandbox Access calendar is unavailable"); + accessEvents = accessCalendar.getEvents(window.start, window.end).map(function(event) { + const record = eventRecord_(event, timezone); + record.mode = classifyAccessEvent_(record.title, keywords, modes); + return record; + }); + } + + return { + version: 1, + generatedAt: isoDateTime_(new Date(), timezone), + displayId: display.id, + displayMode: display.mode, + roomName: display.roomName, + settings: settings, + liveStatus: display.mode === "access" ? normalizeLiveStatus_(liveStatus, timezone) : { mode: "auto", until: "", note: "" }, + roomEvents: roomEvents, + accessEvents: accessEvents, + }; +} + +function keyValueSheet_(sheet) { + if (!sheet) return {}; + const values = sheet.getDataRange().getDisplayValues(); + return values.slice(1).reduce(function(result, row) { + const key = String(row[0] || "").trim(); + if (key) result[key] = row[1]; + return result; + }, {}); +} + +function tableSheet_(sheet) { + if (!sheet) return []; + const values = sheet.getDataRange().getDisplayValues(); + const headers = values[0].map(function(value) { return String(value).trim(); }); + return values.slice(1).filter(function(row) { return row.some(String); }).map(function(row) { + return headers.reduce(function(record, header, index) { + if (header) record[header] = row[index]; + return record; + }, {}); + }); +} + +function classifyAccessEvent_(title, keywords, modes) { + const normalized = String(title || "").toLowerCase(); + const matches = keywords.filter(function(rule) { + return normalized.indexOf(String(rule.event_title_contains || "").toLowerCase()) !== -1; + }).map(function(rule) { + const mode = modes.find(function(item) { return item.mode === rule.mode; }) || {}; + return { mode: rule.mode, priority: Number(mode.priority || 0) }; + }).sort(function(a, b) { return b.priority - a.priority; }); + return matches.length ? matches[0].mode : "auto"; +} + +function eventRecord_(event, timezone) { + return { + id: event.getId(), + title: event.getTitle() || "Reserved", + start: isoDateTime_(event.getStartTime(), timezone), + end: isoDateTime_(event.getEndTime(), timezone), + allDay: event.isAllDayEvent(), + }; +} + +function normalizeLiveStatus_(status, timezone) { + const result = { + mode: status.mode || "auto", + until: "", + note: status.note || "", + updated_at: status.updated_at || "", + }; + if (status.until) { + const parsed = parseUntil_(status.until); + if (!isNaN(parsed.getTime())) result.until = isoDateTime_(parsed, timezone); + } + return result; +} + +function parseUntil_(value) { + const direct = new Date(value); + if (!isNaN(direct.getTime())) return direct; + const todayMatch = String(value).trim().match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s+today$/i); + if (!todayMatch) return new Date(NaN); + let hour = Number(todayMatch[1]) % 12; + if (todayMatch[3].toUpperCase() === "PM") hour += 12; + const date = new Date(); + date.setHours(hour, Number(todayMatch[2] || 0), 0, 0); + return date; +} + +function dateWindow_(days) { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + days); + return { start: start, end: end }; +} + +function isoDateTime_(date, timezone) { + const value = Utilities.formatDate(date, timezone, "yyyy-MM-dd'T'HH:mm:ssZ"); + return value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); +} + +function json_(value) { + return ContentService.createTextOutput(JSON.stringify(value)) + .setMimeType(ContentService.MimeType.JSON); +} diff --git a/signage/apps-script/appsscript.json b/signage/apps-script/appsscript.json new file mode 100644 index 0000000000..882ca10a41 --- /dev/null +++ b/signage/apps-script/appsscript.json @@ -0,0 +1,6 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": {}, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} diff --git a/signage/assets/ucsd-sio-horizontal-black.png b/signage/assets/ucsd-sio-horizontal-black.png new file mode 100644 index 0000000000000000000000000000000000000000..cfe1e137118160c70333788b407201625cc4bf11 GIT binary patch literal 31511 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

SaKRlpldd9QZg`cTl7#NCC}|-$>7pT#LBy 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 Lx~g@`uRr}iB=)rA literal 0 HcmV?d00001 diff --git a/signage/data/sample-google-feed.json b/signage/data/sample-google-feed.json new file mode 100644 index 0000000000..e335f2bf45 --- /dev/null +++ b/signage/data/sample-google-feed.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "generatedAt": "2026-08-10T20:00:00-07:00", + "roomName": "H-Lab - 1 - Sandbox Classroom (24)", + "settings": { + "timezone": "America/Los_Angeles", + "status_cache_seconds": "60", + "display_open_time": "09:00", + "display_close_time": "17:00" + }, + "liveStatus": { + "mode": "auto", + "until": "", + "note": "", + "updated_at": "2026-08-10T19:57:11-07:00" + }, + "roomEvents": [], + "accessEvents": [ + { + "id": "access-2026-08-11", + "title": "Door Open – Independent Access", + "mode": "door_open", + "start": "2026-08-11T09:00:00-07:00", + "end": "2026-08-11T14:00:00-07:00", + "allDay": false + }, + { + "id": "access-2026-08-12", + "title": "Door Open – Independent Access", + "mode": "door_open", + "start": "2026-08-12T09:00:00-07:00", + "end": "2026-08-12T14:00:00-07:00", + "allDay": false + }, + { + "id": "access-2026-08-13", + "title": "Door Open – Independent Access", + "mode": "door_open", + "start": "2026-08-13T09:00:00-07:00", + "end": "2026-08-13T14:00:00-07:00", + "allDay": false + } + ] +} diff --git a/signage/data/sample-mezzanine-feed.json b/signage/data/sample-mezzanine-feed.json new file mode 100644 index 0000000000..65404060b0 --- /dev/null +++ b/signage/data/sample-mezzanine-feed.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "generatedAt": "2026-08-10T20:00:00-07:00", + "displayId": "mezzanine", + "displayMode": "reservations", + "roomName": "H-Lab - 2 - Mezzanine Conference Table (12)", + "settings": { + "timezone": "America/Los_Angeles", + "status_cache_seconds": "60", + "display_open_time": "09:00", + "display_close_time": "17:00" + }, + "liveStatus": { + "mode": "auto", + "until": "", + "note": "" + }, + "roomEvents": [ + { + "id": "mezzanine-2026-08-10-a", + "title": "Plankton Imaging Meeting", + "start": "2026-08-10T11:00:00-07:00", + "end": "2026-08-10T12:30:00-07:00", + "allDay": false + }, + { + "id": "mezzanine-2026-08-10-b", + "title": "Coastal Sensors Project Review", + "start": "2026-08-10T14:00:00-07:00", + "end": "2026-08-10T15:00:00-07:00", + "allDay": false + } + ], + "accessEvents": [] +} diff --git a/signage/deploy/install-pi.sh b/signage/deploy/install-pi.sh new file mode 100755 index 0000000000..71e5e6df05 --- /dev/null +++ b/signage/deploy/install-pi.sh @@ -0,0 +1,131 @@ +#!/bin/sh +set -eu + +ARCHIVE=${1:-} +if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then + echo "Usage: install-pi.sh /path/to/sandbox-signage.tgz" >&2 + exit 1 +fi + +if [ "$(id -u)" -eq 0 ]; then + echo "Run this installer as the signage user, not as root." >&2 + exit 1 +fi + +APP_USER=$(id -un) +APP_HOME=$HOME +APP_DIR="$APP_HOME/sandbox-signage" +NODE_BIN="" + +echo "Preparing Raspberry Pi signage for $APP_USER..." +sudo -v + +sudo apt-get update +sudo apt-get install -y nodejs + +if ! command -v chromium >/dev/null 2>&1 && ! command -v chromium-browser >/dev/null 2>&1; then + if ! sudo apt-get install -y chromium; then + sudo apt-get install -y chromium-browser + fi +fi + +NODE_BIN=$(command -v node) +NODE_MAJOR=$(node -p "Number(process.versions.node.split('.')[0])") +if [ "$NODE_MAJOR" -lt 18 ]; then + echo "Node.js 18 or newer is required; this Raspberry Pi OS release provides Node.js $NODE_MAJOR." >&2 + echo "Update Raspberry Pi OS before installing the signage." >&2 + exit 1 +fi + +STAGE=$(mktemp -d "$APP_HOME/.sandbox-signage-stage.XXXXXX") +trap 'rm -rf "$STAGE"' EXIT HUP INT TERM +tar -xzf "$ARCHIVE" -C "$STAGE" +chmod +x "$STAGE/deploy/launch-kiosk.sh" + +if [ -d "$APP_DIR" ]; then + BACKUP="$APP_HOME/sandbox-signage-backup-$(date +%Y%m%d-%H%M%S)" + mv "$APP_DIR" "$BACKUP" + echo "Previous installation saved at $BACKUP" +fi +mv "$STAGE" "$APP_DIR" +trap - EXIT HUP INT TERM + +SERVICE_FILE=$(mktemp) +cat >"$SERVICE_FILE" <"$AUTOSTART_TMP" +cat >>"$AUTOSTART_TMP" <"$APP_HOME/.config/autostart/sandbox-signage.desktop" </dev/null 2>&1; then + sudo raspi-config nonint do_boot_behaviour B4 + sudo raspi-config nonint do_blanking 1 +fi +if command -v loginctl >/dev/null 2>&1; then + sudo loginctl enable-linger "$APP_USER" +fi + +sudo systemctl daemon-reload +sudo systemctl enable --now sandbox-signage.service +sleep 2 + +if ! sudo systemctl --quiet is-active sandbox-signage.service; then + echo "The signage service did not start. Recent log:" >&2 + sudo journalctl -u sandbox-signage.service -n 30 --no-pager >&2 + exit 1 +fi + +node -e "fetch('http://127.0.0.1:4173/api/health').then(async r => { console.log('Local service:', r.status, await r.text()); process.exit(r.ok ? 0 : 1); }).catch(error => { console.error(error.message); process.exit(1); })" +rm -f "$ARCHIVE" + +echo +echo "Sandbox signage is installed and its data service is running." +echo "The kiosk will open automatically after the desktop starts." +printf "Reboot the Raspberry Pi now? [y/N] " +read -r answer +case "$answer" in + y|Y|yes|YES) + sudo reboot + ;; + *) + echo "Reboot later with: sudo reboot" + ;; +esac diff --git a/signage/deploy/launch-kiosk.sh b/signage/deploy/launch-kiosk.sh new file mode 100755 index 0000000000..564578d4b6 --- /dev/null +++ b/signage/deploy/launch-kiosk.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +exec 9>"/tmp/sandbox-signage-kiosk.lock" +if ! flock -n 9; then + exit 0 +fi + +if command -v chromium >/dev/null 2>&1; then + BROWSER=chromium +elif command -v chromium-browser >/dev/null 2>&1; then + BROWSER=chromium-browser +else + echo "Chromium is not installed" >&2 + exit 1 +fi + +URL="http://127.0.0.1:4173/" +attempt=0 +while [ "$attempt" -lt 60 ]; do + if node -e "fetch('$URL/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then + break + fi + attempt=$((attempt + 1)) + sleep 1 +done + +exec "$BROWSER" \ + --kiosk \ + --noerrdialogs \ + --disable-infobars \ + --no-first-run \ + --disable-session-crashed-bubble \ + --password-store=basic \ + --disable-sync \ + --disable-translate \ + --disable-pinch \ + --overscroll-history-navigation=0 \ + --user-data-dir="$HOME/.config/sandbox-signage-chromium" \ + "$URL" diff --git a/signage/index.html b/signage/index.html new file mode 100644 index 0000000000..96756dd562 --- /dev/null +++ b/signage/index.html @@ -0,0 +1,27 @@ + + + + + + + Scripps Sandbox Signage + + + + + + +

+ + + + diff --git a/signage/package.json b/signage/package.json new file mode 100644 index 0000000000..7de837d0d9 --- /dev/null +++ b/signage/package.json @@ -0,0 +1,11 @@ +{ + "name": "scripps-sandbox-signage", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "serve": "node server.mjs", + "serve:static": "python3 -m http.server 4173" + } +} diff --git a/signage/scripts/deploy-to-pi.sh b/signage/scripts/deploy-to-pi.sh new file mode 100755 index 0000000000..59b7497327 --- /dev/null +++ b/signage/scripts/deploy-to-pi.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +TARGET=${1:-sandbox@sandbox-signage-classroom.local} +PROFILE=${2:-classroom} +case "$PROFILE" in + classroom|mezzanine) ;; + *) echo "Display profile must be 'classroom' or 'mezzanine'." >&2; exit 2 ;; +esac +STAMP=$(date +%Y%m%d-%H%M%S) +REMOTE_ARCHIVE="/tmp/sandbox-signage-$STAMP.tgz" +REMOTE_INSTALLER="/tmp/install-sandbox-signage-$STAMP.sh" +LOCAL_ARCHIVE=$(mktemp -t sandbox-signage.XXXXXX.tgz) +LOCAL_ENV_DIR=$(mktemp -d -t sandbox-signage-env.XXXXXX) + +cleanup() { + rm -f "$LOCAL_ARCHIVE" + rm -rf "$LOCAL_ENV_DIR" +} +trap cleanup EXIT HUP INT TERM + +awk '!/^SIGNAGE_DISPLAY=/' "$PROJECT_ROOT/.env" > "$LOCAL_ENV_DIR/.env" +printf '\nSIGNAGE_DISPLAY=%s\n' "$PROFILE" >> "$LOCAL_ENV_DIR/.env" + +echo "Packaging the $PROFILE signage profile..." +tar -czf "$LOCAL_ARCHIVE" -C "$PROJECT_ROOT" \ + index.html package.json server.mjs \ + assets cache data deploy server src \ + -C "$LOCAL_ENV_DIR" .env + +echo "Uploading to $TARGET..." +scp "$LOCAL_ARCHIVE" "$TARGET:$REMOTE_ARCHIVE" +scp "$PROJECT_ROOT/deploy/install-pi.sh" "$TARGET:$REMOTE_INSTALLER" + +echo "Installing on the Raspberry Pi..." +ssh -t "$TARGET" "sh '$REMOTE_INSTALLER' '$REMOTE_ARCHIVE'; status=\$?; rm -f '$REMOTE_INSTALLER'; exit \$status" diff --git a/signage/server.mjs b/signage/server.mjs new file mode 100644 index 0000000000..b85f5c1840 --- /dev/null +++ b/signage/server.mjs @@ -0,0 +1,74 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDisplayDataService } from "./server/data-service.mjs"; +import { loadEnv } from "./server/env.mjs"; + +const root = fileURLToPath(new URL(".", import.meta.url)); +await loadEnv(join(root, ".env")); +const port = Number(process.env.PORT || 4173); +const dataService = createDisplayDataService({ root }); +const mimeTypes = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", +}; + +function sendJson(response, status, value) { + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(JSON.stringify(value)); +} + +async function serveFile(requestPath, response) { + const relativePath = requestPath === "/" ? "index.html" : requestPath.replace(/^\/+/, ""); + const filePath = resolve(root, normalize(relativePath)); + if (!filePath.startsWith(resolve(root) + "/")) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + + try { + const fileStat = await stat(filePath); + if (!fileStat.isFile()) throw new Error("Not a file"); + response.writeHead(200, { + "Content-Type": mimeTypes[extname(filePath)] || "application/octet-stream", + "Cache-Control": extname(filePath) === ".html" ? "no-cache" : "public, max-age=300", + }); + createReadStream(filePath).pipe(response); + } catch { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + } +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url, `http://${request.headers.host || "localhost"}`); + if (url.pathname === "/api/display-data") { + try { + sendJson(response, 200, await dataService.getDisplayData()); + } catch (error) { + sendJson(response, 503, { error: "Display data unavailable", detail: error.message }); + } + return; + } + if (url.pathname === "/api/health") { + sendJson(response, 200, dataService.getHealth()); + return; + } + await serveFile(url.pathname, response); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Sandbox signage running at http://127.0.0.1:${port}`); +}); diff --git a/signage/server/data-service.mjs b/signage/server/data-service.mjs new file mode 100644 index 0000000000..9dc7cba6eb --- /dev/null +++ b/signage/server/data-service.mjs @@ -0,0 +1,509 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const DEFAULT_TIDE_POINTS = [ + { minute: 0, heightFt: 1.9 }, + { minute: 180, heightFt: 0.2 }, + { minute: 570, heightFt: 4.4 }, + { minute: 810, heightFt: 1.4 }, + { minute: 1170, heightFt: 5.6 }, + { minute: 1440, heightFt: 2.8 }, +]; + +const VISIBILITY_URL = "https://justgetwet.com/blogs/dive-reports-and-conditions"; +const DEFAULT_VISIBILITY = { + value: "—", + source: "Just Get Wet", + sourceUrl: VISIBILITY_URL, + fetchedAt: null, +}; + +function zonedParts(date, timeZone) { + return Object.fromEntries(new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + weekday: "long", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date).filter((part) => part.type !== "literal").map((part) => [part.type, part.value])); +} + +function isoDate(date, timeZone) { + const parts = zonedParts(date, timeZone); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function addDays(dateString, amount) { + const date = new Date(`${dateString}T12:00:00Z`); + date.setUTCDate(date.getUTCDate() + amount); + return date.toISOString().slice(0, 10); +} + +function weekdayFor(dateString) { + return new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }) + .format(new Date(`${dateString}T12:00:00Z`)) + .toUpperCase(); +} + +function minutesFor(date, timeZone) { + const parts = zonedParts(date, timeZone); + return Number(parts.hour) * 60 + Number(parts.minute); +} + +function formatMinutes(minutes) { + const safe = Math.max(0, Math.min(1439, Math.round(minutes))); + return `${String(Math.floor(safe / 60)).padStart(2, "0")}:${String(safe % 60).padStart(2, "0")}`; +} + +function workdayDates(startDate, count = 5) { + const dates = []; + let cursor = startDate; + while (dates.length < count) { + const weekday = weekdayFor(cursor); + if (weekday !== "SAT" && weekday !== "SUN") dates.push(cursor); + cursor = addDays(cursor, 1); + } + return dates; +} + +function classifyRoomEvent(title = "") { + const value = title.toLowerCase(); + if (value.includes("closed")) return "closure"; + if (value.includes("workshop")) return "workshop"; + if (value.includes("class") || /\b[A-Z]{2,5}\s*\d{1,3}[A-Z]?\b/i.test(title)) return "class"; + return "booking"; +} + +function parseSettings(settings = {}) { + return { + timezone: settings.timezone || "America/Los_Angeles", + openTime: settings.display_open_time || "09:00", + closeTime: settings.display_close_time || "17:00", + refreshSeconds: Number(settings.status_cache_seconds || 60), + }; +} + +function clockMinutes(value) { + const [hour, minute] = String(value).split(":").map(Number); + return hour * 60 + minute; +} + +function eventForDay(event, dateString, timeZone, openTime, closeTime) { + const start = new Date(event.start); + const end = new Date(event.end); + const startDate = isoDate(start, timeZone); + const endDate = isoDate(new Date(end.getTime() - 1), timeZone); + if (dateString < startDate || dateString > endDate) return null; + const type = classifyRoomEvent(event.title); + const allDay = Boolean(event.allDay); + return { + id: event.id, + title: event.title || "Reserved", + shortTitle: event.title || "Reserved", + type, + allDay: allDay && type === "closure", + start: allDay || dateString > startDate ? openTime : formatMinutes(minutesFor(start, timeZone)), + end: allDay || dateString < endDate ? closeTime : formatMinutes(minutesFor(end, timeZone)), + }; +} + +function activeAccessClosures(feed, dateString, settings) { + const blockingModes = new Set(["closed", "maintenance"]); + return (feed.accessEvents || []) + .filter((event) => blockingModes.has(event.mode)) + .map((event) => eventForDay({ ...event, title: event.title || event.mode }, dateString, settings.timezone, settings.openTime, settings.closeTime)) + .filter(Boolean) + .map((event) => ({ ...event, type: "closure" })); +} + +function activeAccessOpenings(feed, dateString, settings) { + return (feed.accessEvents || []) + .filter((event) => event.mode === "door_open") + .map((event) => eventForDay(event, dateString, settings.timezone, settings.openTime, settings.closeTime)) + .filter(Boolean) + .sort((a, b) => clockMinutes(a.start) - clockMinutes(b.start)); +} + +function closedOutsideOpenings(openings, dateString, settings) { + if (!openings.length) { + return [{ + id: `default-closed-${dateString}`, + title: "Makerspace closed", + shortTitle: "Closed", + type: "closure", + allDay: true, + start: settings.openTime, + end: settings.closeTime, + }]; + } + + const dayStart = clockMinutes(settings.openTime); + const dayEnd = clockMinutes(settings.closeTime); + const intervals = openings.map((event) => ({ + start: Math.max(dayStart, clockMinutes(event.start)), + end: Math.min(dayEnd, clockMinutes(event.end)), + })).filter((interval) => interval.end > interval.start); + const closures = []; + let cursor = dayStart; + + for (const interval of intervals) { + if (interval.start > cursor) { + closures.push({ + id: `default-closed-${dateString}-${cursor}`, + title: "Makerspace closed", + shortTitle: "Closed", + type: "closure", + allDay: false, + start: formatMinutes(cursor), + end: formatMinutes(interval.start), + }); + } + cursor = Math.max(cursor, interval.end); + } + + if (cursor < dayEnd) { + closures.push({ + id: `default-closed-${dateString}-${cursor}`, + title: "Makerspace closed", + shortTitle: "Closed", + type: "closure", + allDay: false, + start: formatMinutes(cursor), + end: formatMinutes(dayEnd), + }); + } + return closures; +} + +function liveOverrideClosure(feed, now, settings) { + const live = feed.liveStatus || {}; + if (!["closed", "maintenance"].includes(live.mode)) return null; + const until = live.until ? new Date(live.until) : null; + if (until && until <= now) return null; + return { + id: `live-${live.mode}`, + title: live.note || (live.mode === "closed" ? "Classroom closed" : "Maintenance / limited access"), + type: "closure", + start: formatMinutes(minutesFor(now, settings.timezone)), + end: until ? formatMinutes(minutesFor(until, settings.timezone)) : settings.closeTime, + }; +} + +function summarizeWorkday(dateString, roomEvents, accessOpenings, accessClosures, settings) { + const events = roomEvents + .map((event) => eventForDay(event, dateString, settings.timezone, settings.openTime, settings.closeTime)) + .filter(Boolean); + if (!accessOpenings.length || events.some((event) => event.type === "closure")) return "Closed"; + const bookings = events.filter((event) => event.type !== "closure"); + if (bookings.length > 1) return bookings.map((event) => event.title).join("\n"); + if (bookings.length === 1) return bookings[0].title; + if (accessClosures.length) return "Limited access"; + if (accessOpenings.length === 1) return `Open ${accessOpenings[0].start}–${accessOpenings[0].end}`; + return accessOpenings.map((event) => `Open ${event.start}–${event.end}`).join("\n"); +} + +function summarizeReservationDay(dateString, roomEvents, settings) { + const events = roomEvents + .map((event) => eventForDay(event, dateString, settings.timezone, settings.openTime, settings.closeTime)) + .filter(Boolean); + if (events.some((event) => event.type === "closure")) return "Closed"; + const reservations = events.filter((event) => event.type !== "closure"); + if (!reservations.length) return "Available"; + return reservations.map((event) => event.title).join("\n"); +} + +function displayMetadata(feed) { + if (feed.displayMode === "reservations" || feed.displayId === "mezzanine") { + return { + id: "mezzanine-conference-table", + profile: "mezzanine", + mode: "reservations", + roomName: feed.roomName || "H-Lab - 2 - Mezzanine Conference Table (12)", + makerLabel: "SCRIPPS SANDBOX MAKERSPACE", + activityLabel: "mezzanine conference table", + spaceLabel: "conference table", + availableTitle: "conference table available", + }; + } + return { + id: "sandbox-classroom", + profile: "classroom", + mode: "access", + roomName: feed.roomName || "H-Lab - 1 - Sandbox Classroom (24)", + makerLabel: "SCRIPPS SANDBOX MAKERSPACE", + activityLabel: "classroom activity", + spaceLabel: "classroom", + availableTitle: "classroom available", + }; +} + +export function normalizeGoogleFeed(feed, { now = new Date() } = {}) { + const settings = parseSettings(feed.settings); + const display = displayMetadata(feed); + display.timezone = settings.timezone; + const reservationOnly = display.mode === "reservations"; + const today = isoDate(now, settings.timezone); + const parts = zonedParts(now, settings.timezone); + const roomEvents = feed.roomEvents || []; + const todayRoomEvents = roomEvents + .map((event) => eventForDay(event, today, settings.timezone, settings.openTime, settings.closeTime)) + .filter(Boolean); + const todayOpenings = reservationOnly ? [] : activeAccessOpenings(feed, today, settings); + const closures = reservationOnly ? [] : [ + ...closedOutsideOpenings(todayOpenings, today, settings), + ...activeAccessClosures(feed, today, settings), + ]; + const override = reservationOnly ? null : liveOverrideClosure(feed, now, settings); + if (override) closures.push(override); + + const workdays = workdayDates(today).map((dateString) => { + const dayOpenings = reservationOnly ? [] : activeAccessOpenings(feed, dateString, settings); + const dayClosures = reservationOnly ? [] : activeAccessClosures(feed, dateString, settings); + return { + date: dateString, + day: weekdayFor(dateString), + summary: reservationOnly + ? summarizeReservationDay(dateString, roomEvents, settings) + : summarizeWorkday(dateString, roomEvents, dayOpenings, dayClosures, settings), + }; + }); + + return { + display, + date: { + weekday: parts.weekday.toUpperCase(), + day: parts.day, + month: new Intl.DateTimeFormat("en-US", { month: "long", timeZone: settings.timezone }).format(now).toUpperCase(), + iso: today, + }, + nowMinutes: minutesFor(now, settings.timezone), + refreshSeconds: settings.refreshSeconds, + day: { + opensAt: settings.openTime, + closesAt: settings.closeTime, + availableTitle: display.availableTitle, + events: [...todayRoomEvents, ...closures], + }, + workdays, + }; +} + +function parseNoaaTime(value) { + const time = String(value).split(" ")[1] || "00:00"; + const [hour, minute] = time.split(":").map(Number); + return hour * 60 + minute; +} + +export function normalizeNoaaData(predictions, temperature) { + const points = (predictions?.predictions || []) + .map((point) => ({ minute: parseNoaaTime(point.t), heightFt: Number(point.v) })) + .filter((point) => Number.isFinite(point.heightFt)); + const observations = temperature?.data || []; + const latest = observations.at(-1); + return { + waterTempF: latest && Number.isFinite(Number(latest.v)) ? Math.round(Number(latest.v)) : 76, + tide: { + station: "La Jolla 9410230", + points: points.length >= 2 ? points : DEFAULT_TIDE_POINTS, + }, + }; +} + +export function parseVisibilityReport(html) { + const readable = String(html ?? "") + .replace(/&(?:ndash|#8211);/gi, "–") + .replace(/&(?:mdash|#8212);/gi, "—") + .replace(/&(?:nbsp|#160);/gi, " ") + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " "); + const match = readable.match(/\bVis:\s*([0-9]+(?:\s*[–—-]\s*[0-9]+)?\s*ft)\b/i); + if (!match) return null; + return { + value: match[1] + .replace(/\s*[–—-]\s*/g, "–") + .replace(/\s*ft$/i, " ft"), + source: "Just Get Wet", + sourceUrl: VISIBILITY_URL, + }; +} + +function noaaUrl(params) { + const url = new URL("https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + return url; +} + +async function fetchJson(url, fetchImpl, timeoutMs = 12000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { signal: controller.signal, redirect: "follow" }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return await response.json(); + } finally { + clearTimeout(timer); + } +} + +async function fetchText(url, fetchImpl, timeoutMs = 12000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { + signal: controller.signal, + redirect: "follow", + headers: { + Accept: "text/html,application/xhtml+xml", + "User-Agent": "ScrippsSandboxSignage/1.0", + }, + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return await response.text(); + } finally { + clearTimeout(timer); + } +} + +async function fetchNoaa(fetchImpl) { + const common = { + station: "9410230", + time_zone: "lst_ldt", + units: "english", + application: "ScrippsSandboxSignage", + format: "json", + }; + const [predictions, temperature] = await Promise.all([ + fetchJson(noaaUrl({ ...common, date: "today", product: "predictions", datum: "MLLW", interval: "6" }), fetchImpl), + fetchJson(noaaUrl({ ...common, date: "latest", product: "water_temperature" }), fetchImpl), + ]); + if (predictions.error) throw new Error(predictions.error.message || "NOAA prediction error"); + return normalizeNoaaData(predictions, temperature); +} + +async function fetchVisibility(fetchImpl, fetchedAt) { + const parsed = parseVisibilityReport(await fetchText(VISIBILITY_URL, fetchImpl)); + if (!parsed) throw new Error("No visibility observation found"); + return { ...parsed, fetchedAt: fetchedAt.toISOString(), stale: false }; +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function writeJsonAtomic(path, value) { + await mkdir(join(path, ".."), { recursive: true }).catch(() => {}); + const temporary = `${path}.tmp`; + await writeFile(temporary, JSON.stringify(value, null, 2)); + await rename(temporary, path); +} + +export function createDisplayDataService({ root, fetchImpl = fetch, now = () => new Date() }) { + const cachePath = join(root, "cache", "display-data.json"); + const displayProfile = process.env.SIGNAGE_DISPLAY || "classroom"; + const samplePath = join(root, "data", displayProfile === "mezzanine" ? "sample-mezzanine-feed.json" : "sample-google-feed.json"); + const feedUrl = process.env.SIGNAGE_GOOGLE_FEED_URL || ""; + const feedToken = process.env.SIGNAGE_FEED_TOKEN || ""; + const refreshMs = Number(process.env.SIGNAGE_REFRESH_SECONDS || 60) * 1000; + const visibilityRefreshMs = Number(process.env.SIGNAGE_VISIBILITY_REFRESH_MINUTES || 30) * 60 * 1000; + let memory = null; + let fetchedAt = 0; + let visibilityMemory = null; + let visibilityFetchedAt = 0; + let lastError = null; + + async function cachedData() { + if (memory) return memory; + try { return await readJson(cachePath); } catch { return null; } + } + + async function loadGoogleFeed() { + if (!feedUrl) return { feed: await readJson(samplePath), live: false }; + const url = new URL(feedUrl); + if (feedToken) url.searchParams.set("token", feedToken); + url.searchParams.set("display", displayProfile); + return { feed: await fetchJson(url, fetchImpl), live: true }; + } + + async function loadVisibility(previous, currentTime) { + if (visibilityMemory && Date.now() - visibilityFetchedAt < visibilityRefreshMs) { + return { data: visibilityMemory, live: !visibilityMemory.stale }; + } + try { + visibilityMemory = await fetchVisibility(fetchImpl, currentTime); + visibilityFetchedAt = Date.now(); + return { data: visibilityMemory, live: true }; + } catch { + const cached = visibilityMemory || previous?.ocean?.visibility || DEFAULT_VISIBILITY; + visibilityMemory = { ...cached, stale: true }; + visibilityFetchedAt = Date.now(); + return { data: visibilityMemory, live: false }; + } + } + + async function getDisplayData() { + if (memory && Date.now() - fetchedAt < refreshMs) return memory; + const previous = await cachedData(); + const currentTime = now(); + try { + const google = await loadGoogleFeed(); + let ocean; + let noaaLive = true; + try { + ocean = await fetchNoaa(fetchImpl); + } catch { + noaaLive = false; + ocean = previous?.ocean || normalizeNoaaData(null, null); + } + const visibility = await loadVisibility(previous, currentTime); + ocean = { ...ocean, visibility: visibility.data }; + const normalized = normalizeGoogleFeed(google.feed, { now: currentTime }); + const sourcesCurrent = google.live && noaaLive; + memory = { + ...normalized, + ocean, + health: { + online: google.live, + stale: !sourcesCurrent, + lastUpdated: formatMinutes(normalized.nowMinutes), + message: !google.live + ? "SETUP FEED · USING SAMPLE CALENDAR DATA" + : !noaaLive ? "CALENDAR CURRENT · OCEAN DATA CACHED" + : visibility.live ? "DATA CURRENT" : "DATA CURRENT · VISIBILITY CACHED", + sources: { + google: google.live ? "live" : "sample", + noaa: noaaLive ? "live" : "cached", + visibility: visibility.live ? "live" : "cached", + }, + }, + }; + fetchedAt = Date.now(); + lastError = null; + await mkdir(join(root, "cache"), { recursive: true }); + await writeJsonAtomic(cachePath, memory); + return memory; + } catch (error) { + lastError = error; + if (!previous) throw error; + memory = { + ...previous, + nowMinutes: minutesFor(currentTime, previous.display?.timezone || "America/Los_Angeles"), + health: { + ...previous.health, + online: false, + stale: true, + message: `OFFLINE · LAST RELIABLE DATA ${previous.health?.lastUpdated || "UNKNOWN"}`, + }, + }; + fetchedAt = Date.now(); + return memory; + } + } + + return { + getDisplayData, + getHealth: () => ({ ok: !lastError, cached: Boolean(memory), error: lastError?.message || null }), + }; +} diff --git a/signage/server/env.mjs b/signage/server/env.mjs new file mode 100644 index 0000000000..a3c89cdf19 --- /dev/null +++ b/signage/server/env.mjs @@ -0,0 +1,18 @@ +import { readFile } from "node:fs/promises"; + +export async function loadEnv(path) { + let contents; + try { contents = await readFile(path, "utf8"); } catch { return; } + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const separator = trimmed.indexOf("="); + if (separator < 1) continue; + const key = trimmed.slice(0, separator).trim(); + let value = trimmed.slice(separator + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} diff --git a/signage/src/app.js b/signage/src/app.js new file mode 100644 index 0000000000..4905e267db --- /dev/null +++ b/signage/src/app.js @@ -0,0 +1,267 @@ +import { + deriveDisplayState, + formatClockParts, + formatTime, + interpolateTide, + timelinePercent, +} from "./model.js"; +import { scenarios } from "./scenarios.js"; +import { fetchDisplayData } from "./data-client.js"; + +const display = document.querySelector("#display"); +const controls = document.querySelector("#controls"); +const scenarioSelect = document.querySelector("#scenario-select"); +const timeSlider = document.querySelector("#time-slider"); +const timeOutput = document.querySelector("#time-output"); +const query = new URLSearchParams(location.search); + +const requestedScenario = query.get("scenario"); +const useLiveData = !requestedScenario && query.get("controls") !== "1"; +let scenarioKey = requestedScenario in scenarios ? requestedScenario : "standard"; +let currentData = structuredClone(scenarios[scenarioKey].data); +if (query.has("minutes")) currentData.nowMinutes = Number(query.get("minutes")); +let refreshTimer = null; +let clockTimer = null; + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function tideGeometry(points, nowMinutes) { + const width = 490; + const top = 20; + const bottom = 205; + const min = Math.min(...points.map((point) => point.heightFt)) - 0.3; + const max = Math.max(...points.map((point) => point.heightFt)) + 0.3; + const x = (minute) => (minute / 1440) * width; + const y = (height) => bottom - ((height - min) / (max - min)) * (bottom - top); + const coordinates = points.map((point) => [x(point.minute), y(point.heightFt)]); + const line = `M ${coordinates.map(([px, py]) => `${px.toFixed(1)} ${py.toFixed(1)}`).join(" L ")}`; + const fill = `${line} L ${width} ${bottom} L 0 ${bottom} Z`; + const now = interpolateTide(points, nowMinutes); + return { line, fill, currentX: x(nowMinutes), currentY: y(now.heightFt), now }; +} + +function renderTide(data) { + const tide = tideGeometry(data.ocean.tide.points, data.nowMinutes); + const guides = [0, 360, 720, 1080].map((minute) => { + const x = (minute / 1440) * 490; + return ` + ${formatTime(minute).slice(0, 2)}`; + }).join(""); + const extrema = data.ocean.tide.points.slice(1, -1).map((point) => { + const x = (point.minute / 1440) * 490; + const allHeights = data.ocean.tide.points.map((item) => item.heightFt); + const min = Math.min(...allHeights) - 0.3; + const max = Math.max(...allHeights) + 0.3; + const y = 205 - ((point.heightFt - min) / (max - min)) * 185; + return ``; + }).join(""); + + return { + value: `${tide.now.heightFt.toFixed(1)} FT ${tide.now.direction}`, + svg: ` + ${guides} + + + ${extrema} + + + `, + }; +} + +function renderSchedule(state, data) { + const total = state.close - state.open; + return state.segments.map((segment) => { + const width = ((segment.endMinutes - segment.startMinutes) / total) * 100; + const title = segment.kind === "available" ? "available" : segment.shortTitle ?? segment.title; + return `
+

${escapeHtml(title)}

+

${formatTime(segment.startMinutes)}–${formatTime(segment.endMinutes)}

+
`; + }).join(""); +} + +function eventKindLabel(event) { + if (event.type === "workshop") return "Workshop"; + if (event.type === "class") return "Class"; + return "Reservation"; +} + +function displayLabels(data) { + return { + profile: data.display?.profile || "classroom", + maker: data.display?.makerLabel || "SCRIPPS SANDBOX MAKERSPACE", + activity: data.display?.activityLabel || "classroom activity", + space: data.display?.spaceLabel || "classroom", + }; +} + +function renderTimelineTicks(state) { + const ticks = []; + for (let minute = state.open; minute <= state.close; minute += 60) { + ticks.push(`
${formatTime(minute).slice(0, 2)}
`); + } + return ticks.join(""); +} + +function nextUp(state, data) { + const labels = displayLabels(data); + const spaceTitle = labels.space.toUpperCase(); + if (state.currentEvent) { + return { + kicker: "In progress", + title: state.currentEvent.title, + meta: `${eventKindLabel(state.currentEvent)} · until ${formatTime(state.currentEvent.endMinutes)}`, + }; + } + if (state.statusKind === "closed") { + return { kicker: "Today", title: `${spaceTitle} CLOSED`, meta: state.detail }; + } + if (state.statusKind === "closing") { + return { kicker: "Today", title: `CLOSING AT ${formatTime(state.close)}`, meta: `${state.close - currentData.nowMinutes} minutes remaining` }; + } + if (!state.nextEvent) return { kicker: "Today", title: `${spaceTitle} AVAILABLE`, meta: `Open until ${formatTime(state.close)}` }; + return { + kicker: "Next up", + title: state.nextEvent.title, + meta: `${eventKindLabel(state.nextEvent)} · ${formatTime(state.nextEvent.startMinutes)}–${formatTime(state.nextEvent.endMinutes)}`, + }; +} + +function render() { + const state = deriveDisplayState(currentData); + const labels = displayLabels(currentData); + const clock = formatClockParts(currentData.nowMinutes); + const tide = renderTide(currentData); + const next = nextUp(state, currentData); + const nowPosition = timelinePercent(currentData.nowMinutes, state.open, state.close); + const healthText = currentData.health.message || (currentData.health.stale + ? `Showing last reliable data · updated ${currentData.health.lastUpdated}` + : "Data current"); + const healthClass = currentData.health.stale ? "is-stale" : ""; + + display.innerHTML = `
+ + +
+

${escapeHtml(labels.maker)}

+

${escapeHtml(labels.activity)}

+

${state.status}

+

${escapeHtml(state.detail)}

+
+ +
+
+
${renderTimelineTicks(state)}
+
+
${renderSchedule(state, currentData)}
+
+
+ +
+ + +
+ TIDE · ${currentData.ocean.tide.station} · VIS · JUST GET WET + + ${healthText} +
+ +
`; + + timeSlider.value = String(currentData.nowMinutes); + timeOutput.value = formatTime(currentData.nowMinutes); + fitCanvas(); +} + +function fitCanvas() { + const canvas = document.querySelector(".signage-canvas"); + const viewport = document.querySelector(".viewport"); + if (!canvas || !viewport) return; + const scale = Math.min(innerWidth / 3840, innerHeight / 2160); + canvas.style.transform = `scale(${scale})`; + viewport.style.width = `${3840 * scale}px`; + viewport.style.height = `${2160 * scale}px`; +} + +for (const [key, scenario] of Object.entries(scenarios)) { + scenarioSelect.add(new Option(scenario.label, key)); +} +scenarioSelect.value = scenarioKey; +controls.hidden = query.get("controls") !== "1"; + +scenarioSelect.addEventListener("change", () => { + scenarioKey = scenarioSelect.value; + currentData = structuredClone(scenarios[scenarioKey].data); + render(); +}); + +timeSlider.addEventListener("input", () => { + currentData.nowMinutes = Number(timeSlider.value); + render(); +}); + +addEventListener("resize", fitCanvas); + +async function refreshLiveData() { + if (!useLiveData) return; + try { + currentData = await fetchDisplayData(); + render(); + clearTimeout(refreshTimer); + refreshTimer = setTimeout(refreshLiveData, Math.max(15, currentData.refreshSeconds || 60) * 1000); + } catch { + currentData = { + ...currentData, + health: { + ...currentData.health, + online: false, + stale: true, + message: "LOCAL DATA SERVICE UNAVAILABLE", + }, + }; + render(); + } +} + +function tickClock() { + if (useLiveData) { + currentData.nowMinutes = (currentData.nowMinutes + 1) % 1440; + render(); + } +} + +render(); +if (useLiveData) { + refreshLiveData(); + clockTimer = setInterval(tickClock, 60_000); +} diff --git a/signage/src/data-client.js b/signage/src/data-client.js new file mode 100644 index 0000000000..5fe92179e1 --- /dev/null +++ b/signage/src/data-client.js @@ -0,0 +1,38 @@ +const STORAGE_KEY = "sandbox-signage:last-reliable-data"; + +function assertDisplayData(data) { + if (!data || typeof data !== "object") throw new Error("Display data is not an object"); + if (!data.day || !Array.isArray(data.day.events)) throw new Error("Display data has no day schedule"); + if (!Array.isArray(data.workdays)) throw new Error("Display data has no workday overview"); + if (!data.ocean?.tide?.points?.length) throw new Error("Display data has no tide points"); + return data; +} + +export async function fetchDisplayData({ + fetchImpl = fetch, + storage = globalThis.localStorage, + url = "/api/display-data", +} = {}) { + try { + const response = await fetchImpl(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + const data = assertDisplayData(await response.json()); + storage?.setItem(STORAGE_KEY, JSON.stringify(data)); + return data; + } catch (error) { + const cached = storage?.getItem(STORAGE_KEY); + if (!cached) throw error; + const data = assertDisplayData(JSON.parse(cached)); + return { + ...data, + health: { + ...data.health, + online: false, + stale: true, + message: `OFFLINE · LAST RELIABLE DATA ${data.health?.lastUpdated || "UNKNOWN"}`, + }, + }; + } +} + +export { STORAGE_KEY }; diff --git a/signage/src/model.js b/signage/src/model.js new file mode 100644 index 0000000000..c977c69b5a --- /dev/null +++ b/signage/src/model.js @@ -0,0 +1,153 @@ +export function timeToMinutes(value) { + if (typeof value === "number") return value; + const match = /^(\d{1,2}):(\d{2})$/.exec(value ?? ""); + if (!match) throw new Error(`Invalid time: ${value}`); + return Number(match[1]) * 60 + Number(match[2]); +} + +export function formatTime(minutes) { + const normalized = ((Math.round(minutes) % 1440) + 1440) % 1440; + const hours = Math.floor(normalized / 60); + const mins = normalized % 60; + return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; +} + +export function formatClockParts(minutes) { + const [hours, mins] = formatTime(minutes).split(":"); + return { hours, minutes: mins }; +} + +export function buildScheduleSegments(day) { + const open = timeToMinutes(day.opensAt); + const close = timeToMinutes(day.closesAt); + const allDayClosure = day.events.find((event) => event.type === "closure" && event.allDay); + + if (allDayClosure) { + return [{ + ...allDayClosure, + kind: "closed", + startMinutes: open, + endMinutes: close, + }]; + } + + const bookings = day.events + .filter((event) => !event.allDay) + .map((event) => ({ + ...event, + kind: event.type === "closure" ? "closed" : "booked", + startMinutes: Math.max(open, timeToMinutes(event.start)), + endMinutes: Math.min(close, timeToMinutes(event.end)), + })) + .filter((event) => event.endMinutes > event.startMinutes) + .sort((a, b) => a.startMinutes - b.startMinutes || a.endMinutes - b.endMinutes); + + const segments = []; + let cursor = open; + + for (const event of bookings) { + if (event.startMinutes > cursor) { + segments.push({ + kind: "available", + title: day.availableTitle || "available", + startMinutes: cursor, + endMinutes: event.startMinutes, + }); + } + + const startMinutes = Math.max(cursor, event.startMinutes); + if (event.endMinutes > startMinutes) { + segments.push({ ...event, startMinutes }); + cursor = Math.max(cursor, event.endMinutes); + } + } + + if (cursor < close) { + segments.push({ + kind: "available", + title: day.availableTitle || "available", + startMinutes: cursor, + endMinutes: close, + }); + } + + return segments; +} + +export function deriveDisplayState(data, nowMinutes = data.nowMinutes) { + const open = timeToMinutes(data.day.opensAt); + const close = timeToMinutes(data.day.closesAt); + const events = data.day.events.map((event) => ({ + ...event, + startMinutes: event.allDay ? 0 : timeToMinutes(event.start), + endMinutes: event.allDay ? 1440 : timeToMinutes(event.end), + })); + const closure = events.find( + (event) => event.type === "closure" && (event.allDay || (nowMinutes >= event.startMinutes && nowMinutes < event.endMinutes)), + ); + const currentEvent = events.find( + (event) => event.type !== "closure" && nowMinutes >= event.startMinutes && nowMinutes < event.endMinutes, + ); + const nextEvent = events + .filter((event) => event.type !== "closure" && event.startMinutes > nowMinutes) + .sort((a, b) => a.startMinutes - b.startMinutes)[0] ?? null; + + let status = "AVAILABLE"; + let statusKind = "available"; + let detail = nextEvent ? `until ${formatTime(nextEvent.startMinutes)}` : `until ${formatTime(close)}`; + + if (closure) { + status = "CLOSED"; + statusKind = "closed"; + detail = closure.title; + } else if (nowMinutes < open) { + status = "CLOSED"; + statusKind = "closed"; + detail = `opens at ${formatTime(open)}`; + } else if (nowMinutes >= close) { + status = "CLOSED"; + statusKind = "closed"; + detail = "for the day"; + } else if (currentEvent) { + status = "IN USE"; + statusKind = "booked"; + detail = `${currentEvent.title} · until ${formatTime(currentEvent.endMinutes)}`; + } else if (close - nowMinutes <= 30) { + status = "CLOSING SOON"; + statusKind = "closing"; + detail = `${data.display?.spaceLabel || "space"} closes at ${formatTime(close)}`; + } + + return { + open, + close, + status, + statusKind, + detail, + currentEvent, + nextEvent, + segments: buildScheduleSegments(data.day), + }; +} + +export function interpolateTide(points, nowMinutes) { + if (!points?.length) return null; + const sorted = [...points].sort((a, b) => a.minute - b.minute); + if (nowMinutes <= sorted[0].minute) return { heightFt: sorted[0].heightFt, direction: "RISING" }; + if (nowMinutes >= sorted.at(-1).minute) return { heightFt: sorted.at(-1).heightFt, direction: "FALLING" }; + + const nextIndex = sorted.findIndex((point) => point.minute >= nowMinutes); + const before = sorted[nextIndex - 1]; + const after = sorted[nextIndex]; + const progress = (nowMinutes - before.minute) / (after.minute - before.minute); + const heightFt = before.heightFt + (after.heightFt - before.heightFt) * progress; + return { + heightFt, + direction: after.heightFt >= before.heightFt ? "RISING" : "FALLING", + }; +} + +export function timelinePercent(minutes, open, close) { + if (close <= open) return 0; + return Math.max(0, Math.min(100, ((minutes - open) / (close - open)) * 100)); +} diff --git a/signage/src/scenarios.js b/signage/src/scenarios.js new file mode 100644 index 0000000000..85b1b9c954 --- /dev/null +++ b/signage/src/scenarios.js @@ -0,0 +1,153 @@ +const tidePoints = [ + { minute: 0, heightFt: 1.9 }, + { minute: 180, heightFt: 0.2 }, + { minute: 570, heightFt: 4.4 }, + { minute: 810, heightFt: 1.4 }, + { minute: 1170, heightFt: 5.6 }, + { minute: 1440, heightFt: 2.8 }, +]; + +const workdays = [ + { day: "MON", summary: "Free → 14:00" }, + { day: "TUE", summary: "3D printing workshop" }, + { day: "WED", summary: "SIO 176" }, + { day: "THU", summary: "Closed" }, + { day: "FRI", summary: "SIO 176" }, +]; + +const base = { + display: { + id: "sandbox-classroom", + profile: "classroom", + mode: "access", + roomName: "H-Lab - 1 - Sandbox Classroom (24)", + timezone: "America/Los_Angeles", + makerLabel: "SCRIPPS SANDBOX MAKERSPACE", + activityLabel: "classroom activity", + spaceLabel: "classroom", + availableTitle: "classroom available", + }, + date: { weekday: "MONDAY", day: "10", month: "AUGUST", iso: "2026-08-10" }, + nowMinutes: 11 * 60 + 43, + day: { + opensAt: "09:00", + closesAt: "18:00", + availableTitle: "classroom available", + events: [ + { id: "workshop", title: "3D printing workshop", shortTitle: "3D printing workshop", type: "workshop", start: "14:00", end: "16:00" }, + ], + }, + workdays, + ocean: { + waterTempF: 76, + visibility: { + value: "15–20 ft", + source: "Just Get Wet", + sourceUrl: "https://justgetwet.com/blogs/dive-reports-and-conditions", + fetchedAt: "2026-08-10T11:40:00-07:00", + stale: false, + }, + tide: { station: "La Jolla 9410230", points: tidePoints }, + }, + health: { online: true, stale: false, lastUpdated: "11:40" }, +}; + +function clone(value) { + return structuredClone(value); +} + +export const scenarios = { + standard: { + label: "Standard day", + data: clone(base), + }, + allDayFree: { + label: "Available all day", + data: { + ...clone(base), + nowMinutes: 10 * 60 + 18, + day: { opensAt: "09:00", closesAt: "18:00", events: [] }, + workdays: [{ day: "MON", summary: "Available all day" }, ...workdays.slice(1)], + }, + }, + multipleBookings: { + label: "Multiple bookings", + data: { + ...clone(base), + nowMinutes: 13 * 60 + 12, + day: { + opensAt: "09:00", + closesAt: "18:00", + events: [ + { id: "class", title: "SIO 176", shortTitle: "SIO 176", type: "class", start: "09:30", end: "11:00" }, + { id: "workshop", title: "Laser cutter workshop", shortTitle: "laser workshop", type: "workshop", start: "14:00", end: "15:30" }, + { id: "meeting", title: "Project studio", shortTitle: "project studio", type: "class", start: "16:15", end: "17:30" }, + ], + }, + workdays: [{ day: "MON", summary: "SIO 176\nLaser cutter workshop\nProject studio" }, ...workdays.slice(1)], + }, + }, + closingSoon: { + label: "Closing soon", + data: { ...clone(base), nowMinutes: 17 * 60 + 42 }, + }, + currentlyBooked: { + label: "Class in progress", + data: { ...clone(base), nowMinutes: 14 * 60 + 35 }, + }, + closed: { + label: "Closed all day", + data: { + ...clone(base), + nowMinutes: 12 * 60, + day: { + opensAt: "09:00", + closesAt: "18:00", + events: [{ id: "closure", title: "Classroom closed", type: "closure", allDay: true }], + }, + workdays: [{ day: "MON", summary: "Closed" }, ...workdays.slice(1)], + }, + }, + offline: { + label: "Offline fallback", + data: { + ...clone(base), + nowMinutes: 11 * 60 + 43, + health: { online: false, stale: true, lastUpdated: "09:12" }, + }, + }, + mezzanine: { + label: "Mezzanine conference table", + data: { + ...clone(base), + display: { + id: "mezzanine-conference-table", + profile: "mezzanine", + mode: "reservations", + roomName: "H-Lab - 2 - Mezzanine Conference Table (12)", + timezone: "America/Los_Angeles", + makerLabel: "SCRIPPS SANDBOX MAKERSPACE", + activityLabel: "mezzanine conference table", + spaceLabel: "conference table", + availableTitle: "conference table available", + }, + nowMinutes: 10 * 60 + 24, + day: { + opensAt: "09:00", + closesAt: "17:00", + availableTitle: "conference table available", + events: [ + { id: "lab-meeting", title: "Plankton imaging meeting", shortTitle: "Plankton imaging", type: "booking", start: "11:00", end: "12:30" }, + { id: "project-review", title: "Coastal sensors project review", shortTitle: "Project review", type: "booking", start: "14:00", end: "15:00" }, + ], + }, + workdays: [ + { day: "MON", summary: "Plankton imaging meeting\nCoastal sensors project review" }, + { day: "TUE", summary: "Available" }, + { day: "WED", summary: "Student design review" }, + { day: "THU", summary: "Faculty meeting\nInstrument planning" }, + { day: "FRI", summary: "Available" }, + ], + }, + }, +}; diff --git a/signage/src/styles.css b/signage/src/styles.css new file mode 100644 index 0000000000..246ce9595f --- /dev/null +++ b/signage/src/styles.css @@ -0,0 +1,484 @@ +:root { + font-family: "Source Sans 3", Arial, sans-serif; + color: #0b0b0b; + background: #111; + --aqua: #25b8c4; + --aqua-current: #00a6c7; + --orange: #ef4b27; + --cream: #f8f2e3; + --ink: #0b0b0b; +} + +* { box-sizing: border-box; } + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #111; +} + +body { + display: grid; + place-items: center; + cursor: none; +} + +#display { + width: 100vw; + height: 100vh; + display: grid; + place-items: center; +} + +.viewport { + position: relative; + overflow: hidden; + background: var(--aqua); +} + +.signage-canvas { + position: absolute; + inset: 0 auto auto 0; + width: 3840px; + height: 2160px; + overflow: hidden; + transform-origin: top left; + color: var(--ink); + background: var(--aqua); + font-family: "Source Sans 3", Arial, sans-serif; +} + +.signage-canvas.theme-mezzanine { + --aqua: #f5f0e6; + --aqua-current: #182b49; + --orange: #182b49; + --cream: #ffffff; + --ink: #000000; +} + +.environment-rail { + position: absolute; + inset: 0 auto 0 0; + width: 576px; + color: var(--cream); + background: var(--orange); +} + +.clock { + position: absolute; + top: 82px; + left: 40px; + font-family: "Jost", Arial, sans-serif; + font-size: 340px; + font-weight: 400; + line-height: 0.67; + letter-spacing: -28px; + font-variant-numeric: tabular-nums; +} + +.clock span { display: block; } + +.vertical-date { + position: absolute; + top: 223px; + right: 59px; + display: flex; + gap: 15px; + align-items: flex-start; + font-size: 50px; + font-weight: 300; + line-height: 1; + letter-spacing: 4px; + text-transform: uppercase; + white-space: nowrap; +} + +.vertical-date > span { + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.vertical-date b { + font-family: "Jost", Arial, sans-serif; + font-weight: 400; +} + +.condition { + position: absolute; + left: 40px; + width: 490px; +} + +.water-condition { top: 1060px; } +.visibility-condition { top: 1285px; } +.tide-condition { top: 1515px; } + +.condition p { margin: 0; } + +.condition-label { + font-size: 58px; + font-weight: 300; + line-height: 1; + letter-spacing: 1px; + text-transform: lowercase; +} + +.condition-value { + margin-top: 18px !important; + font-family: "Jost", Arial, sans-serif; + font-size: 84px; + font-weight: 500; + line-height: 0.9; + letter-spacing: -2px; + text-transform: uppercase; +} + +.tide-value { + font-size: 58px; + font-weight: 700; + letter-spacing: 1px; + white-space: nowrap; +} + +.visibility-value { + font-size: 72px; + white-space: nowrap; +} + +.mini-tide { + position: absolute; + left: 38px; + bottom: 35px; + width: 490px; + height: 250px; + overflow: visible; +} + +.tide-fill { fill: var(--cream); opacity: 0.38; } +.tide-line { fill: none; stroke: var(--cream); stroke-width: 5; } +.tide-dot { fill: var(--orange); stroke: var(--cream); stroke-width: 5; } +.tide-guide { stroke: var(--cream); stroke-width: 4; } +.tide-time { + fill: var(--cream); + font-family: "Jost", Arial, sans-serif; + font-size: 31px; + font-weight: 500; +} +.tide-current-ring { fill: var(--cream); stroke: var(--aqua-current); stroke-width: 9; } +.tide-current-core { fill: var(--aqua-current); } + +.main-header { + position: absolute; + left: 694px; + top: 91px; + max-width: 2100px; +} + +.main-header p, +.main-header h1 { margin: 0; } + +.maker-label { + font-size: 58px; + font-weight: 300; + line-height: 1; + letter-spacing: 1px; + text-transform: uppercase; +} + +.activity-label { + margin-top: 39px !important; + font-size: 108px; + font-weight: 500; + line-height: 0.9; + letter-spacing: -2px; + text-transform: lowercase; +} + +.availability { + margin-top: 42px !important; + font-family: "Jost", Arial, sans-serif; + font-size: 280px; + font-weight: 500; + line-height: 0.82; + letter-spacing: 5px; + text-transform: uppercase; + white-space: nowrap; +} + +.status-detail { + margin-top: 44px !important; + font-size: 48px; + font-weight: 400; + line-height: 1.1; + text-transform: none; +} + +.status-closing .availability { font-size: 205px; } +.status-closing .main-header::after { + content: ""; + display: block; + width: 360px; + height: 18px; + margin-top: 55px; + background: var(--orange); +} +.status-closed .availability, +.status-booked .availability { font-size: 245px; } +.status-closing .availability, +.status-closed .availability, +.status-booked .availability { font-family: "Source Sans 3", Arial, sans-serif; } + +.timeline { + position: absolute; + left: 652px; + top: 1470px; + width: 2160px; + height: 500px; +} + +.timeline-axis { + position: absolute; + top: 48px; + left: 0; + width: 100%; + height: 8px; + background: var(--ink); +} + +.timeline-ticks { + position: absolute; + top: 34px; + left: 0; + width: 100%; +} + +.timeline-tick { + position: absolute; + top: 0; + width: 7px; + height: 31px; + background: var(--ink); + font-size: 29px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.timeline-tick span { + position: absolute; + top: 48px; + left: -4px; +} + +.timeline-tick:last-child span { transform: translateX(-100%); } + +.current-marker { + position: absolute; + top: 4px; + width: 8px; + height: 205px; + background: var(--ink); + transform: translateX(-4px); +} + +.current-marker::before { + content: ""; + position: absolute; + top: -2px; + left: -18px; + width: 0; + height: 0; + border-right: 22px solid transparent; + border-bottom: 35px solid var(--ink); + border-left: 22px solid transparent; + transform: rotate(180deg); +} + +.schedule-track { + position: absolute; + left: 0; + top: 226px; + width: 100%; + height: 144px; + display: flex; + overflow: hidden; +} + +.schedule-block { + min-width: 0; + padding: 21px 17px; + overflow: hidden; + border-right: 4px solid var(--aqua); +} + +.schedule-available { color: var(--ink); background: var(--cream); } +.schedule-booked { color: var(--cream); background: var(--ink); } +.schedule-closed { color: var(--cream); background: var(--ink); } + +.schedule-title, +.schedule-time { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-title { + font-size: 39px; + font-weight: 700; + line-height: 1; +} + +.schedule-time { + margin-top: 13px; + font-size: 25px; + font-weight: 300; + line-height: 1; +} + +.schedule-now-tick { + position: absolute; + top: 384px; + width: 8px; + height: 72px; + background: var(--ink); + transform: translateX(-4px); +} + +.right-divider { + position: absolute; + top: 87px; + bottom: 46px; + left: 2883px; + width: 11px; + background: var(--ink); +} + +.right-column { + position: absolute; + top: 87px; + bottom: 60px; + left: 2940px; + width: 810px; +} + +.right-column p, +.right-column h2 { margin: 0; } + +.next-kicker { + font-size: 38px; + font-weight: 400; + line-height: 1; +} + +.next-title { + max-width: 760px; + margin-top: 42px !important; + font-size: 72px; + font-weight: 700; + line-height: 1.04; + letter-spacing: 1px; + text-transform: uppercase; +} + +.next-meta { + margin-top: 20px !important; + font-size: 42px; + font-weight: 300; + line-height: 1; +} + +.week { + margin-top: 110px; + border-top: 10px solid var(--ink); +} + +.day-row { + min-height: 108px; + padding: 25px 8px 20px 0; + display: grid; + grid-template-columns: 165px 1fr; + gap: 24px; + align-items: baseline; + border-bottom: 8px solid var(--ink); +} + +.day-name { + font-size: 43px; + font-weight: 500; + line-height: 1; +} + +.day-event { + font-size: 36px; + font-weight: 400; + line-height: 1.12; + text-align: right; + white-space: pre-line; +} + +.footer-status { + position: absolute; + bottom: 58px; + left: 620px; + display: flex; + gap: 20px; + align-items: center; + max-width: 2150px; + font-size: 32px; + font-weight: 500; + line-height: 1; + letter-spacing: 1px; + text-transform: uppercase; +} + +.health-dot { + width: 18px; + height: 18px; + flex: 0 0 auto; + border-radius: 50%; + background: #176b54; +} + +.footer-status.is-stale .health-dot { background: var(--orange); } +.footer-status.is-stale { font-weight: 700; } + +.official-logo { + position: absolute; + right: 92px; + bottom: 44px; + width: 690px; + height: auto; + display: block; + mix-blend-mode: multiply; +} + +.controls { + position: fixed; + z-index: 10; + top: 16px; + right: 16px; + width: 260px; + padding: 14px; + color: white; + background: rgb(0 0 0 / 82%); + border-radius: 8px; + box-shadow: 0 6px 20px rgb(0 0 0 / 24%); + font: 14px/1.4 system-ui, sans-serif; +} + +.controls label { display: grid; gap: 6px; margin-bottom: 10px; } +.controls label:last-child { margin-bottom: 0; } +.controls select, +.controls input { width: 100%; } + +@media (prefers-reduced-motion: no-preference) { + .status-closing .main-header::after { animation: closing-pulse 4s ease-in-out infinite; } +} + +@keyframes closing-pulse { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 1; } +} diff --git a/signage/tests/data-client.test.mjs b/signage/tests/data-client.test.mjs new file mode 100644 index 0000000000..4d756a80b0 --- /dev/null +++ b/signage/tests/data-client.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fetchDisplayData, STORAGE_KEY } from "../src/data-client.js"; + +function validData() { + return { + day: { events: [] }, + workdays: [], + ocean: { tide: { points: [{ minute: 0, heightFt: 1 }] } }, + health: { stale: false, lastUpdated: "12:00" }, + }; +} + +function memoryStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }; +} + +test("live display data is saved as the browser's last reliable copy", async () => { + const storage = memoryStorage(); + const data = await fetchDisplayData({ + storage, + fetchImpl: async () => ({ ok: true, json: async () => validData() }), + }); + assert.equal(data.health.stale, false); + assert.ok(storage.getItem(STORAGE_KEY)); +}); + +test("browser falls back to its reliable copy when the local service is unreachable", async () => { + const storage = memoryStorage(); + storage.setItem(STORAGE_KEY, JSON.stringify(validData())); + const data = await fetchDisplayData({ + storage, + fetchImpl: async () => { throw new Error("offline"); }, + }); + assert.equal(data.health.stale, true); + assert.match(data.health.message, /OFFLINE/); +}); diff --git a/signage/tests/data-service.test.mjs b/signage/tests/data-service.test.mjs new file mode 100644 index 0000000000..6e4fee8360 --- /dev/null +++ b/signage/tests/data-service.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeGoogleFeed, normalizeNoaaData, parseVisibilityReport } from "../server/data-service.mjs"; + +const now = new Date("2026-08-10T13:15:00-07:00"); + +test("Google feed becomes the display's room schedule and workday summary", () => { + const data = normalizeGoogleFeed({ + roomName: "H-Lab - 1 - Sandbox Classroom (24)", + settings: { timezone: "America/Los_Angeles", display_open_time: "09:00", display_close_time: "18:00" }, + liveStatus: { mode: "auto" }, + roomEvents: [ + { id: "one", title: "SIO 176", start: "2026-08-10T14:00:00-07:00", end: "2026-08-10T16:00:00-07:00", allDay: false }, + { id: "two", title: "3D Printing Workshop", start: "2026-08-11T10:00:00-07:00", end: "2026-08-11T11:30:00-07:00", allDay: false }, + ], + accessEvents: [ + { id: "open-mon", title: "Door Open", mode: "door_open", start: "2026-08-10T09:00:00-07:00", end: "2026-08-10T17:00:00-07:00" }, + { id: "open-tue", title: "Door Open", mode: "door_open", start: "2026-08-11T09:00:00-07:00", end: "2026-08-11T17:00:00-07:00" }, + ], + }, { now }); + + assert.equal(data.nowMinutes, 13 * 60 + 15); + assert.equal(data.day.events[0].title, "SIO 176"); + assert.equal(data.day.events[0].type, "class"); + assert.equal(data.workdays[0].summary, "SIO 176"); + assert.equal(data.workdays[1].summary, "3D Printing Workshop"); +}); + +test("closed and maintenance access events block the classroom but door-open events do not", () => { + const data = normalizeGoogleFeed({ + settings: { timezone: "America/Los_Angeles" }, + liveStatus: { mode: "auto" }, + roomEvents: [], + accessEvents: [ + { id: "open", title: "Door Open", mode: "door_open", start: "2026-08-10T09:00:00-07:00", end: "2026-08-10T14:00:00-07:00" }, + { id: "maintenance", title: "Maintenance", mode: "maintenance", start: "2026-08-10T15:00:00-07:00", end: "2026-08-10T16:00:00-07:00" }, + ], + }, { now }); + + const maintenance = data.day.events.find((event) => event.id === "maintenance"); + assert.equal(maintenance.type, "closure"); + assert.equal(maintenance.start, "15:00"); + assert.ok(data.day.events.some((event) => event.start === "14:00" && event.end === "17:00")); +}); + +test("room calendar closures appear as closed in the five-workday summary", () => { + const data = normalizeGoogleFeed({ + settings: { timezone: "America/Los_Angeles", display_open_time: "09:00", display_close_time: "18:00" }, + liveStatus: { mode: "auto" }, + roomEvents: [ + { id: "mon-closed", title: "Makerspace Closed", start: "2026-08-10T08:00:00-07:00", end: "2026-08-10T17:00:00-07:00", allDay: false }, + { id: "fri-closed", title: "Makerspace Closed", start: "2026-08-14T08:00:00-07:00", end: "2026-08-14T17:00:00-07:00", allDay: false }, + ], + accessEvents: [], + }, { now }); + + assert.equal(data.workdays[0].summary, "Closed"); + assert.equal(data.workdays[4].summary, "Closed"); +}); + +test("empty access days are closed and door-open events define summer hours", () => { + const data = normalizeGoogleFeed({ + settings: { timezone: "America/Los_Angeles", display_open_time: "09:00", display_close_time: "17:00" }, + liveStatus: { mode: "auto" }, + roomEvents: [], + accessEvents: [ + { id: "open-tue", title: "Door Open – Independent Access", mode: "door_open", start: "2026-08-11T09:00:00-07:00", end: "2026-08-11T14:00:00-07:00" }, + { id: "open-wed", title: "Door Open – Independent Access", mode: "door_open", start: "2026-08-12T09:00:00-07:00", end: "2026-08-12T14:00:00-07:00" }, + { id: "open-thu", title: "Door Open – Independent Access", mode: "door_open", start: "2026-08-13T09:00:00-07:00", end: "2026-08-13T14:00:00-07:00" }, + ], + }, { now }); + + assert.equal(data.day.events[0].allDay, true); + assert.equal(data.workdays[0].summary, "Closed"); + assert.equal(data.workdays[1].summary, "Open 09:00–14:00"); + assert.equal(data.workdays[4].summary, "Closed"); +}); + +test("multiple bookings and open windows are listed on separate lines", () => { + const data = normalizeGoogleFeed({ + settings: { timezone: "America/Los_Angeles", display_open_time: "09:00", display_close_time: "17:00" }, + liveStatus: { mode: "auto" }, + roomEvents: [ + { id: "class", title: "SIO 176", start: "2026-08-10T10:00:00-07:00", end: "2026-08-10T11:00:00-07:00", allDay: false }, + { id: "workshop", title: "3D Printing Workshop", start: "2026-08-10T14:00:00-07:00", end: "2026-08-10T15:30:00-07:00", allDay: false }, + ], + accessEvents: [ + { id: "open-mon", title: "Door Open", mode: "door_open", start: "2026-08-10T09:00:00-07:00", end: "2026-08-10T17:00:00-07:00" }, + { id: "open-tue-am", title: "Door Open", mode: "door_open", start: "2026-08-11T09:00:00-07:00", end: "2026-08-11T11:00:00-07:00" }, + { id: "open-tue-pm", title: "Door Open", mode: "door_open", start: "2026-08-11T13:00:00-07:00", end: "2026-08-11T16:00:00-07:00" }, + ], + }, { now }); + + assert.equal(data.workdays[0].summary, "SIO 176\n3D Printing Workshop"); + assert.equal(data.workdays[1].summary, "Open 09:00–11:00\nOpen 13:00–16:00"); +}); + +test("mezzanine reservation profile treats an empty calendar as available", () => { + const data = normalizeGoogleFeed({ + displayId: "mezzanine", + displayMode: "reservations", + roomName: "H-Lab - 2 - Mezzanine Conference Table (12)", + settings: { timezone: "America/Los_Angeles", display_open_time: "09:00", display_close_time: "17:00" }, + roomEvents: [], + accessEvents: [], + }, { now }); + + assert.equal(data.display.profile, "mezzanine"); + assert.equal(data.display.activityLabel, "mezzanine conference table"); + assert.equal(data.day.events.length, 0); + assert.equal(data.day.availableTitle, "conference table available"); + assert.equal(data.workdays[0].summary, "Available"); +}); + +test("mezzanine reservation profile lists every reservation on separate lines", () => { + const data = normalizeGoogleFeed({ + displayId: "mezzanine", + displayMode: "reservations", + settings: { timezone: "America/Los_Angeles", display_open_time: "09:00", display_close_time: "17:00" }, + roomEvents: [ + { id: "one", title: "Plankton Imaging Meeting", start: "2026-08-10T10:00:00-07:00", end: "2026-08-10T11:00:00-07:00", allDay: false }, + { id: "two", title: "Coastal Sensors Review", start: "2026-08-10T14:00:00-07:00", end: "2026-08-10T15:00:00-07:00", allDay: false }, + ], + accessEvents: [], + }, { now }); + + assert.equal(data.workdays[0].summary, "Plankton Imaging Meeting\nCoastal Sensors Review"); + assert.deepEqual(data.day.events.map((event) => event.type), ["booking", "booking"]); +}); + +test("active live closed override becomes a timed closure", () => { + const data = normalizeGoogleFeed({ + settings: { timezone: "America/Los_Angeles" }, + liveStatus: { mode: "closed", until: "2026-08-10T15:00:00-07:00", note: "Staff meeting" }, + roomEvents: [], + accessEvents: [ + { id: "open", title: "Door Open", mode: "door_open", start: "2026-08-10T09:00:00-07:00", end: "2026-08-10T17:00:00-07:00" }, + ], + }, { now }); + + assert.deepEqual(data.day.events.find((event) => event.id === "live-closed"), { + id: "live-closed", + title: "Staff meeting", + type: "closure", + start: "13:15", + end: "15:00", + }); +}); + +test("NOAA predictions and temperature normalize to the display model", () => { + const data = normalizeNoaaData({ predictions: [ + { t: "2026-08-10 00:00", v: "1.2" }, + { t: "2026-08-10 06:00", v: "5.4" }, + ] }, { data: [{ t: "2026-08-10 13:00", v: "69.8" }] }); + + assert.equal(data.waterTempF, 70); + assert.deepEqual(data.tide.points[1], { minute: 360, heightFt: 5.4 }); +}); + +test("visibility parser extracts a single or ranged observation", () => { + const ranged = parseVisibilityReport("

Vis: 15-20 ft Swell: 1 ft

"); + const single = parseVisibilityReport("

Vis: 8 ft Water Temp: 70F

"); + + assert.equal(ranged.value, "15–20 ft"); + assert.equal(single.value, "8 ft"); + assert.equal(parseVisibilityReport("No observation today"), null); +}); + +test("visibility parser accepts encoded en dashes and uses the first report", () => { + const parsed = parseVisibilityReport("

Vis: 12–18 ft

Vis: 5 ft

"); + assert.equal(parsed.value, "12–18 ft"); +}); diff --git a/signage/tests/model.test.mjs b/signage/tests/model.test.mjs new file mode 100644 index 0000000000..11873722dd --- /dev/null +++ b/signage/tests/model.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildScheduleSegments, + deriveDisplayState, + formatTime, + interpolateTide, + timeToMinutes, +} from "../src/model.js"; +import { scenarios } from "../src/scenarios.js"; + +test("time conversion stays in 24-hour display format", () => { + assert.equal(timeToMinutes("14:35"), 875); + assert.equal(formatTime(875), "14:35"); +}); + +test("schedule gaps become explicit available segments", () => { + const segments = buildScheduleSegments(scenarios.standard.data.day); + assert.deepEqual( + segments.map(({ kind, startMinutes, endMinutes }) => ({ kind, startMinutes, endMinutes })), + [ + { kind: "available", startMinutes: 540, endMinutes: 840 }, + { kind: "booked", startMinutes: 840, endMinutes: 960 }, + { kind: "available", startMinutes: 960, endMinutes: 1080 }, + ], + ); +}); + +test("schedule gaps use the display profile's available label", () => { + const segments = buildScheduleSegments({ + opensAt: "09:00", + closesAt: "17:00", + availableTitle: "conference table available", + events: [], + }); + assert.equal(segments[0].title, "conference table available"); +}); + +test("current booking takes priority over available status", () => { + const data = scenarios.standard.data; + const state = deriveDisplayState(data, 14 * 60 + 30); + assert.equal(state.status, "IN USE"); + assert.equal(state.currentEvent.title, "3D printing workshop"); +}); + +test("free room changes to closing soon within thirty minutes", () => { + const state = deriveDisplayState(scenarios.closingSoon.data); + assert.equal(state.status, "CLOSING SOON"); + assert.equal(state.statusKind, "closing"); +}); + +test("all-day closure overrides schedule hours", () => { + const state = deriveDisplayState(scenarios.closed.data); + assert.equal(state.status, "CLOSED"); + assert.equal(state.detail, "Classroom closed"); + assert.equal(state.segments.length, 1); + assert.equal(state.segments[0].kind, "closed"); +}); + +test("a timed closure becomes a closed segment between available periods", () => { + const segments = buildScheduleSegments({ + opensAt: "09:00", + closesAt: "18:00", + events: [{ title: "Maintenance", type: "closure", start: "12:00", end: "13:00" }], + }); + assert.deepEqual(segments.map((segment) => segment.kind), ["available", "closed", "available"]); +}); + +test("tide interpolation reports height and direction", () => { + const result = interpolateTide([ + { minute: 0, heightFt: 1 }, + { minute: 60, heightFt: 3 }, + ], 30); + assert.equal(result.heightFt, 2); + assert.equal(result.direction, "RISING"); +}); From 381dc4537fe692e12a51a68ec81dab3c0667b358 Mon Sep 17 00:00:00 2001 From: Scripps Sandbox Makerspace Date: Mon, 10 Aug 2026 15:31:50 -0700 Subject: [PATCH 2/3] Document canonical signage repository --- signage/CONTINUE_HERE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/signage/CONTINUE_HERE.md b/signage/CONTINUE_HERE.md index 19807362dd..6bbc330e96 100644 --- a/signage/CONTINUE_HERE.md +++ b/signage/CONTINUE_HERE.md @@ -4,6 +4,8 @@ This file is the durable handoff for continuing the project without the original ## Project identity +- Canonical repository: `https://github.com/ScriptsSandbox/sandbox-appsscript/tree/main/signage` +- Repository folder: `signage/` - Local source folder: `/Users/ritterratter/Documents/Codex/2026-08-06/referenced-chatgpt-conversation-this-is-an` - Displays: Sandbox Classroom and Mezzanine Conference Table signage, 3840 × 2160, 16:9 - Classroom Raspberry Pi: `sandbox@sandbox-signage-classroom.local`, profile `classroom` @@ -112,6 +114,6 @@ On networks that block `.local` discovery or local SSH, use the Raspberry Pi Con - `src/styles.css`: fixed-canvas layout and visual system - `tests/`: executable behavior specifications -## Backup requirement +## Source and configuration backup -Back up this entire folder somewhere other than this Mac. The `.env` file is hidden and excluded from Git, but contains the deployed feed configuration required by a new installation. A private Git repository is appropriate for the source, with `.env` backed up separately in a secure location. +The application source is maintained in the `signage/` directory of `ScriptsSandbox/sandbox-appsscript`. The `.env` file is deliberately excluded from Git because it contains the deployed feed configuration required by a new installation. Back up `.env` separately in a secure location; do not commit it. From e5be33b285cae04f643430bfe0f369980a5d5c23 Mon Sep 17 00:00:00 2001 From: Riley Meehan Date: Mon, 10 Aug 2026 17:09:59 -0700 Subject: [PATCH 3/3] Add access-aware opening and closing states --- signage/CONTINUE_HERE.md | 3 +- signage/PHASE2_SETUP.md | 4 +- signage/README.md | 4 +- signage/apps-script/Code.gs | 24 +++++----- signage/data/sample-google-feed.json | 2 +- signage/data/sample-mezzanine-feed.json | 29 +++++++++++- signage/server/data-service.mjs | 22 ++++----- signage/src/app.js | 20 +++++++-- signage/src/model.js | 60 ++++++++++++++++++++++--- signage/src/scenarios.js | 44 +++++++++++++++++- signage/src/styles.css | 30 +++++-------- signage/tests/data-service.test.mjs | 27 +++++++++-- signage/tests/model.test.mjs | 36 ++++++++++++++- 13 files changed, 239 insertions(+), 66 deletions(-) diff --git a/signage/CONTINUE_HERE.md b/signage/CONTINUE_HERE.md index 6bbc330e96..e12c5b2c39 100644 --- a/signage/CONTINUE_HERE.md +++ b/signage/CONTINUE_HERE.md @@ -46,7 +46,8 @@ Then describe the desired change. The previous chat is not required. - `Sandbox Access` events titled/classified as `door_open` define public access windows. - An empty access-calendar day is closed. - Time outside a `door_open` event is closed. -- The mezzanine ignores Sandbox Access; an empty reservation day means available. +- The mezzanine is available only during a Sandbox Access `door_open` window; an empty access-calendar day is closed on both displays. +- Both displays show a 20-minute opening countdown and a 15-minute closing countdown based on the actual access window. Closed and pre-opening states are monochrome. - Access events classified as closed or maintenance override open windows. - Google Apps Script provides a display-safe public feed; deployment details are in `PHASE2_SETUP.md`. - NOAA station 9410230 provides La Jolla tide predictions and water temperature. diff --git a/signage/PHASE2_SETUP.md b/signage/PHASE2_SETUP.md index 259513a86f..e6de226681 100644 --- a/signage/PHASE2_SETUP.md +++ b/signage/PHASE2_SETUP.md @@ -46,6 +46,6 @@ The health endpoint at [http://127.0.0.1:4173/api/health](http://127.0.0.1:4173/ - Sandbox Access `door_open` events define the hours when the space is open. Empty days and time outside those events are closed. - Sandbox Access `closed` and `maintenance` events override open windows and block the classroom timeline. - Sandbox Access `pickup_only` and `poster_pickup` events do not create general classroom availability. -- An active `closed` or `maintenance` LiveStatus override temporarily blocks the classroom until its expiration time. -- Mezzanine reservations come only from **H-Lab - 2 - Mezzanine Conference Table (12)**. The access calendar and classroom LiveStatus do not restrict that display; no reservation means the table is available during its displayed day. +- An active `closed` or `maintenance` LiveStatus override temporarily blocks both displays until its expiration time. +- Mezzanine reservations come only from **H-Lab - 2 - Mezzanine Conference Table (12)**. The table is available only within a Sandbox Access `door_open` window; outside those windows it is closed with the rest of the Makerspace. - NOAA station **9410230 La Jolla** supplies tide predictions and water temperature. diff --git a/signage/README.md b/signage/README.md index 9dafe8fde8..3219cebd9a 100644 --- a/signage/README.md +++ b/signage/README.md @@ -7,7 +7,7 @@ This is the shared, data-driven 3840 × 2160 application for the Sandbox classro | Profile | Calendar | Empty calendar means | Palette | | --- | --- | --- | --- | | `classroom` | H-Lab - 1 - Sandbox Classroom (24) | Closed unless Sandbox Access has a Door Open event | Orange, aqua, cream, black | -| `mezzanine` | H-Lab - 2 - Mezzanine Conference Table (12) | Available | Navy `#182B49`, warm cream `#F5F0E6`, black, white | +| `mezzanine` | H-Lab - 2 - Mezzanine Conference Table (12) | Closed unless Sandbox Access has a Door Open event | Navy `#182B49`, warm cream `#F5F0E6`, black, white | ## Run it locally @@ -58,6 +58,8 @@ The renderer receives one normalized display object containing: - water temperature, today's La Jolla tide points, and locally reported underwater visibility - connectivity, staleness, and last-updated state +Both displays become monochrome while the Makerspace is closed. During the last 20 minutes before an access window they show an opening countdown; during the last 15 minutes of an access window they show a closing countdown. These transitions use the actual Sandbox Access event times, so temporary summer, finals, and holiday hours require no application change. + The server caches its last successful normalized response in `cache/display-data.json`. The browser also caches its last successful response in local storage. If either Google or NOAA is unavailable, the screen keeps showing the most recent reliable information and quietly marks it as stale. Without a configured Google feed, the server deliberately labels the screen **SETUP FEED · USING SAMPLE CALENDAR DATA**. Scenario previews remain available with `?scenario=standard` and the review controls with `?controls=1&scenario=standard`. diff --git a/signage/apps-script/Code.gs b/signage/apps-script/Code.gs index e6d9b23eef..3c19660f1d 100644 --- a/signage/apps-script/Code.gs +++ b/signage/apps-script/Code.gs @@ -50,25 +50,25 @@ function buildSignageFeed_(display) { const roomEvents = roomCalendar.getEvents(window.start, window.end).map(function(event) { return eventRecord_(event, timezone); }); - let accessEvents = []; - if (display.mode === "access") { - const accessCalendar = CalendarApp.getCalendarById(settings.access_calendar_id || SIGNAGE_CONFIG.accessCalendarId); - if (!accessCalendar) throw new Error("Sandbox Access calendar is unavailable"); - accessEvents = accessCalendar.getEvents(window.start, window.end).map(function(event) { - const record = eventRecord_(event, timezone); - record.mode = classifyAccessEvent_(record.title, keywords, modes); - return record; - }); - } + // Both displays are inside the Makerspace. The access calendar therefore + // defines when either room may be shown as available, while each room's own + // calendar remains authoritative for its bookings. + const accessCalendar = CalendarApp.getCalendarById(settings.access_calendar_id || SIGNAGE_CONFIG.accessCalendarId); + if (!accessCalendar) throw new Error("Sandbox Access calendar is unavailable"); + const accessEvents = accessCalendar.getEvents(window.start, window.end).map(function(event) { + const record = eventRecord_(event, timezone); + record.mode = classifyAccessEvent_(record.title, keywords, modes); + return record; + }); return { - version: 1, + version: 2, generatedAt: isoDateTime_(new Date(), timezone), displayId: display.id, displayMode: display.mode, roomName: display.roomName, settings: settings, - liveStatus: display.mode === "access" ? normalizeLiveStatus_(liveStatus, timezone) : { mode: "auto", until: "", note: "" }, + liveStatus: normalizeLiveStatus_(liveStatus, timezone), roomEvents: roomEvents, accessEvents: accessEvents, }; diff --git a/signage/data/sample-google-feed.json b/signage/data/sample-google-feed.json index e335f2bf45..0b331561b0 100644 --- a/signage/data/sample-google-feed.json +++ b/signage/data/sample-google-feed.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "generatedAt": "2026-08-10T20:00:00-07:00", "roomName": "H-Lab - 1 - Sandbox Classroom (24)", "settings": { diff --git a/signage/data/sample-mezzanine-feed.json b/signage/data/sample-mezzanine-feed.json index 65404060b0..7ffd823097 100644 --- a/signage/data/sample-mezzanine-feed.json +++ b/signage/data/sample-mezzanine-feed.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "generatedAt": "2026-08-10T20:00:00-07:00", "displayId": "mezzanine", "displayMode": "reservations", @@ -31,5 +31,30 @@ "allDay": false } ], - "accessEvents": [] + "accessEvents": [ + { + "id": "access-2026-08-11", + "title": "Door Open – Independent Access", + "mode": "door_open", + "start": "2026-08-11T09:00:00-07:00", + "end": "2026-08-11T14:00:00-07:00", + "allDay": false + }, + { + "id": "access-2026-08-12", + "title": "Door Open – Independent Access", + "mode": "door_open", + "start": "2026-08-12T09:00:00-07:00", + "end": "2026-08-12T14:00:00-07:00", + "allDay": false + }, + { + "id": "access-2026-08-13", + "title": "Door Open – Independent Access", + "mode": "door_open", + "start": "2026-08-13T09:00:00-07:00", + "end": "2026-08-13T14:00:00-07:00", + "allDay": false + } + ] } diff --git a/signage/server/data-service.mjs b/signage/server/data-service.mjs index 9dc7cba6eb..54dbe67ab0 100644 --- a/signage/server/data-service.mjs +++ b/signage/server/data-service.mjs @@ -205,13 +205,13 @@ function summarizeWorkday(dateString, roomEvents, accessOpenings, accessClosures return accessOpenings.map((event) => `Open ${event.start}–${event.end}`).join("\n"); } -function summarizeReservationDay(dateString, roomEvents, settings) { +function summarizeReservationDay(dateString, roomEvents, accessOpenings, accessClosures, settings) { const events = roomEvents .map((event) => eventForDay(event, dateString, settings.timezone, settings.openTime, settings.closeTime)) .filter(Boolean); - if (events.some((event) => event.type === "closure")) return "Closed"; + if (!accessOpenings.length || events.some((event) => event.type === "closure")) return "Closed"; const reservations = events.filter((event) => event.type !== "closure"); - if (!reservations.length) return "Available"; + if (!reservations.length) return accessClosures.length ? "Limited access" : "Available"; return reservations.map((event) => event.title).join("\n"); } @@ -244,29 +244,28 @@ export function normalizeGoogleFeed(feed, { now = new Date() } = {}) { const settings = parseSettings(feed.settings); const display = displayMetadata(feed); display.timezone = settings.timezone; - const reservationOnly = display.mode === "reservations"; const today = isoDate(now, settings.timezone); const parts = zonedParts(now, settings.timezone); const roomEvents = feed.roomEvents || []; const todayRoomEvents = roomEvents .map((event) => eventForDay(event, today, settings.timezone, settings.openTime, settings.closeTime)) .filter(Boolean); - const todayOpenings = reservationOnly ? [] : activeAccessOpenings(feed, today, settings); - const closures = reservationOnly ? [] : [ + const todayOpenings = activeAccessOpenings(feed, today, settings); + const closures = [ ...closedOutsideOpenings(todayOpenings, today, settings), ...activeAccessClosures(feed, today, settings), ]; - const override = reservationOnly ? null : liveOverrideClosure(feed, now, settings); + const override = liveOverrideClosure(feed, now, settings); if (override) closures.push(override); const workdays = workdayDates(today).map((dateString) => { - const dayOpenings = reservationOnly ? [] : activeAccessOpenings(feed, dateString, settings); - const dayClosures = reservationOnly ? [] : activeAccessClosures(feed, dateString, settings); + const dayOpenings = activeAccessOpenings(feed, dateString, settings); + const dayClosures = activeAccessClosures(feed, dateString, settings); return { date: dateString, day: weekdayFor(dateString), - summary: reservationOnly - ? summarizeReservationDay(dateString, roomEvents, settings) + summary: display.mode === "reservations" + ? summarizeReservationDay(dateString, roomEvents, dayOpenings, dayClosures, settings) : summarizeWorkday(dateString, roomEvents, dayOpenings, dayClosures, settings), }; }); @@ -284,6 +283,7 @@ export function normalizeGoogleFeed(feed, { now = new Date() } = {}) { day: { opensAt: settings.openTime, closesAt: settings.closeTime, + accessWindows: todayOpenings.map((event) => ({ start: event.start, end: event.end })), availableTitle: display.availableTitle, events: [...todayRoomEvents, ...closures], }, diff --git a/signage/src/app.js b/signage/src/app.js index 4905e267db..2b864bb071 100644 --- a/signage/src/app.js +++ b/signage/src/app.js @@ -1,6 +1,7 @@ import { deriveDisplayState, formatClockParts, + formatFriendlyTime, formatTime, interpolateTide, timelinePercent, @@ -113,6 +114,20 @@ function renderTimelineTicks(state) { function nextUp(state, data) { const labels = displayLabels(data); const spaceTitle = labels.space.toUpperCase(); + if (state.statusKind === "opening") { + return { + kicker: "Today", + title: `OPENING AT ${formatFriendlyTime(state.nextAccessWindow.startMinutes)}`, + meta: `${state.nextAccessWindow.startMinutes - currentData.nowMinutes} minutes remaining`, + }; + } + if (state.statusKind === "closing") { + return { + kicker: "Today", + title: `CLOSING AT ${formatFriendlyTime(state.currentAccessWindow.endMinutes)}`, + meta: `${state.currentAccessWindow.endMinutes - currentData.nowMinutes} minutes remaining`, + }; + } if (state.currentEvent) { return { kicker: "In progress", @@ -123,9 +138,6 @@ function nextUp(state, data) { if (state.statusKind === "closed") { return { kicker: "Today", title: `${spaceTitle} CLOSED`, meta: state.detail }; } - if (state.statusKind === "closing") { - return { kicker: "Today", title: `CLOSING AT ${formatTime(state.close)}`, meta: `${state.close - currentData.nowMinutes} minutes remaining` }; - } if (!state.nextEvent) return { kicker: "Today", title: `${spaceTitle} AVAILABLE`, meta: `Open until ${formatTime(state.close)}` }; return { kicker: "Next up", @@ -168,7 +180,7 @@ function render() {

${escapeHtml(labels.maker)}

${escapeHtml(labels.activity)}

-

${state.status}

+

${escapeHtml(state.status)}

${escapeHtml(state.detail)}

diff --git a/signage/src/model.js b/signage/src/model.js index c977c69b5a..a07cf2021d 100644 --- a/signage/src/model.js +++ b/signage/src/model.js @@ -17,6 +17,15 @@ export function formatClockParts(minutes) { return { hours, minutes: mins }; } +export function formatFriendlyTime(minutes) { + const normalized = ((Math.round(minutes) % 1440) + 1440) % 1440; + const hour24 = Math.floor(normalized / 60); + const mins = normalized % 60; + const suffix = hour24 < 12 ? "AM" : "PM"; + const hour12 = hour24 % 12 || 12; + return `${hour12}:${String(mins).padStart(2, "0")} ${suffix}`; +} + export function buildScheduleSegments(day) { const open = timeToMinutes(day.opensAt); const close = timeToMinutes(day.closesAt); @@ -91,12 +100,41 @@ export function deriveDisplayState(data, nowMinutes = data.nowMinutes) { const nextEvent = events .filter((event) => event.type !== "closure" && event.startMinutes > nowMinutes) .sort((a, b) => a.startMinutes - b.startMinutes)[0] ?? null; + const accessWindows = (Array.isArray(data.day.accessWindows) + ? data.day.accessWindows + : [{ start: data.day.opensAt, end: data.day.closesAt }]) + .map((window) => ({ + startMinutes: timeToMinutes(window.start), + endMinutes: timeToMinutes(window.end), + })) + .filter((window) => window.endMinutes > window.startMinutes) + .sort((a, b) => a.startMinutes - b.startMinutes); + const currentAccessWindow = accessWindows.find( + (window) => nowMinutes >= window.startMinutes && nowMinutes < window.endMinutes, + ) ?? null; + const nextAccessWindow = accessWindows.find((window) => window.startMinutes > nowMinutes) ?? null; + const blockingClosure = closure && !String(closure.id || "").startsWith("default-closed-") + ? closure + : null; + const nextOpeningBlocked = nextAccessWindow && events.some((event) => ( + event.type === "closure" + && !String(event.id || "").startsWith("default-closed-") + && (event.allDay || (event.startMinutes <= nextAccessWindow.startMinutes && event.endMinutes > nextAccessWindow.startMinutes)) + )); + const minutesUntilOpening = nextAccessWindow ? Math.ceil(nextAccessWindow.startMinutes - nowMinutes) : null; + const minutesUntilClosing = currentAccessWindow ? Math.ceil(currentAccessWindow.endMinutes - nowMinutes) : null; let status = "AVAILABLE"; let statusKind = "available"; - let detail = nextEvent ? `until ${formatTime(nextEvent.startMinutes)}` : `until ${formatTime(close)}`; - - if (closure) { + let detail = currentAccessWindow && nextEvent && nextEvent.startMinutes < currentAccessWindow.endMinutes + ? `until ${formatTime(nextEvent.startMinutes)}` + : `until ${formatTime(currentAccessWindow?.endMinutes ?? close)}`; + + if (!blockingClosure && !currentAccessWindow && !nextOpeningBlocked && minutesUntilOpening > 0 && minutesUntilOpening <= 20) { + status = `OPENING IN ${minutesUntilOpening} MIN`; + statusKind = "opening"; + detail = `Makerspace opens at ${formatFriendlyTime(nextAccessWindow.startMinutes)}.`; + } else if (closure) { status = "CLOSED"; statusKind = "closed"; detail = closure.title; @@ -108,14 +146,20 @@ export function deriveDisplayState(data, nowMinutes = data.nowMinutes) { status = "CLOSED"; statusKind = "closed"; detail = "for the day"; + } else if (!currentAccessWindow) { + status = "CLOSED"; + statusKind = "closed"; + detail = nextAccessWindow + ? `Makerspace opens at ${formatFriendlyTime(nextAccessWindow.startMinutes)}.` + : "Makerspace closed."; + } else if (minutesUntilClosing > 0 && minutesUntilClosing <= 15) { + status = `CLOSING IN ${minutesUntilClosing} MIN`; + statusKind = "closing"; + detail = `Makerspace closes at ${formatFriendlyTime(currentAccessWindow.endMinutes)}.`; } else if (currentEvent) { status = "IN USE"; statusKind = "booked"; detail = `${currentEvent.title} · until ${formatTime(currentEvent.endMinutes)}`; - } else if (close - nowMinutes <= 30) { - status = "CLOSING SOON"; - statusKind = "closing"; - detail = `${data.display?.spaceLabel || "space"} closes at ${formatTime(close)}`; } return { @@ -126,6 +170,8 @@ export function deriveDisplayState(data, nowMinutes = data.nowMinutes) { detail, currentEvent, nextEvent, + currentAccessWindow, + nextAccessWindow, segments: buildScheduleSegments(data.day), }; } diff --git a/signage/src/scenarios.js b/signage/src/scenarios.js index 85b1b9c954..822d84e099 100644 --- a/signage/src/scenarios.js +++ b/signage/src/scenarios.js @@ -32,6 +32,7 @@ const base = { day: { opensAt: "09:00", closesAt: "18:00", + accessWindows: [{ start: "09:00", end: "18:00" }], availableTitle: "classroom available", events: [ { id: "workshop", title: "3D printing workshop", shortTitle: "3D printing workshop", type: "workshop", start: "14:00", end: "16:00" }, @@ -66,7 +67,7 @@ export const scenarios = { data: { ...clone(base), nowMinutes: 10 * 60 + 18, - day: { opensAt: "09:00", closesAt: "18:00", events: [] }, + day: { opensAt: "09:00", closesAt: "18:00", accessWindows: [{ start: "09:00", end: "18:00" }], events: [] }, workdays: [{ day: "MON", summary: "Available all day" }, ...workdays.slice(1)], }, }, @@ -78,6 +79,7 @@ export const scenarios = { day: { opensAt: "09:00", closesAt: "18:00", + accessWindows: [{ start: "09:00", end: "18:00" }], events: [ { id: "class", title: "SIO 176", shortTitle: "SIO 176", type: "class", start: "09:30", end: "11:00" }, { id: "workshop", title: "Laser cutter workshop", shortTitle: "laser workshop", type: "workshop", start: "14:00", end: "15:30" }, @@ -89,7 +91,11 @@ export const scenarios = { }, closingSoon: { label: "Closing soon", - data: { ...clone(base), nowMinutes: 17 * 60 + 42 }, + data: { ...clone(base), nowMinutes: 17 * 60 + 50 }, + }, + openingSoon: { + label: "Opening soon", + data: { ...clone(base), nowMinutes: 8 * 60 + 48 }, }, currentlyBooked: { label: "Class in progress", @@ -103,6 +109,7 @@ export const scenarios = { day: { opensAt: "09:00", closesAt: "18:00", + accessWindows: [], events: [{ id: "closure", title: "Classroom closed", type: "closure", allDay: true }], }, workdays: [{ day: "MON", summary: "Closed" }, ...workdays.slice(1)], @@ -135,6 +142,7 @@ export const scenarios = { day: { opensAt: "09:00", closesAt: "17:00", + accessWindows: [{ start: "09:00", end: "17:00" }], availableTitle: "conference table available", events: [ { id: "lab-meeting", title: "Plankton imaging meeting", shortTitle: "Plankton imaging", type: "booking", start: "11:00", end: "12:30" }, @@ -150,4 +158,36 @@ export const scenarios = { ], }, }, + mezzanineClosed: { + label: "Mezzanine · Makerspace closed", + data: { + ...clone(base), + display: { + id: "mezzanine-conference-table", + profile: "mezzanine", + mode: "reservations", + roomName: "H-Lab - 2 - Mezzanine Conference Table (12)", + timezone: "America/Los_Angeles", + makerLabel: "SCRIPPS SANDBOX MAKERSPACE", + activityLabel: "mezzanine conference table", + spaceLabel: "conference table", + availableTitle: "conference table available", + }, + nowMinutes: 12 * 60, + day: { + opensAt: "09:00", + closesAt: "17:00", + accessWindows: [], + availableTitle: "conference table available", + events: [{ id: "default-closed-mezzanine", title: "Makerspace closed", type: "closure", allDay: true }], + }, + workdays: [ + { day: "MON", summary: "Closed" }, + { day: "TUE", summary: "Available" }, + { day: "WED", summary: "Student design review" }, + { day: "THU", summary: "Faculty meeting\nInstrument planning" }, + { day: "FRI", summary: "Closed" }, + ], + }, + }, }; diff --git a/signage/src/styles.css b/signage/src/styles.css index 246ce9595f..fddfe860b0 100644 --- a/signage/src/styles.css +++ b/signage/src/styles.css @@ -59,6 +59,15 @@ body { --ink: #000000; } +.signage-canvas.status-closed, +.signage-canvas.status-opening { + --aqua: #d8d8d4; + --aqua-current: #555555; + --orange: #454545; + --cream: #f5f5f0; + --ink: #0b0b0b; +} + .environment-rail { position: absolute; inset: 0 auto 0 0; @@ -216,17 +225,11 @@ body { text-transform: none; } -.status-closing .availability { font-size: 205px; } -.status-closing .main-header::after { - content: ""; - display: block; - width: 360px; - height: 18px; - margin-top: 55px; - background: var(--orange); -} +.status-opening .availability, +.status-closing .availability { font-size: 168px; } .status-closed .availability, .status-booked .availability { font-size: 245px; } +.status-opening .availability, .status-closing .availability, .status-closed .availability, .status-booked .availability { font-family: "Source Sans 3", Arial, sans-serif; } @@ -473,12 +476,3 @@ body { .controls label:last-child { margin-bottom: 0; } .controls select, .controls input { width: 100%; } - -@media (prefers-reduced-motion: no-preference) { - .status-closing .main-header::after { animation: closing-pulse 4s ease-in-out infinite; } -} - -@keyframes closing-pulse { - 0%, 100% { opacity: 0.45; } - 50% { opacity: 1; } -} diff --git a/signage/tests/data-service.test.mjs b/signage/tests/data-service.test.mjs index 6e4fee8360..9a0d3e24d0 100644 --- a/signage/tests/data-service.test.mjs +++ b/signage/tests/data-service.test.mjs @@ -95,7 +95,7 @@ test("multiple bookings and open windows are listed on separate lines", () => { assert.equal(data.workdays[1].summary, "Open 09:00–11:00\nOpen 13:00–16:00"); }); -test("mezzanine reservation profile treats an empty calendar as available", () => { +test("mezzanine is closed when the access calendar has no opening", () => { const data = normalizeGoogleFeed({ displayId: "mezzanine", displayMode: "reservations", @@ -107,8 +107,27 @@ test("mezzanine reservation profile treats an empty calendar as available", () = assert.equal(data.display.profile, "mezzanine"); assert.equal(data.display.activityLabel, "mezzanine conference table"); - assert.equal(data.day.events.length, 0); + assert.equal(data.day.events.length, 1); + assert.equal(data.day.events[0].type, "closure"); + assert.deepEqual(data.day.accessWindows, []); assert.equal(data.day.availableTitle, "conference table available"); + assert.equal(data.workdays[0].summary, "Closed"); +}); + +test("mezzanine is available within a Makerspace access window", () => { + const data = normalizeGoogleFeed({ + displayId: "mezzanine", + displayMode: "reservations", + roomName: "H-Lab - 2 - Mezzanine Conference Table (12)", + settings: { timezone: "America/Los_Angeles", display_open_time: "09:00", display_close_time: "17:00" }, + roomEvents: [], + accessEvents: [ + { id: "open", title: "Door Open", mode: "door_open", start: "2026-08-10T09:00:00-07:00", end: "2026-08-10T17:00:00-07:00" }, + ], + }, { now }); + + assert.deepEqual(data.day.accessWindows, [{ start: "09:00", end: "17:00" }]); + assert.equal(data.day.events.length, 0); assert.equal(data.workdays[0].summary, "Available"); }); @@ -121,7 +140,9 @@ test("mezzanine reservation profile lists every reservation on separate lines", { id: "one", title: "Plankton Imaging Meeting", start: "2026-08-10T10:00:00-07:00", end: "2026-08-10T11:00:00-07:00", allDay: false }, { id: "two", title: "Coastal Sensors Review", start: "2026-08-10T14:00:00-07:00", end: "2026-08-10T15:00:00-07:00", allDay: false }, ], - accessEvents: [], + accessEvents: [ + { id: "open", title: "Door Open", mode: "door_open", start: "2026-08-10T09:00:00-07:00", end: "2026-08-10T17:00:00-07:00" }, + ], }, { now }); assert.equal(data.workdays[0].summary, "Plankton Imaging Meeting\nCoastal Sensors Review"); diff --git a/signage/tests/model.test.mjs b/signage/tests/model.test.mjs index 11873722dd..bd23dc4030 100644 --- a/signage/tests/model.test.mjs +++ b/signage/tests/model.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildScheduleSegments, deriveDisplayState, + formatFriendlyTime, formatTime, interpolateTide, timeToMinutes, @@ -12,6 +13,7 @@ import { scenarios } from "../src/scenarios.js"; test("time conversion stays in 24-hour display format", () => { assert.equal(timeToMinutes("14:35"), 875); assert.equal(formatTime(875), "14:35"); + assert.equal(formatFriendlyTime(17 * 60), "5:00 PM"); }); test("schedule gaps become explicit available segments", () => { @@ -43,10 +45,40 @@ test("current booking takes priority over available status", () => { assert.equal(state.currentEvent.title, "3D printing workshop"); }); -test("free room changes to closing soon within thirty minutes", () => { +test("both displays show an exact closing countdown in the final fifteen minutes", () => { const state = deriveDisplayState(scenarios.closingSoon.data); - assert.equal(state.status, "CLOSING SOON"); + assert.equal(state.status, "CLOSING IN 10 MIN"); assert.equal(state.statusKind, "closing"); + assert.equal(state.detail, "Makerspace closes at 6:00 PM."); +}); + +test("pre-opening countdown begins twenty minutes before the access window", () => { + const data = scenarios.openingSoon.data; + const state = deriveDisplayState(data); + assert.equal(state.status, "OPENING IN 12 MIN"); + assert.equal(state.statusKind, "opening"); + assert.equal(state.detail, "Makerspace opens at 9:00 AM."); +}); + +test("screen stays ordinarily closed before the twenty-minute opening reminder", () => { + const state = deriveDisplayState(scenarios.openingSoon.data, 8 * 60 + 39); + assert.equal(state.status, "CLOSED"); + assert.equal(state.statusKind, "closed"); +}); + +test("closing countdown takes priority over a reservation still in progress", () => { + const data = structuredClone(scenarios.mezzanine.data); + data.day.events.push({ id: "late", title: "Lab group", type: "booking", start: "16:30", end: "17:00" }); + const state = deriveDisplayState(data, 16 * 60 + 50); + assert.equal(state.status, "CLOSING IN 10 MIN"); + assert.equal(state.currentEvent.title, "Lab group"); + assert.equal(state.detail, "Makerspace closes at 5:00 PM."); +}); + +test("an empty normalized access-window list means the Makerspace is closed", () => { + const state = deriveDisplayState(scenarios.mezzanineClosed.data); + assert.equal(state.status, "CLOSED"); + assert.equal(state.statusKind, "closed"); }); test("all-day closure overrides schedule hours", () => {