• Full-screen 16:9 dark-theme signage board at /tv/
  • Designed for in-venue TV screens and blocks mobile viewports with a friendly message
  • Pulls live data from /api/events every 2 minutes
  • Uses the API response’s lastUpdated timestamp as the shared clock anchor for slideshow rotation and featured-event conflict rotation
  • Visible header clock is still device-local time
  • Caches the last successful /api/events payload in localStorage so reloads can repaint immediately from prior data
  • Polls /api/tv-build every minute and reloads only when the deployed TV build changes
  • TV_BUILD_ID is injected dynamically by the worker as window.__TV_BUILD_ID__; if the TV loads a cached HTML page, the build ID self-corrects from the first poll result
  • Hard-refresh fallback every hour via <meta http-equiv="refresh">
  • /tv/ can show a device-side debug overlay when TV_DEBUG_MODE = "1" is injected by the worker
  • The right-TV Now Playing composition has an explicit short-landscape layout for 720p/768p panels: title/artist/album retain a usable column, the cover stays on-canvas, and the QR remains a 220px scan target without overlapping metadata

Sources: public/tv/index.html, public/tv/script.js, public/tv/styles.css, public/tv/cinematic.css

Live screenshots

The TV is easier to understand from screenshots than from the DOM alone. These captures were taken from the live production page at pub.ihnyc-rc.org/tv on April 4, 2026.

Live TV overview showing the full screen layout Combined live TV layout

  • Left column: Happening at the Pub for same-day featured activity and the mixed announcement / tender slideshow beneath it
  • Right column: Coming Up at the Pub for tomorrow-through-next-7-days events and Pub Serving Hours This Week for the current weekly service grid
  • Footer: current tender assignment, serving window, shift note, refresh countdown, and environment flag
  • In this live frame, Happening at the Pub is populated by an overnight event that began the previous evening and is still active after midnight

Live Pub TV panel highlighting the current Happening at the Pub Current event panel

  • This panel is driven from todayEvents and now correctly includes overnight events that overlap the current Eastern calendar day
  • The green Now Happening badge is the highest-priority featured state and is what pub staff and guests should notice first

Live Pub TV announcement area with the current slide and controls Announcement panel

  • Announcement slides can be text, image, or mixed image+text
  • The slideshow dots indicate the current slide and give manual navigation targets
  • When a full image announcement is active, the slide can use most of the lower-left panel

Live upcoming-events and pub-hours column Upcoming events and serving-hours rail

  • Coming Up at the Pub is optimized for quick scanning of the next several pub events
  • Pub Serving Hours This Week highlights today’s service state while still showing the rest of the week
  • This right-side column is the highest-density “what is going on here?” area for people glancing at the TV

Layout

+-----------------------------------------------------+
| Header: logo, current date, live clock              |
+------------------------------+----------------------+
| Left column                  | Right column         |
| - Happening at the Pub       | - Coming Up at the   |
| - Announcements / tender     |   Pub                |
|   slideshow                  | - Pub Hours This     |
|                              |   Week               |
+-----------------------------------------------------+
| Footer: tender, hours, note, refresh, test flag     |
+-----------------------------------------------------+

Sources: public/tv/index.html, public/tv/styles.css (.tv-main, .tv-left, .tv-aside)


Event inclusion pipeline

The TV page does not query Notion directly. It renders whatever the worker returns from GET /api/events.

Both event arrays use the same base gate:

  • Include on Pub TV must be checked (location is no longer filtered — any location or none is accepted)
  • Events are sorted by actual start timestamp before the API response is returned

todayEvents

  • The worker computes todayISO in America/New_York
  • The event day starts at 12:00 AM Eastern
  • The worker queries a broad Notion window from todayISO through < todayISO + 2 days
  • It then narrows that result set to rows whose Event Start falls on todayISO in Eastern time

weekEvents

  • Uses the same pub-location and TV-include gates
  • Pulls rows with Event Start >= tomorrowISO and < todayISO + 8 days
  • That means calendar tomorrow through the next 7 days

Sources: worker.js (fetchFromNotion, eventOverlapsLocalDate, transformPage, transformWeekPage)


Event status logic

Each event is evaluated client-side using ISO timestamps from the API and the TV’s server-aligned clock.

