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 login

Secrets and Vars

Required for read paths

Set once per environment; stored encrypted by Cloudflare.

wrangler secret put NOTION_API_KEY

Optional for QR sync

wrangler secret put NOTION_QR_SYNC_API_KEY
wrangler secret put SHORT_IO_API_KEY
wrangler secret put TENDER_QR_SYNC_TOKEN

Non-secret vars in wrangler.toml

  • NOTION_DATABASE_ID
  • NOTION_SHIFTS_DATABASE_ID
  • NOTION_ANNOUNCEMENTS_DATABASE_ID
  • NOTION_PUB_TENDERS_DATABASE_ID
  • PUBLIC_SITE_URL
  • SHORT_IO_DOMAIN
  • TV_BUILD_ID — bump on every deploy to trigger TV self-reload
  • TV_DEBUG_MODE
  • IMAGE_R2_REFRESH_TTL_SECONDS

Removed vars

TV_HEALTH_EXPECTED_COUNT and TV_HEALTH_EXPECTED_LABELS were removed. Expected screens are now managed via the admin dashboard config panel, stored in R2 at tv-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 dev

Serves 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/events
  • http://localhost:8787/api/tenders
  • http://localhost:8787/tv/

Notion access in dev

If NOTION_API_KEY is not set locally, /api/events returns placeholder data and /api/tenders returns an empty placeholder directory.


Deploy

wrangler deploy

Publishes 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

  1. Workers & Pages ihnyc-avi-pub-landing Settings Domains & Routes
  2. 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

SignalWhere to look
Worker errorsCloudflare Dashboard Workers & Pages ihnyc-avi-pub-landing Logs
/api/events healthHit /api/events and confirm "source": "notion" plus non-empty live sections
/api/tenders healthHit /api/tenders and confirm active directory rows are returned
/api/image/* healthHit a live proxied image route and confirm 200 image/*
QR sync healthCall POST /api/sync-tender-qrs?dryRun=1 with admin auth and review the JSON summary
TV response freshnessFooter countdown on /tv/ shows Refreshing in M:SS
TV client stateSet TV_DEBUG_MODE = "1" and load /tv/ to inspect fetch state, active slide, viewport, user agent, and image load/error status
TV health dashboardhttps://pub.ihnyc-rc.org/admin/tv-health (Cloudflare Access required) — live heartbeat, build match, memory, network, JS errors per screen
Cache behaviorCloudflare Dashboard Caching Cache Analytics
Frontend stateLoad /, /tv/, /announcements/, and /tenders/ after deploy

Refresh and Cache Behavior

SurfaceRefresh behavior
Cloudflare edge cache2-minute TTL for /api/events and /api/tenders
/api/image/*Same-origin image routes backed by edge cache and optionally R2 mirroring
/tv/ HTMLReturned 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 checkPolls /api/tv-build every minute and reloads only when the deployed TV build changes
TV hard reload fallback1-hour meta refresh safety net if JavaScript hangs completely

Important distinction:

  • Purging /api/events does 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_BUCKET configured, /api/image/* populates the R2 mirror lazily on first request and refreshes mirrored objects after IMAGE_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-build check; 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:

  1. The shift row is not marked Not in Pub
  2. The shift Date/Time or Operational Timeframe overlaps the current Eastern date and time
  3. The shift has at least one related assignment row
  4. The assignment row has at least one related Pub Tender page

/tenders/ is empty

Check these in order:

  1. The tender pages have Active checked
  2. NOTION_PUB_TENDERS_DATABASE_ID is set, or shift/assignment discovery is working
  3. The Notion token can read the tender database

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:

  1. The tender has a valid Tender ID
  2. POST /api/sync-tender-qrs?dryRun=1 succeeds with admin auth
  3. The tender page has a QR files property plus optional QR Link / Short.io Link ID properties

TV image slide or tender image is blank

Check these in order:

  1. GET /api/events returns the slide with a non-empty imageUrl
  2. The returned imageUrl is a short same-origin /api/image/... route, not a giant signed S3 URL
  3. The /api/image/... route returns 200 image/*
  4. Enable TV_DEBUG_MODE = "1", load /tv/ on the affected screen, and check imageStatus, viewport, and ua

Two TVs are showing different slides

Check these in order:

  1. Both screens are on the same deployed /tv/ HTML and script version
  2. Both screens are fetching the same /api/events payload
  3. 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 QR files property
  • Existing synced rows are skipped unless they are out of date
  • QR Link and Short.io Link ID are 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:

FieldNotes
Heartbeat barAge of last ping vs 5-min stale threshold; stripe animation when missing
BuildBuild ID the TV is currently running (buildId from health ping); sourced from window.__TV_BUILD_ID__ injected by the worker
Remote buildBuild ID currently deployed (remoteBuildId); ”⟳ pending reload” when a reload is queued but not yet executed
Fetch / Last fetch okLast /api/events result and age of last successful fetch
FCPFirst Contentful Paint in ms — one-time page load metric, only present in the first ping after a reload
Build pollLast /api/tv-build result with latency in ms
Memory barJS heap used / limit (Samsung TVs report ~10 MB used / 347 MB limit)
ImagesLoaded vs broken count
Slides / Right railCurrent slide position in both carousels
UptimeTime since first-boot-at persisted to localStorage; survives page reloads. Format: Xh Ym (session: Ym) — session resets on each reload
ReloadsLifetime reload counter persisted to localStorage
DeviceParsed user agent (“Samsung TV · Tizen 6.5 · Chrome 108”)
localStorageNumber of items in localStorage; useful for spotting cache bloat over time
NetworkConnection type, downlink, RTT via navigator.connection; RTT=0 on Samsung TVs is expected on strong connections
VisibilityOnly shown (amber) when document.visibilityState is not visible; useful for diagnosing background-throttled sessions
OnlineOnly shown (red) when navigator.onLine is false
Last JS errorPersistent — 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.js does 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-01

The 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.