Overview
- Deployed via Wrangler CLI to Cloudflare Workers
- No build step; worker and static assets are deployed as-is
- Custom domain:
pub.ihnyc-rc.org - Production deploys are triggered automatically by Cloudflare Workers CI/CD on every push to
main
Sources: wrangler.toml, README.md
Prerequisites
- Node.js v18+
- Wrangler CLI
npm install -g wrangler
wrangler loginSecrets and Vars
Required for read paths
Set once per environment; stored encrypted by Cloudflare.
wrangler secret put NOTION_API_KEYOptional for QR sync
wrangler secret put NOTION_QR_SYNC_API_KEY
wrangler secret put SHORT_IO_API_KEY
wrangler secret put TENDER_QR_SYNC_TOKENNon-secret vars in wrangler.toml
NOTION_DATABASE_IDNOTION_SHIFTS_DATABASE_IDNOTION_ANNOUNCEMENTS_DATABASE_IDNOTION_PUB_TENDERS_DATABASE_IDPUBLIC_SITE_URLSHORT_IO_DOMAINTV_BUILD_ID— bump on every deploy to trigger TV self-reloadTV_DEBUG_MODEIMAGE_R2_REFRESH_TTL_SECONDS
Removed vars
TV_HEALTH_EXPECTED_COUNTandTV_HEALTH_EXPECTED_LABELSwere removed. Expected screens are now managed via the admin dashboard config panel, stored in R2 attv-health/config.json. The env vars remain as a fallback only if no labels have been saved yet.
Optional R2 binding for mirrored images
[[r2_buckets]]
binding = "IMAGE_MIRROR_BUCKET"
bucket_name = "ihnyc-avi-pub-images"- The bucket is used as a lazy-populated mirror for Notion-backed announcement, tender, and tender-QR images served through
/api/image/* - Objects are written into R2 on first request; deploy does not bulk-import images
- If the binding is absent,
/api/image/*still works by proxying Notion file URLs directly
Local Development
wrangler devServes the worker and static assets at http://localhost:8787.
Useful local URLs:
http://localhost:8787/http://localhost:8787/tv/http://localhost:8787/announcements/http://localhost:8787/tenders/http://localhost:8787/api/eventshttp://localhost:8787/api/tendershttp://localhost:8787/tv/
Notion access in dev
If
NOTION_API_KEYis not set locally,/api/eventsreturns placeholder data and/api/tendersreturns an empty placeholder directory.
Deploy
wrangler deployPublishes worker.js as the Worker and uploads ./public as static assets to Cloudflare.
Custom Domain
The site is served at pub.ihnyc-rc.org. This requires ihnyc-rc.org DNS to be managed by Cloudflare.
Option A - Cloudflare Dashboard
- Workers & Pages →
ihnyc-avi-pub-landing→ Settings → Domains & Routes - Add → enter
pub.ihnyc-rc.org
Option B - wrangler.toml
routes = [
{ pattern = "pub.ihnyc-rc.org/*", zone_name = "ihnyc-rc.org" }
]Then redeploy with wrangler deploy.
Monitoring
| Signal | Where to look |
|---|---|
| Worker errors | Cloudflare Dashboard → Workers & Pages → ihnyc-avi-pub-landing → Logs |
/api/events health | Hit /api/events and confirm "source": "notion" plus non-empty live sections |
/api/tenders health | Hit /api/tenders and confirm active directory rows are returned |
/api/image/* health | Hit a live proxied image route and confirm 200 image/* |
| QR sync health | Call POST /api/sync-tender-qrs?dryRun=1 with admin auth and review the JSON summary |
| TV response freshness | Footer countdown on /tv/ shows Refreshing in M:SS |
| TV client state | Set TV_DEBUG_MODE = "1" and load /tv/ to inspect fetch state, active slide, viewport, user agent, and image load/error status |
| TV health dashboard | https://pub.ihnyc-rc.org/admin/tv-health (Cloudflare Access required) — live heartbeat, build match, memory, network, JS errors per screen |
| Cache behavior | Cloudflare Dashboard → Caching → Cache Analytics |
| Frontend state | Load /, /tv/, /announcements/, and /tenders/ after deploy |
Refresh and Cache Behavior
| Surface | Refresh behavior |
|---|---|
| Cloudflare edge cache | 2-minute TTL for /api/events and /api/tenders |
/api/image/* | Same-origin image routes backed by edge cache and optionally R2 mirroring |
/tv/ HTML | Returned with no-store headers |
/tv/ | Soft refreshes /api/events every 2 minutes; slideshow and featured-event state are anchored to the server’s lastUpdated timestamp |
/announcements/ | Re-fetches /api/events every 2 minutes |
/ | Loads hours and announcement count once per page load |
/tenders/ | Loads /api/tenders once on page load |
| TV build check | Polls /api/tv-build every minute and reloads only when the deployed TV build changes |
| TV hard reload fallback | 1-hour meta refresh safety net if JavaScript hangs completely |
Important distinction:
- Purging
/api/eventsdoes not purge/api/tenders - TV image URLs are intentionally short same-origin
/api/image/...routes; if images disappear, test those endpoints directly before assuming the page data is wrong - With
IMAGE_MIRROR_BUCKETconfigured,/api/image/*populates the R2 mirror lazily on first request and refreshes mirrored objects afterIMAGE_R2_REFRESH_TTL_SECONDS - Frontend file changes to
/tv/,script.js, or CSS do not apply to an already-open TV session until a full page reload occurs - TV frontend asset changes are picked up by the build-triggered
/api/tv-buildcheck; a separate 1-hour hard refresh remains as a last-resort failover if the JavaScript loop hangs
Troubleshooting Checks
TV says there is no active tender
Check these in order:
- The shift row is not marked
Not in Pub - The shift
Date/TimeorOperational Timeframeoverlaps the current Eastern date and time - The shift has at least one related assignment row
- The assignment row has at least one related Pub Tender page
/tenders/ is empty
Check these in order:
- The tender pages have
Activechecked NOTION_PUB_TENDERS_DATABASE_IDis set, or shift/assignment discovery is working- The Notion token can read the tender database
Tender appears in the footer but not in the slideshow
That is expected when Meet your Tender is unchecked. The footer always shows on-duty names; the slideshow only shows opted-in tender profile slides.
Tender slide has no QR
Current code requires the tender page’s QR file property to resolve to an image. URL properties alone do not render a TV QR today. Recommended checks:
- The tender has a valid
Tender ID POST /api/sync-tender-qrs?dryRun=1succeeds with admin auth- The tender page has a
QRfiles property plus optionalQR Link/Short.io Link IDproperties
TV image slide or tender image is blank
Check these in order:
GET /api/eventsreturns the slide with a non-emptyimageUrl- The returned
imageUrlis a short same-origin/api/image/...route, not a giant signed S3 URL - The
/api/image/...route returns200 image/* - Enable
TV_DEBUG_MODE = "1", load/tv/on the affected screen, and checkimageStatus,viewport, andua
Two TVs are showing different slides
Check these in order:
- Both screens are on the same deployed
/tv/HTML and script version - Both screens are fetching the same
/api/eventspayload - One screen is not paused or background-throttled by the browser
The slideshow and featured-event rotation are now server-time anchored, so persistent drift usually means one screen is stale or throttled.
Updating Notion Property Names
All Notion property names the worker reads are defined as constants at the top of worker.js. If a database property is renamed, update the corresponding constant and redeploy.
// Events DB
const PROP_TITLE = "Name"
const PROP_DATE_START = "Event Start"
const PROP_DATE_END = "Event End"
const PROP_LOCATION = "Location"
const PROP_TV_INCLUDE = "Include on Pub TV"
const PROP_SOURCE_EMAIL_SUBJECT = "Source Email Subject"
// Shifts DB
const SHIFT_PROP_DATE_TIME = "Date/Time"
const SHIFT_PROP_OPERATIONAL_TIMEFRAME = "Operational Timeframe"
const SHIFT_PROP_LAST_CALL = "Last Call"
const SHIFT_PROP_NOT_IN_PUB = "Not in Pub"
const SHIFT_PROP_ASSIGNMENTS = "Assignments DB"
// Assignments DB
const ASSIGNMENT_PROP_PUB_TENDERS = "Pub Tenders DB"
const ASSIGNMENT_PROP_DATE_TIME = "Date/Time"
// Pub Tenders DB
const TENDER_PROP_ACTIVE = "Active"
const TENDER_PROP_BIO = "Bio"
const TENDER_PROP_PICTURE = "Picture"
const TENDER_PROP_QR = "QR"
const TENDER_PROP_SHOW_PROFILE = "Meet your Tender"Tender QR Sync Flow
The Short.io QR path is now live.
Manual dry run:
curl -X POST \
-H "Authorization: Bearer <TENDER_QR_SYNC_TOKEN>" \
"https://pub.ihnyc-rc.org/api/sync-tender-qrs?dryRun=1"Manual write:
curl -X POST \
-H "Authorization: Bearer <TENDER_QR_SYNC_TOKEN>" \
"https://pub.ihnyc-rc.org/api/sync-tender-qrs"Current sync behavior:
- Canonical tender destination stays on the pub site as
/tenders/?tender=<id> - Short.io uses a deterministic
pub-{tenderId}path - The generated SVG is uploaded into the tender page’s
QRfiles property - Existing synced rows are skipped unless they are out of date
QR LinkandShort.io Link IDare updated when those properties exist on the page
TV Health Admin Dashboard
https://pub.ihnyc-rc.org/admin/tv-health — gated by Cloudflare Access (Zero Trust → Applications → pub.ihnyc-rc.org/admin/*).
What it shows
Each connected screen gets a card with:
| Field | Notes |
|---|---|
| Heartbeat bar | Age of last ping vs 5-min stale threshold; stripe animation when missing |
| Build | Build ID the TV is currently running (buildId from health ping); sourced from window.__TV_BUILD_ID__ injected by the worker |
| Remote build | Build ID currently deployed (remoteBuildId); ”⟳ pending reload” when a reload is queued but not yet executed |
| Fetch / Last fetch ok | Last /api/events result and age of last successful fetch |
| FCP | First Contentful Paint in ms — one-time page load metric, only present in the first ping after a reload |
| Build poll | Last /api/tv-build result with latency in ms |
| Memory bar | JS heap used / limit (Samsung TVs report ~10 MB used / 347 MB limit) |
| Images | Loaded vs broken count |
| Slides / Right rail | Current slide position in both carousels |
| Uptime | Time since first-boot-at persisted to localStorage; survives page reloads. Format: Xh Ym (session: Ym) — session resets on each reload |
| Reloads | Lifetime reload counter persisted to localStorage |
| Device | Parsed user agent (“Samsung TV · Tizen 6.5 · Chrome 108”) |
| localStorage | Number of items in localStorage; useful for spotting cache bloat over time |
| Network | Connection type, downlink, RTT via navigator.connection; RTT=0 on Samsung TVs is expected on strong connections |
| Visibility | Only shown (amber) when document.visibilityState is not visible; useful for diagnosing background-throttled sessions |
| Online | Only shown (red) when navigator.onLine is false |
| Last JS error | Persistent — survives subsequent healthy pings |
Screen ID → Label Mapping
The config panel (below the summary bar) maps screen IDs to display labels and controls which screens count as “expected”:
- Save — assigns a label; that screen is now treated as expected
- Ignore — hides a screen from cards and counts (for ghost/dev sessions)
- Show ignored (N) — reveals ignored screens with an Unignore option
Config is persisted to R2 at tv-health/config.json. Adding a label is all that’s needed to register a new expected screen — no wrangler.toml change required.
Setting a screen label on the TV itself
The TV reads its label from a URL parameter on first load and persists it to localStorage:
https://pub.ihnyc-rc.org/tv/?screen=Bar+Left
Parameters accepted: screen, tv, or label. Once set, subsequent loads without the parameter use the stored value. Until physical access is possible, use the admin config panel to assign labels by screen ID instead.
iPad / /ipad surface
Not yet implemented
/ipad/script.jsdoes not include TV health ping code. The iPad will not appear in the health dashboard until health pings are added to the ipad surface. Tracked as a future task.
Bumping the build ID
TV screens self-reload when TV_BUILD_ID in wrangler.toml changes. Bump it on every deploy that changes public/tv/script.js or any TV-facing asset:
TV_BUILD_ID = "YYYY-MM-DD-NN" # e.g. 2026-04-24-01The worker injects this value into the TV HTML as window.__TV_BUILD_ID__ at request time (via buildTvAssetResponse). The TV script reads it from there — there is no hardcoded fallback. If the TV loads a cached HTML page without the injection, the build ID starts empty and is self-corrected from the first successful /api/tv-build poll, which sets TV_BUILD_ID to the remote value so no spurious reload is queued.