StatusConditionBadge
NowstartMs <= now < endMsNow Happening
NextStarts within 90 minutesComing Up Next
LaterStarts more than 90 minutes from nowComing Soon
DoneEnd time has passedHidden
  • The Featured Event panel shows the highest-priority non-Done event (Now > Next > Later)
  • When multiple events share the same timeslot and status, the panel rotates among them every 15 seconds using the shared server-aligned clock
  • The Coming Up list filters out any Done rows before rendering
  • If an event has no usable end timestamp, the client falls back to start + 1 hour

Sources: public/tv/script.js (deriveStatusFromEvent, selectFeaturedEvent, renderWeekEvents)


Happening at the Pub

This panel renders from todayEvents only.

  1. The client re-derives every event’s live status from startIso and endIso
  2. It picks a single featured row using priority order Now Next Later
  3. It renders the featured title, RC badge, time range, and location (if set)
  4. It then lists the remaining non-Done same-day events under Also today, each with title, time, and location

If there is no non-Done same-day event, the panel stays visible and shows the empty-state message.

Sources: public/tv/script.js (withLiveStatuses, selectFeaturedEvent, renderFeaturedEvent)


RC classification badges

Events can carry one of two RC classification tags, shown as a badge before the event title.

TagBadge styleMeaning
rc-directSolid greenOrganized by an RC member
rc-affiliateOutlined accentResident-led, supported by the RC
  • Badge displays the RC logo (RC-transparent-background-logo.png)
  • Tag is extracted from the event row’s Source Email Subject by matching RC-Direct or RC-Affiliate

Sources: public/tv/script.js (rcBadgeHtml), worker.js (extractRcTag)


Announcements and Meet your Tender slideshow

The bottom-left panel is a slideshow that rotates through two types of slides:

  1. Announcement slides
  2. Tender profile slides

Slides auto-advance according to each slide’s configured duration, defaulting to 10 seconds. Navigation dots are shown inline in the panel heading bar, right-aligned after the slide heading text (e.g. “Announcements ●●○”). They are hidden when only one slide is present.

The slide position is no longer session-relative. Each TV computes the active slide from:

  • the shared slides payload
  • each slide’s duration
  • the API response’s lastUpdated timestamp

That keeps multiple TV sessions aligned even if browsers were opened at different times, as long as they are on the same payload.

Announcement slides

  • Can be text-only, image-only, or mixed image+text cards
  • Show a title (Name) and body text (Content, falling back to Name)
  • Can render an image from the announcement page’s Picture, Image, or PNG file property
  • Use Duration when present to override the default 10-second slide duration
  • Use the announcement priority field as a styling hint
  • Image URLs are rewritten to same-origin /api/image/announcement/:pageId routes before the frontend sees them
  • Those image routes can mirror Notion-hosted files into R2, so always-on TV sessions are less dependent on temporary Notion file URLs remaining valid
  • Pure image slides collapse the featured-event panel into a compact strip so the announcement image can use more vertical space
  • Fall back to a single empty-state message if the combined slides array is empty

Tender profile slides

  • Heading reads “Meet your Tender”
  • Show the tender’s first name only
  • Show the tender’s Picture through a same-origin /api/image/tender/:pageId route
  • Show a QR image only when the QR file property resolves successfully, via /api/image/tender-qr/:pageId
  • Do not currently show the tender bio, even if Bio exists on the Notion page
  • Do not generate a QR in the browser. The TV only renders the pre-generated Notion QR image file
  • Are only generated when Meet your Tender is checked on the tender page

Tender slide layout

The tender card (.slide-tender-layout) uses position: absolute with explicit inset values rather than flex sizing. Flex-based percentage-height resolution through the position: absolute slideshow chain was unreliable on certain TV rendering environments. The inset values map directly to .tv-slide’s edges and are deterministic on all screens.

QR code destination

The QR code displayed in the tender slide encodes the Short.io short URL (e.g. go.affectivetech.com/pub-pt3). That short URL’s target is the canonical profile URL /tenders/?tender=<id>. The older /tender/:id route still exists only as a compatibility redirect into the query-based directory URL.

Sources: public/tv/script.js (renderSlideshow, renderSlideMarkup), worker.js (buildSlides, fetchTenderInfoFromAssignmentPage)


