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..e12c5b2c39 --- /dev/null +++ b/signage/CONTINUE_HERE.md @@ -0,0 +1,120 @@ +# Continue work on the Scripps Sandbox signage + +This file is the durable handoff for continuing the project without the original Codex conversation. + +## 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` +- 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 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. +- 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 + +## Source and configuration backup + +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. diff --git a/signage/PHASE2_SETUP.md b/signage/PHASE2_SETUP.md new file mode 100644 index 0000000000..e6de226681 --- /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 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/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..3219cebd9a --- /dev/null +++ b/signage/README.md @@ -0,0 +1,71 @@ +# 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) | Closed unless Sandbox Access has a Door Open event | 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 + +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`. + +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..3c19660f1d --- /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); + }); + // 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: 2, + generatedAt: isoDateTime_(new Date(), timezone), + displayId: display.id, + displayMode: display.mode, + roomName: display.roomName, + settings: settings, + liveStatus: normalizeLiveStatus_(liveStatus, timezone), + 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 0000000000..cfe1e13711 Binary files /dev/null and b/signage/assets/ucsd-sio-horizontal-black.png differ diff --git a/signage/data/sample-google-feed.json b/signage/data/sample-google-feed.json new file mode 100644 index 0000000000..0b331561b0 --- /dev/null +++ b/signage/data/sample-google-feed.json @@ -0,0 +1,44 @@ +{ + "version": 2, + "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..7ffd823097 --- /dev/null +++ b/signage/data/sample-mezzanine-feed.json @@ -0,0 +1,60 @@ +{ + "version": 2, + "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": [ + { + "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/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..54dbe67ab0 --- /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, 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 reservations = events.filter((event) => event.type !== "closure"); + if (!reservations.length) return accessClosures.length ? "Limited access" : "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 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 = activeAccessOpenings(feed, today, settings); + const closures = [ + ...closedOutsideOpenings(todayOpenings, today, settings), + ...activeAccessClosures(feed, today, settings), + ]; + const override = liveOverrideClosure(feed, now, settings); + if (override) closures.push(override); + + const workdays = workdayDates(today).map((dateString) => { + const dayOpenings = activeAccessOpenings(feed, dateString, settings); + const dayClosures = activeAccessClosures(feed, dateString, settings); + return { + date: dateString, + day: weekdayFor(dateString), + summary: display.mode === "reservations" + ? summarizeReservationDay(dateString, roomEvents, dayOpenings, dayClosures, 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, + accessWindows: todayOpenings.map((event) => ({ start: event.start, end: event.end })), + 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..2b864bb071 --- /dev/null +++ b/signage/src/app.js @@ -0,0 +1,279 @@ +import { + deriveDisplayState, + formatClockParts, + formatFriendlyTime, + 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.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", + 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.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)}

+

${escapeHtml(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..a07cf2021d --- /dev/null +++ b/signage/src/model.js @@ -0,0 +1,199 @@ +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 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); + 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; + 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 = 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; + } 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 (!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)}`; + } + + return { + open, + close, + status, + statusKind, + detail, + currentEvent, + nextEvent, + currentAccessWindow, + nextAccessWindow, + 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..822d84e099 --- /dev/null +++ b/signage/src/scenarios.js @@ -0,0 +1,193 @@ +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", + 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" }, + ], + }, + 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", accessWindows: [{ start: "09:00", end: "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", + 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" }, + { 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 + 50 }, + }, + openingSoon: { + label: "Opening soon", + data: { ...clone(base), nowMinutes: 8 * 60 + 48 }, + }, + 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", + accessWindows: [], + 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", + 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" }, + { 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" }, + ], + }, + }, + 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 new file mode 100644 index 0000000000..fddfe860b0 --- /dev/null +++ b/signage/src/styles.css @@ -0,0 +1,478 @@ +: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; +} + +.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; + 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-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; } + +.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%; } 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..9a0d3e24d0 --- /dev/null +++ b/signage/tests/data-service.test.mjs @@ -0,0 +1,193 @@ +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 is closed when the access calendar has no opening", () => { + 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, 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"); +}); + +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: [ + { 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"); + 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..bd23dc4030 --- /dev/null +++ b/signage/tests/model.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildScheduleSegments, + deriveDisplayState, + formatFriendlyTime, + 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"); + assert.equal(formatFriendlyTime(17 * 60), "5:00 PM"); +}); + +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("both displays show an exact closing countdown in the final fifteen minutes", () => { + const state = deriveDisplayState(scenarios.closingSoon.data); + 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", () => { + 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"); +});