Generated by /office-hours on 2026-05-06 Reviewed by /plan-eng-review on 2026-05-06 Branch: dghauri0/install-gstack Repo: dghauri0/ihnyc-avi-pub-landing Status: APPROVED (post-eng-review) Mode: Builder
Problem Statement
Two related bugs surface when pub-tender shifts overlap:
Bug 1 — active tender drop. pub.ihnyc-rc.org, /tv, and /ipad show only one tender during an overlap window. Friday example: Dawood 6:30–11:30 PM, Veronica 8:30 PM–2 AM should produce three windows on screen (Dawood only → both → Veronica only); today the second tender is silently dropped from pubTender.activeTenders.
Bug 1B+ (uncovered during eng review) — hours and noteTiming are single-shift artifacts. Even after fixing Bug 1, the TV/iPad footer renders one shift’s hours string and one shift’s countdown. During solo-Dawood (6:30–8:30 PM) the TV would say “pub closes at 11:30” — wrong; Veronica is coming and the actual close is 2 AM. The pub-level fields must reflect the night’s pub-close envelope.
Bug 2 — segmented serving-hours rendering. The “Serving Hours This Week” widget shows "6:40 PM — 11:30 PM, 9:00 PM — 1:40 AM" for Friday. The pub is continuously open 6:40 PM → 1:40 AM; the right rendering is one merged range with the arrow glyph used elsewhere.
Constraints
- All three surfaces (
/,/ipad,/tvincluding takeover) must inherit the fix from one worker change. - Visual behavior on TV: same rotation as today; both tenders’ profile slides cycle through the existing rotation. No new UI primitives.
- Footer / overlay copy that lists names must include all on-shift tenders.
- “Last Call” semantics: pub-closure-relative, owned by whichever shift ends the night. Earlier shifts can leave the Notion
Last Callproperty empty. - iPad surface gets the fix incidentally; deeper iPad polish is out of scope.
Premises
- Root cause of Bug 1:
findActiveShift(worker.js:3325) returns one shift via.find(). - Root cause of Bug 1B+:
fetchPubTenderFromNotion(worker.js:3107–3153) deriveshours,note,noteTimingfrom a singleactiveShiftinstead of the night’s pub-close envelope. - Root cause of Bug 2:
fetchPubHoursWeek(worker.js:3082) joins per-shiftformatTimeRangestrings with,without merging overlapping intervals. - Notion shift schema is fine as-is; only worker collapse-to-one logic discards data.
- All three surfaces consume
pubTender.activeTendersandpubTender.noteTiming— a single worker fix covers them. - No new UI is needed for Bug 1 — the existing TV rotation iterates
activeTenders[].
Recommended Approach
Approach A from /office-hours (minimal union), refined per /plan-eng-review:
- DRY: extract one
mergeIntervals(intervals)helper used by both Bug 1B+ (nightly envelope) and Bug 2 (hours widget). - Bug 1:
findActiveShift→findActiveShifts—.find()becomes.filter(). ReturnsShift[]. - Bug 1B+: compute nightly envelope — derive
hoursandnoteTimingfrom the merged interval that containsnow(computeNightlyEnvelope(shifts, now)), not from a single shift. - Bug 2: merge intervals in
fetchPubHoursWeekbefore formatting. - Tighten
fetchTenderInfoFromAssignmentfilter — restrictitemsto assignment IDs from the active-shift union, not just same-todayISOstartDate. - No client changes for
/,/ipad,/tv— they all readpubTender.activeTenders[],pubTender.hours,pubTender.noteTiming. Shapes preserved.
Implementation sketch
Notion shift records (today)
│
▼
shifts = transformShift().filter(overlapsLocalDate)
│
├──► Bug 1: findActiveShifts(now) → activeShifts[]
│
│ ├──► tenderInfo = union over activeShifts.assignmentIds
│ │ (existing fetchTenderInfoFromAssignment, dedupeTenderDetails)
│ │
│ └──► Bug 1B+: envelope = computeNightlyEnvelope(shifts, now)
│ ├──► pubTender.hours = formatTimeRange(envelope.start, envelope.end)
│ ├──► pubTender.noteTiming = derive from shift owning envelope.end
│ └──► pubTender.note = derive same way
│
└──► Bug 2: fetchPubHoursWeek
per-day shifts → mergeIntervals → join(", ")
Pure helpers (testable later)
// Merge sorted intervals; touching boundary (a.end === b.start) merges.
function mergeIntervals(intervals) {
const sorted = [...intervals].sort((a, b) => a.startDate - b.startDate);
const merged = [];
for (const iv of sorted) {
const last = merged[merged.length - 1];
if (last && iv.startDate.getTime() <= last.endDate.getTime()) {
if (iv.endDate.getTime() > last.endDate.getTime()) last.endDate = iv.endDate;
} else {
merged.push({ startDate: iv.startDate, endDate: iv.endDate });
}
}
return merged;
}
// All currently-active shifts (was: findActiveShift returning one).
function findActiveShifts(shifts, now) {
const nowMs = now.getTime();
return shifts.filter((shift) => {
const startMs = shift.startDate?.getTime();
const endMs = shift.endDate?.getTime() ?? (startMs ?? 0) + 60 * 60 * 1000;
return startMs != null && nowMs >= startMs && nowMs < endMs;
});
}
// The merged interval containing `now`. Returns null if none.
// Used to give pubTender.hours / noteTiming the night's pub-close envelope.
function computeNightlyEnvelope(shifts, now) {
const merged = mergeIntervals(
shifts.filter((s) => s.endDate && s.endDate > now)
);
return merged.find(
(iv) => iv.startDate.getTime() <= now.getTime() && now.getTime() < iv.endDate.getTime()
) || null;
}
// Which raw shift owns the envelope's end time? That's the "closing shift" —
// its lastCallMinutes / displayEndDate / etc. populate noteTiming.
function findClosingShift(shifts, envelope) {
if (!envelope) return null;
return shifts.find(
(s) => s.endDate && s.endDate.getTime() === envelope.endDate.getTime()
) || null;
}Wiring in fetchPubTenderFromNotion
const activeShifts = findActiveShifts(shifts, now);
if (activeShifts.length === 0) {
return { /* inactive state, unchanged */ };
}
const envelope = computeNightlyEnvelope(shifts, now);
const closingShift = findClosingShift(shifts, envelope) || activeShifts[0];
const allAssignmentIds = activeShifts.flatMap((s) => s.assignmentIds || []);
const tenderInfo = await fetchTenderInfoFromAssignment(env, allAssignmentIds, todayISO);
const noteInfo = deriveShiftNote(closingShift, now);
return {
name: tenderInfo.name || "No pub tender assigned",
hours: formatTimeRange(envelope.startDate, envelope.endDate),
note: noteInfo.text,
noteState: noteInfo.state,
noteTiming: {
startIso: closingShift.startDate?.toISOString() || "",
endIso: closingShift.endDate?.toISOString() || "",
displayStartIso: closingShift.displayStartDate?.toISOString() || "",
displayEndIso: closingShift.displayEndDate?.toISOString() || "",
lastCallMinutes:
typeof closingShift.lastCallMinutes === "number"
? closingShift.lastCallMinutes
: null,
},
pageUrl: tenderInfo.pageUrl || "",
activeTenders: tenderInfo.tenders || [],
};Tighten fetchTenderInfoFromAssignment filter
Replace the items.filter(getDateKeyInTimeZone(item.startDate) === todayISO) heuristic at worker.js:3364 with: pass an expectedAssignmentIds set (or accept items already filtered upstream) and trust the active-shift caller. Avoids the cross-midnight false-positive where an unrelated Saturday shift’s assignment leaks into the union.
Wiring in fetchPubHoursWeek
shifts.sort((a, b) => a.startDate - b.startDate);
const merged = mergeIntervals(shifts);
const hoursStr = merged
.map((s) => formatTimeRange(s.startDate, s.endDate))
.join(", ");
result.push({ date: dateISO, open: true, hours: hoursStr });Edge cases
Tender / envelope path:
- Single shift active — identical to today (envelope = single shift).
- Two overlapping shifts, same tender on both —
dedupeTenderDetailskeys onid || fullName(worker.js:3636), no duplication. - Two shifts touching at boundary (one ends 11:30, next starts 11:30) — half-open interval
[start, end)keeps exactly one active at the boundary instant. Envelope merge uses<=, so they merge into one envelope window. - Shift crossing midnight (Veronica 8:30 PM – 2 AM) —
transformShiftalready handles this;findActiveShiftsand the envelope inherit it. - Three or more concurrent shifts —
mergeIntervalsis sweep-line, handles N. - Zero active shifts — inactive state branch unchanged.
- Pre-overlap solo (6:30–8:30 PM Fri) — envelope sees Dawood (active) AND Veronica (upcoming, endDate > now). Merged envelope covers 6:30 PM – 2:00 AM. ✓
- Post-midnight Veronica solo —
findActiveShiftsreturns [Veronica], envelope = [Veronica’s window],closingShift= Veronica. ✓ - True gap day (6–8 PM, 10 PM–1 AM) at 9:00 PM —
findActiveShiftsreturns []. Inactive state. ✓ - True gap day, before the gap (7 PM) — envelope sees first shift only (gap means no merge across).
hours= first shift’s window. After-gap second shift kicks in once it becomes active.
Hours widget path:
- True overlap → merged single arrow.
- Adjacent shifts touching (6–8, 8–10) → merged using
<=(decision: merge; nobody experiences the boundary as a closure). - True gap → preserved as two segments; frontend’s multi-segment fallback renders raw string.
- Single shift → identical to today.
- Sub-shift (one fully inside another) → outer envelope.
Open Questions
None remaining after eng review. (Prior open questions on dedupeTenderDetails keying and TV mosaic resilience were resolved: keys on id || fullName so distinct tenders don’t collapse; existing rotation already iterates activeTenders[].)
Success Criteria
Tender / envelope path:
- Friday 7:00 PM (solo): TV/landing footer shows
Dawood · 6:30 PM – 2:00 AM(envelope, not Dawood’s 11:30). - Friday 9:00 PM (overlap): footer shows
Dawood & Veronica · 6:30 PM – 2:00 AM. TV right rail rotates both profile cards. - Friday 11:45 PM (Veronica solo): footer shows
Veronica · 8:30 PM – 2:00 AM. Last call countdown counts down to 2 AM. - 1:30 AM Saturday: footer shows
Veronica · 8:30 PM – 2:00 AM. Last call active per Veronica’slastCallMinutes. - Single-tender shifts behave identically to today.
Hours widget path:
- Friday row renders
6:40 PM → 1:40 AM(merged), same arrow glyph as Wed/Thu. - A real gap-day still renders both segments.
- Sun/Wed/Thu single-shift rows unchanged.
What already exists (reused, not rebuilt)
dedupeTenderDetails(worker.js:3636) — already keys onid || fullName, handles same-tender-on-multiple-shifts.dedupeStrings+ first-name parsing (worker.js:3374–3378) — footer name list already collected from union of items.fetchTenderInfoFromAssignmentalready loops over assignment IDs and concatenates names; growing the input list from one shift’s IDs to N shifts’ IDs is the only change at the call site.- TV rotation in
syncTakeoverTenderRotation(tv/script.js:1250) already iteratescardsof any length. formatTimeRange(worker.js:3646) emits the en-dash that the frontend split regex[–—-]already handles.
NOT in scope
- Per-shift handoff badges (“Dawood until 11:30 · Veronica until 2:00”) — Approach B from /office-hours. Deferred until product asks for it.
- Full shift timeline model — Approach C. Overkill for this bug.
- Bootstrapping a test framework (vitest + miniflare) — filed as TODO. Manual staging checks acceptable for ship.
- iPad rotator polish — separate work.
- Notion-side “Last Call” workflow documentation — filed as TODO; logic change implies “Last Call only on closing shift,” and that’s a doc/training change for whoever fills shifts in Notion.
Distribution Plan
Existing Cloudflare Workers deploy via wrangler. No new artifacts.
Dependencies
None. Self-contained worker change.
Worktree parallelization strategy
Sequential implementation — all changes touch worker.js. No parallelization opportunity.
Failure modes (one realistic production failure per new codepath)
| Codepath | Failure | Detected? | User-visible? |
|---|---|---|---|
findActiveShifts | Returns shift with corrupt endDate (NaN) — predicate skips it; tender silently disappears | No test, no error log | Silent: tender just doesn’t appear |
mergeIntervals | Sort instability with identical start times (same shift on both database queries) | dedupeTenderDetails downstream catches dup tenders, but envelope could double-count length | Probably not visible |
computeNightlyEnvelope | now falls in a gap between two merged intervals — returns null after caller already passed findActiveShifts-positive check | Defensive: caller falls back to activeShifts[0] envelope | Not visible |
fetchTenderInfoFromAssignment filter tighten | Active shift has zero assignmentIds (data-entry oversight in Notion) | Existing fallback (empty tenders array) → “No pub tender assigned” copy | Visible: existing failure mode, no regression |
Critical gaps: None — all failure modes either degrade gracefully or are caught by existing defensive code paths.
The Assignment
Before merging: open /api/events?surface=ipad on staging during a faked overlap (test shift in Notion) and paste the pubTender block into a comment on the PR. Compare against today’s single-tender baseline. The diff should show activeTenders.length go from 1 → 2 and hours show the envelope.
Completion summary
- Step 0: Scope Challenge — accepted as-is (1 file, ~50 lines)
- Architecture Review: 1 issue raised (1B+ envelope), resolved
- Code Quality Review: 2 issues raised (DRY mergeIntervals; tightened filter), both accepted
- Test Review: diagram produced, 4 unit-test gaps + 4 manual checks. Unit tests deferred to TODO (no framework).
- Performance Review: no issues
- NOT in scope: written
- What already exists: written
- TODOS.md updates: 4 items
- Failure modes: 0 critical gaps
- Outside voice: skipped (50-line bug fix; not warranted)
- Parallelization: sequential, single file
- Lake Score: 4/4 chose complete option (DRY helper, envelope, filter tighten, TODOs)
GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|---|---|---|---|---|---|
| CEO Review | /plan-ceo-review | Scope & strategy | 0 | — | not run (small bug fix; scope agreed) |
| Eng Review | /plan-eng-review | Architecture & tests (required) | 1 | CLEAN (PLAN) | 3 issues found, all resolved; 0 critical gaps |
| Design Review | /plan-design-review | UI/UX gaps | 0 | — | not run (no new UI primitives) |
| Outside Voice | independent challenge | Cross-model check | 0 | — | skipped (size doesn’t warrant) |
UNRESOLVED: 0 VERDICT: ENG CLEARED — ready to implement.