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, /tv including 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 Call property empty.
  • iPad surface gets the fix incidentally; deeper iPad polish is out of scope.

Premises

  1. Root cause of Bug 1: findActiveShift (worker.js:3325) returns one shift via .find().
  2. Root cause of Bug 1B+: fetchPubTenderFromNotion (worker.js:3107–3153) derives hours, note, noteTiming from a single activeShift instead of the night’s pub-close envelope.
  3. Root cause of Bug 2: fetchPubHoursWeek (worker.js:3082) joins per-shift formatTimeRange strings with , without merging overlapping intervals.
  4. Notion shift schema is fine as-is; only worker collapse-to-one logic discards data.
  5. All three surfaces consume pubTender.activeTenders and pubTender.noteTiming — a single worker fix covers them.
  6. No new UI is needed for Bug 1 — the existing TV rotation iterates activeTenders[].

Approach A from /office-hours (minimal union), refined per /plan-eng-review:

  1. DRY: extract one mergeIntervals(intervals) helper used by both Bug 1B+ (nightly envelope) and Bug 2 (hours widget).
  2. Bug 1: findActiveShiftfindActiveShifts.find() becomes .filter(). Returns Shift[].
  3. Bug 1B+: compute nightly envelope — derive hours and noteTiming from the merged interval that contains now (computeNightlyEnvelope(shifts, now)), not from a single shift.
  4. Bug 2: merge intervals in fetchPubHoursWeek before formatting.
  5. Tighten fetchTenderInfoFromAssignment filter — restrict items to assignment IDs from the active-shift union, not just same-todayISO startDate.
  6. No client changes for /, /ipad, /tv — they all read pubTender.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 — dedupeTenderDetails keys on id || 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) — transformShift already handles this; findActiveShifts and the envelope inherit it.
  • Three or more concurrent shifts — mergeIntervals is 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 — findActiveShifts returns [Veronica], envelope = [Veronica’s window], closingShift = Veronica. ✓
  • True gap day (6–8 PM, 10 PM–1 AM) at 9:00 PM — findActiveShifts returns []. 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’s lastCallMinutes.
  • 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 on id || fullName, handles same-tender-on-multiple-shifts.
  • dedupeStrings + first-name parsing (worker.js:3374–3378) — footer name list already collected from union of items.
  • fetchTenderInfoFromAssignment already 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 iterates cards of 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)

CodepathFailureDetected?User-visible?
findActiveShiftsReturns shift with corrupt endDate (NaN) — predicate skips it; tender silently disappearsNo test, no error logSilent: tender just doesn’t appear
mergeIntervalsSort instability with identical start times (same shift on both database queries)dedupeTenderDetails downstream catches dup tenders, but envelope could double-count lengthProbably not visible
computeNightlyEnvelopenow falls in a gap between two merged intervals — returns null after caller already passed findActiveShifts-positive checkDefensive: caller falls back to activeShifts[0] envelopeNot visible
fetchTenderInfoFromAssignment filter tightenActive shift has zero assignmentIds (data-entry oversight in Notion)Existing fallback (empty tenders array) → “No pub tender assigned” copyVisible: 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

ReviewTriggerWhyRunsStatusFindings
CEO Review/plan-ceo-reviewScope & strategy0not run (small bug fix; scope agreed)
Eng Review/plan-eng-reviewArchitecture & tests (required)1CLEAN (PLAN)3 issues found, all resolved; 0 critical gaps
Design Review/plan-design-reviewUI/UX gaps0not run (no new UI primitives)
Outside Voiceindependent challengeCross-model check0skipped (size doesn’t warrant)

UNRESOLVED: 0 VERDICT: ENG CLEARED — ready to implement.