Coming Up panel

  • Renders from weekEvents, which the worker defines as calendar tomorrow through the next 7 days
  • Same-day events are intentionally excluded
  • Events whose computed status is Done are removed before rendering
  • Long event titles animate with a horizontal marquee scroll
  • The panel is hidden entirely if no upcoming events remain

Sources: public/tv/script.js (renderWeekEvents, startWeekTitleScroll)


Pub hours grid

  • Seven-column grid showing Sunday through Saturday pub hours for the current week
  • Today’s column has three visual states:
    • amber before today’s first shift segment starts, or between multiple segments
    • green while a shift segment is active
    • dimmed after the last segment has ended
  • Overnight segments like 9:00 PM - 1:40 AM are treated as ending on the next day when determining those states
  • Shift window math is evaluated explicitly in America/New_York, not the browser’s local timezone

Sources: public/tv/script.js (renderPubHoursWeek, getShiftStateForDate, getEffectivePubHoursDate)


Persistent footer bar showing the on-duty tender and live shift state.

FieldContent
Pub TenderFirst name(s) of on-duty tender(s)
Serving HoursOperational timeframe
Shift StateLive note with color and animation

Footer vs profile slide

The footer always shows the tender’s first name, regardless of whether Meet your Tender is checked. The checkbox only gates the profile slide in the slideshow panel.

Shift state transitions

StateNote textVisual
upcomingUpcoming shiftNormal
setupSetting upAmber
activeOn duty nowNormal
last-callLast callRed, pulsing
closedCleaning / closedDimmed
inactiveNo active shift right nowNormal

Sources: public/tv/script.js (renderPubTender), worker.js (deriveShiftNote)


Lives in the same footer strip as Pub Tender. Surfaces the song currently identified by the pub song-ID pipeline (the Raspberry Pi + Cloudflare Worker SSE stream). Ambient by design — hides entirely when the music is silent or the capture service is offline rather than showing a banner pub customers don’t need to read.

FieldContent
Album artCover art for the current track, ~56px square. Falls back to a monochrome music-note SVG when the song-ID backend didn’t return cover art.
TitleLarger type (~1.15rem) so it reads from typical pub viewing distance (10-15 ft).
ArtistSmaller type below the title.

Visibility states

Pi stateMusic stateFooter slot
OnlinePlayingVisible: album art + title + artist. Cross-fades (~200 ms) when the song changes.
OnlineSilent (no music)Hidden. The /setlist/ page surfaces an explicit “Quiet right now” indicator; the TV stays calm.
Offline (no heartbeat for 90 s)AnyHidden. Customers don’t need to see “service offline” — staff have logs and the /setlist/ page for that.

Source labels

The footer slot doesn’t render the source (“Spotify”, “Aux”, “DJ”) — that information lives on /setlist/ only. The TV intentionally stays minimal.

