The calendar resolves priority itself, and disagrees with the backend #93

Closed
opened 2026-08-31 20:21:18 +00:00 by claude-bot · 0 comments
Contributor

Found while scoping conflict detection for #48 batch C. This is the same class of bug as #43 — priority resolution not being the one used for real — but on the frontend side, and still open.

The divergence

buildDayMap (frontend/src/components/calendar/utils.ts:44-49) does its own resolution:

const active = events
  .filter(e => e.start <= date && e.end >= date)
  .sort((a, b) => b.priority - a.priority)

const primary = active[0] ?? null
const secondaries = active.slice(1)

services/priority_resolver.get_events_for_date — the one that decides what actually reaches the controller — does three things this does not:

backend calendar
Date pins (event_date_overrides) applied first not fetched at all
User-event shadowing (parent_event_id) parent excluded ignored
Equal-priority tiebreak _SOURCE_RANK (user 3, builtin 2, system 1) JS sort stability, i.e. input order

So the calendar can show a different primary event than the one Iris will push.

This is not theoretical

Against the real dev database, 2026 has 248 days with events, 91 with more than one, and 43 where the top priority is tied — decided by a tiebreak the calendar does not implement:

2026-02-17  tied at 65: Lunar New Year vs Mardi Gras Weekend
2026-02-20  tied at 60: Black History Month vs Ramadan   (and Feb 21-26)

On each of those 43 days the displayed primary is whatever order the API happened to return, while the pushed primary is chosen by source rank.

The pin case is worse

event_date_overrides is never fetched by the calendar — there is no getDateOverride/listDateOverrides in api/index.ts, and buildDayMap(events, schemes, year) has no parameter for it.

So "Promote Secondary" appears not to work. The mutation succeeds and the backend honours the pin on the push path (fixed in #43), but the calendar recomputes from events alone and keeps showing the old primary. From the user's side the button looks broken — which is precisely the symptom #43 set out to fix, reintroduced one layer up.

I have not confirmed this end-to-end against a running instance with a pin set; it is read from the code. Worth verifying before fixing, in case a refetch masks it somewhere I have not spotted.

Why it blocks batch C

Items 1 and 2 of #48 are the day-cell ⚠ conflict icon and a "conflicts only" filter. Both need to know which events contend for a day and which one wins. Building that on buildDayMap would mean encoding the tiebreak a second time, in a third place, and the conflict flag would then disagree with the backend exactly where it matters most — the tied days.

Suggested fix

Give the backend one resolved-day endpoint and let the calendar render it, rather than re-deriving:

GET /events/resolved?year=2026
-> [{ date, primary, secondaries, conflict, pinned }, ...]

built on get_events_for_date, so there is a single source of truth. The conflict flag comes free: it is true when 2+ events share the top (priority, source_rank), which is the honest definition — a winner chosen arbitrarily rather than by priority.

That also removes duplicated date arithmetic and the shadowing/pin logic from the client entirely.

Alternative, if a new endpoint is unwanted: expose event_date_overrides for listing and port shadowing plus source-rank tiebreak into buildDayMap. Cheaper now, but keeps two implementations that must be kept in step — which is what produced this.

Note on scope

Spec §2.4 is explicit that a month-long backdrop being overridden by a higher-priority day event is normal, not a conflict: "Any higher-priority event on a given day naturally overrides the backdrop for that day, without any special configuration." So should mean tied at the top, not merely more than one event — 43 actionable days rather than 91 noisy ones.

Found while scoping conflict detection for #48 batch C. This is the same class of bug as #43 — priority resolution not being the one used for real — but on the frontend side, and still open. ## The divergence `buildDayMap` (`frontend/src/components/calendar/utils.ts:44-49`) does its own resolution: ```ts const active = events .filter(e => e.start <= date && e.end >= date) .sort((a, b) => b.priority - a.priority) const primary = active[0] ?? null const secondaries = active.slice(1) ``` `services/priority_resolver.get_events_for_date` — the one that decides what actually reaches the controller — does three things this does not: | | backend | calendar | |---|---|---| | Date pins (`event_date_overrides`) | applied first | **not fetched at all** | | User-event shadowing (`parent_event_id`) | parent excluded | ignored | | Equal-priority tiebreak | `_SOURCE_RANK` (user 3, builtin 2, system 1) | JS sort stability, i.e. input order | So **the calendar can show a different primary event than the one Iris will push.** ## This is not theoretical Against the real dev database, 2026 has **248 days with events, 91 with more than one, and 43 where the top priority is tied** — decided by a tiebreak the calendar does not implement: ``` 2026-02-17 tied at 65: Lunar New Year vs Mardi Gras Weekend 2026-02-20 tied at 60: Black History Month vs Ramadan (and Feb 21-26) ``` On each of those 43 days the displayed primary is whatever order the API happened to return, while the pushed primary is chosen by source rank. ## The pin case is worse `event_date_overrides` is never fetched by the calendar — there is no `getDateOverride`/`listDateOverrides` in `api/index.ts`, and `buildDayMap(events, schemes, year)` has no parameter for it. **So "Promote Secondary" appears not to work.** The mutation succeeds and the backend honours the pin on the push path (fixed in #43), but the calendar recomputes from events alone and keeps showing the old primary. From the user's side the button looks broken — which is precisely the symptom #43 set out to fix, reintroduced one layer up. I have not confirmed this end-to-end against a running instance with a pin set; it is read from the code. Worth verifying before fixing, in case a refetch masks it somewhere I have not spotted. ## Why it blocks batch C Items 1 and 2 of #48 are the day-cell `⚠ conflict` icon and a "conflicts only" filter. Both need to know which events contend for a day and which one wins. Building that on `buildDayMap` would mean **encoding the tiebreak a second time, in a third place**, and the conflict flag would then disagree with the backend exactly where it matters most — the tied days. ## Suggested fix Give the backend one resolved-day endpoint and let the calendar render it, rather than re-deriving: ``` GET /events/resolved?year=2026 -> [{ date, primary, secondaries, conflict, pinned }, ...] ``` built on `get_events_for_date`, so there is a single source of truth. The `conflict` flag comes free: it is `true` when 2+ events share the top `(priority, source_rank)`, which is the honest definition — a winner chosen arbitrarily rather than by priority. That also removes duplicated date arithmetic and the shadowing/pin logic from the client entirely. **Alternative**, if a new endpoint is unwanted: expose `event_date_overrides` for listing and port shadowing plus source-rank tiebreak into `buildDayMap`. Cheaper now, but keeps two implementations that must be kept in step — which is what produced this. ## Note on scope Spec §2.4 is explicit that a month-long backdrop being overridden by a higher-priority day event is **normal, not a conflict**: *"Any higher-priority event on a given day naturally overrides the backdrop for that day, without any special configuration."* So `⚠` should mean *tied at the top*, not merely *more than one event* — 43 actionable days rather than 91 noisy ones.
claude-bot added this to the v1.0.0 milestone 2026-08-31 20:21:23 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Iris-WLED#93
No description provided.