The pub runs rotating drink and food specials — “Whiskey Wednesday,” a featured cocktail, a kitchen special. This feature lets the operator manage them in one place and surfaces them in three spots:

  • The right bar TV rotation — a “Pub Specials” card that joins the on-screen rotation while the bar is serving.
  • A public page at pub.ihnyc-rc.org/specials — the live specials plus an archive of “Previously served.”
  • The /menu page — a short specials strip at the top that links through to /specials for the full build sheet.

/admin/pub-specials writes the pub_specials table; schedule fields determine where and when each item appears.

Sources: worker/lib/pub-scheduler/specials-store.js (CRUD + shift-link logic), worker/lib/pub-scheduler/specials-page.js (admin form), worker.js (Pub Specials section, /api/pub-specials, /api/admin/pub-specials), public/specials/index.html, public/menu/index.html, public/tv/cinematic.js, migrations 0017 / 0019 / 0020 / 0025 / 0027 / 0030.


What an operator manages

Each special is a row with a title, optional description, price, and an optional photo (stored in R2 under pub-specials/<id>, served at /api/pub-specials/<id>/image). That image route resizes the R2 original through the env.IMAGES binding (scale-down to 1280, JPEG q80 — mirroring the event-photo proxy) and edge-caches the result, so the bar TV and /specials get an instant cached copy instead of streaming the full-size multi-MB original on every load (it falls back to the raw bytes if the transform is unavailable). On top of that, four behaviors shape where and when it shows: shift-linking, an always-on flag, an optional recipe / build sheet, and archive / restore.

The admin page splits into two lists:

  • Current Specials — everything that can still show.
  • Previously served / Archived — a read-only history of specials that have finished their run or been manually shelved, with a “Bring back” / “Restore” action.

The two lists never overlap: a special that’s fully in the past (all its linked shifts are over) or manually archived drops out of Current and appears only in the history.

There’s also a “Copy last week’s specials” helper — copySpecialsFromPriorWeek() re-creates the prior week’s shift-linked specials onto this week’s matching shifts, so a recurring special like Whiskey Wednesday doesn’t have to be re-entered every week.


Shift-linking — shift_ids

A special can be tied to specific shifts via shift_ids, a CSV of pub_shifts row ids the special runs with (migration 0020). The pub schedule is set up weekly, so a special is pointed at that week’s shift rows.

  • Empty / NULL shift_ids = every day the pub is open. This is the back-compat behavior for an evergreen special with no schedule.
  • One or more ids = the special only surfaces on the day(s) those shifts run.

Because shift_ids is a CSV and not a real foreign key, the “does this special run today?” join is done in JavaScript (specialRunsForShift() / listActiveSpecials()). A linked special previews all of its shift’s calendar day, and an overnight shift that’s still serving keeps showing until serving ends — but a shift that ended earlier in the day drops the special immediately into the history rather than letting it linger until midnight.

Note on the old days column. An earlier design (migration 0019) used a days CSV of day-of-week numbers (0=Sun … 6=Sat). It was superseded by shift_ids — tying to actual shift rows is more precise than a weekday. The days column is left in place (D1 can’t easily drop a column) but is no longer read.

When the auto-retire happens by shift date, it’s date-driven: servedSpecialIds() / listServedSpecials() flag a shift-linked special as “served” once every linked shift has both started before today and already ended. The Current list excludes those ids, and they appear in Previously-served keyed by their run dates (a multi-night special shows a range like “Jun 11 – Jun 12”; recurring weekly copies collapse to one history row with a times_served count).


The recipe / build sheet

A special can carry an optional bartender build sheet (migration 0027) — four flat TEXT columns, deliberately kept human-readable in D1 rather than a JSON blob for a non-technical successor:

FieldMeaning
recipe_ingredientsNewline-delimited, one ingredient per line (e.g. 1.5 oz · Gin).
recipe_glassGlassware.
recipe_garnishGarnish.
recipe_methodBuild / technique notes.

All four are optional; a has_recipe flag is true only when at least one field has real (non-whitespace) content, so a special with no recipe shows no “Recipe” affordance. Each field is trimmed and capped at 2 KB on write so a paste-bomb can’t bloat the edge-cached public payload that the bar TVs poll. The recipe surfaces as a click-through “Recipe” expander on /specials, and the /menu specials strip links into /specials for the full build sheet.


Where specials appear

Right bar TV

The right TV polls GET /api/pub-specials (≈60 s) and renderRightSpecials() draws the “Pub Specials” card. By default specials only join the rotation while the pub is actually serving (inside a shift’s serving window) — they’re aligned to the shift, not on 24/7.

The always_on flag (migration 0025) overrides that: when always_on = 1, the special shows on the right-TV rotation regardless of whether the pub is currently serving. Default 0 preserves the shift-aligned behavior. The TV’s gate is serving || hasAlwaysOn, so a single always-on special is enough to keep the specials card in rotation off-hours.

Public /specials

GET /api/pub-specials returns the active specials for today (listActiveSpecials()) plus a serving_now flag from isPubServingNow(). That flag drives the patron-facing label: “Specials on now” only while the bar is actually open, “Tonight’s specials” the rest of the shift day (specials preview all day on their shift’s date, not just during serving hours). Adding ?include=past returns the deduped “Previously served” history, which /specials renders as a collapsed disclosure beneath the live cards. The public payload is edge-cached ~60 s.

The /menu page shows a short specials strip at the top; each entry links into /specials where the full recipe / build sheet lives.


Manual archive vs. shift-date auto-retire

There are two independent ways a special leaves the Current list:

  1. Auto-retire by shift date (the shift_ids logic above) — a shift-linked special retires itself once all its shifts are past. This is automatic and needs no operator action.
  2. Manual archive (the archived flag, migration 0030) — an operator “Archive” button on the admin sets archived = 1 via POST /api/admin/pub-specials/<id>/archive. The special drops out of Current and shows in Previously served / Archived with a “Restore” action (POST …/unarchive, clears the flag).

The manual archive exists precisely because an evergreen / every-open-day special has no shifts to expire — so the shift-date retire can never fire for it. Archive is the only non-destructive way to shelve such a special without deleting it (and losing its photo and recipe). It’s lightweight — a flag flip, not the full multipart edit form — and fully reversible.


Deferred: the Agilysys “hit” metric

A natural next step is a “hit” metric — how many of each special actually sold, so the archive can show which specials performed. That requires sales data from the pub’s Agilysys POS, which needs a POS API key the project doesn’t have yet. Until that key exists, the public-archive “hit” tracking is deferred — the archive shows what was served and when, but not how it sold.


  • tv-display — the right bar TV rotation the “Pub Specials” card lives in, and the serving-window logic the always_on flag overrides
  • index — the pub site service overview