Sources: public/tv/index.html (#footer-now-playing), public/tv/styles.css (.footer-item--now-playing), public/tv/script.js (search “Pub “Now Playing” — SSE subscriber”). See Now Playing and setlist system for the full pipeline.


Refresh strategy

MechanismIntervalPurpose
Featured event re-evaluation15 seconds, client-sideKeeps badges and same-timeslot rotation accurate between API calls
Soft data refresh2 minutes via /api/eventsPulls fresh event, announcement, and tender-footer data
Build fingerprint poll1 minute via /api/tv-buildReloads only when deployed TV assets actually changed
Hard meta refresh fallback1 hourSafety net if JavaScript hangs completely
Countdown displayLiveShows Refreshing in M:SS in the footer
Slideshow rotationDuration-driven, default 10 secondsAdvances slides using the shared server-aligned cycle

Sources: public/tv/script.js (init, startRefreshCountdown), public/tv/index.html (<meta http-equiv="refresh">)


Debug mode

Set TV_DEBUG_MODE = "1" and open /tv/ to show a device-side overlay with:

  • /api/events fetch status
  • server timestamp used for sync
  • active slide index and type
  • image load vs error status
  • exact current image URL
  • viewport size, DPR, and user agent
  • unhandled JS/runtime errors

This mode exists specifically to debug embedded TV browsers that behave differently from desktop browsers.

Sources: public/tv/script.js (TV_DEBUG_MODE, installDebugHooks, startBuildPolling), worker.js (buildTvAssetResponse, handleTvBuildApi), public/tv/styles.css (.tv-debug-overlay)



iPad night board (/ipad/)

A separate landscape-only board at /ipad/ designed to sit on a bar-top iPad during pub events.

What it shows

  • Tonight’s Events — all todayEvents returned by the API, each row showing: status pill (Now / Next / Later / Done), event title, and time + location inline (7:00 PM – 9:00 PM · 📍 Pub)
  • On Shift Tonight — photo(s) and full name(s) of active pub tenders from pubTender.activeTenders

What it does not show

  • No hero/featured event panel
  • No announcement slideshow
  • No “Coming Up” week list
  • No board-status meta (refresh cadence, source, last sync)
  • No tender shift notes, hours, or detail cards

Layout

+---------------------------------------------+
| Logo  Tonight at the Pub  [date]     [clock] |
+----------------------------+----------------+
| Tonight's Events           | On Shift       |
|                            | Tonight        |
| [Now]  Trivia Night        |                |
|        8:30 PM · 📍 Pub   |  [photo]       |
|                            |  First Last    |
| [Next] Karaoke             |                |
|        10:00 PM            |                |
+----------------------------+----------------+

Event row ordering and states

  • Active events (Now, Next, Later) are always shown first
  • Done events are sorted to the bottom, faded to 45% opacity
  • If all events are done, the list collapses to a single “All wrapped up for tonight” message — no need to show a wall of faded rows

Event row accent

  • The active Now row gets a green left-border accent, a subtle green tint, and a slow breathing glow animation so bar staff can spot the current event without reading the pill
  • All rows carry a 3px left border; only Now has a colored one

Refresh

  • Data: every 60 seconds via /api/events?window=1
  • Clock: every 10 seconds client-side
  • Status re-evaluation: every 15 seconds client-side
  • Hard page reload: every 5 minutes via <meta http-equiv="refresh">
  • Portrait orientation on a touch device shows a rotate prompt and hides the board

Rotation blocker

If the iPad is held in portrait, a full-screen overlay asks the user to rotate. The board content is hidden until landscape orientation is restored.

Sources: public/ipad/index.html, public/ipad/script.js, public/ipad/styles.css


Shipped: split into /bar-tv-left + /bar-tv-right

Shipped 2026-05-19 → 2026-05-21

Both bar-TV routes are live in production. The two pub TVs used to both load /tv and render identical content — because they sit side-by-side on the same wall, that wasted ~50% of the available pixels. The split gives each TV a focused subset of panels at full 1920px scale.

Allocation:

  • LEFT TV (/bar-tv-left)Happening at the Pub + Announcements / tender slideshow at full 1920px width. Now Playing carried in the footer.
  • RIGHT TV (/bar-tv-right)Coming Up at the Pub + Pub Serving Hours This Week arranged as a side-by-side 2-column grid (grid-template-columns: 1.15fr 1fr), with the right-rail-stage rotation (overview / flyer / Now Playing hero / takeover-tender slide).
  • Both TVs — header + footer carried over identically. Now Playing footer line surfaces on both.
  • Takeover — both TVs swap on event-takeover-pre / event-takeover-live body class. LEFT mosaic spans the full LEFT viewport; RIGHT swaps to the tender slide.

Shipping history:

  1. Phase 0 — vitest+miniflare test framework bootstrap (predecessor PR).
  2. Phase 1 — two HTMLs carved out, buildTvAssetResponse extended, Now Playing SSE gated on element-presence, vw-based typography scale per body class, worker routes wired (PRs #54–#58 era).
  3. Phase 2 — Now Playing hero card on /bar-tv-right as a right-rail-stage slide (PR #59 + #62 album-art-proxy fix).
  4. Phase 3 — side-by-side Coming Up + Pub Hours grid on the right TV with footer-readability bumps (PR #63).
  5. Phase 4 — Pub Serving Hours cards widened, heading restructured with Open Wed–Fri subtitle, arrow glyph swap ↓ → → (PR #64). See 2026-05-21-widen-pub-hours-cards.

/tv continues to work unchanged as a dev/laptop preview indefinitely.

See 2026-05-20-bar-tv-split for the original full design, locked decisions, typography scale, empty-state behavior, and implementation order.