From a3a00ba13c01fda49e37a99cb271326e1e9f9be2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 03:44:11 +0000 Subject: [PATCH 1/3] fix(web): align day-view calendar columns with day query window Timed events were matched to the viewed day using civil-day strings in the effective timezone, which could drop secondary columns when UTC midnight disagreed with calendar-local event days. Use dayEventQueryRange overlap instead and apply the calendar TZ hint before paint. Co-authored-by: Tyler Dane --- .../src/timezone/CalendarTimeZoneHintSync.tsx | 4 +- .../Calendar/dayCalendarColumns.util.test.ts | 51 ++++++++++++++++++- .../Calendar/dayCalendarColumns.util.ts | 27 +++++----- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx b/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx index 99716fffe3..89b3562eb2 100644 --- a/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx +++ b/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useLayoutEffect } from "react"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; import { pickCalendarTimeZoneHint } from "@web/timezone/calendar-timezone-hint"; import { setCalendarTimeZoneHint } from "@web/timezone/effective-timezone.store"; @@ -7,7 +7,7 @@ import { setCalendarTimeZoneHint } from "@web/timezone/effective-timezone.store" export function CalendarTimeZoneHintSync() { const { data: calendars } = useCalendarsQuery(); - useEffect(() => { + useLayoutEffect(() => { setCalendarTimeZoneHint( calendars ? pickCalendarTimeZoneHint(calendars) : null, ); diff --git a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts index 005530a735..462e325362 100644 --- a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts +++ b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts @@ -6,11 +6,16 @@ import { import dayjs from "@core/util/date/dayjs"; import { type GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; +import { + resetEffectiveTimeZoneStoreForTests, + setBrowserTimeZoneForTests, + setCalendarTimeZoneHint, +} from "@web/timezone/effective-timezone.store"; import { calendarIdsWithVisibleEventsOnDay, getDayViewCalendars, } from "./dayCalendarColumns.util"; -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; function makeCalendar(overrides: Partial): Calendar { return { @@ -208,6 +213,50 @@ describe("getDayViewCalendars", () => { }); describe("calendarIdsWithVisibleEventsOnDay", () => { + afterEach(() => { + resetEffectiveTimeZoneStoreForTests(); + }); + + it("matches timed events to the viewed day via the day query window, not a separate civil-day string", () => { + setBrowserTimeZoneForTests("UTC"); + setCalendarTimeZoneHint("America/Denver"); + const viewDay = dayjs.tz("2026-11-11T00:15:00", "America/Denver"); + const primary = makeCalendar({ + id: "507f1f77bcf86cd799439011" as Calendar["id"], + name: "Acme", + }); + const secondary = makeCalendar({ + id: "507f1f77bcf86cd799439012" as Calendar["id"], + name: "Design", + }); + const timed = [ + gridEvent({ + _id: "a", + calendarId: primary.id, + startDate: "2026-11-11 09:00:00.000", + endDate: "2026-11-11 10:30:00.000", + timeZone: "America/Denver", + }), + gridEvent({ + _id: "b", + calendarId: secondary.id, + startDate: "2026-11-11 13:00:00.000", + endDate: "2026-11-11 14:30:00.000", + timeZone: "America/Denver", + }), + ]; + + expect( + getDayViewCalendars({ + allDayEvents: [], + calendars: [primary, secondary], + dateInView: viewDay, + hiddenEventIds: new Set(), + timedEvents: timed, + }), + ).toEqual([primary, secondary]); + }); + it("returns an empty set when every event is hidden", () => { const timed = [ gridEvent({ diff --git a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts index dcbb1f1375..ac16a47adc 100644 --- a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts +++ b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts @@ -1,11 +1,11 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { type CalendarId } from "@core/types/domain-primitives"; -import { type Dayjs } from "@core/util/date/dayjs"; +import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { getDefaultTargetCalendar } from "@web/calendars/calendar.util"; import { type GridEvent } from "@web/common/types/web.event.types"; import { isEventVisibleOnGrid } from "@web/events/visibility/filter-events-visible-on-grid"; -import { getEffectiveTimeZone } from "@web/timezone/effective-timezone.store"; -import { inEffectiveTimeZone } from "@web/timezone/in-time-zone"; +import { parseGridScheduleDate } from "@web/timezone/parse-grid-schedule-date"; +import { dayEventQueryRange } from "@web/views/Day/util/day-window.util"; import { isAllDayEventOnDay } from "./dayAllDayRows.util"; type DayViewCalendarColumnInput = { @@ -37,12 +37,14 @@ const eligibleCalendarsInOrder = (calendars: Calendar[]): Calendar[] => { return calendars.slice(0, 1); }; -const CIVIL_DAY_FORMAT = "YYYY-MM-DD"; - -const timedEventCivilDay = (event: GridEvent, viewZone: string) => - inEffectiveTimeZone(event.startDate, event.timeZone ?? viewZone).format( - CIVIL_DAY_FORMAT, - ); +const timedEventOverlapsViewDay = (event: GridEvent, dateInView: Dayjs) => { + const { startDate, endDate } = dayEventQueryRange(dateInView); + const rangeStart = dayjs(startDate); + const rangeEnd = dayjs(endDate); + const eventStart = parseGridScheduleDate(event.startDate, event.timeZone); + const eventEnd = parseGridScheduleDate(event.endDate, event.timeZone); + return eventStart.isBefore(rangeEnd) && eventEnd.isAfter(rangeStart); +}; export const calendarIdsWithVisibleEventsOnDay = ({ allDayEvents, @@ -54,16 +56,13 @@ export const calendarIdsWithVisibleEventsOnDay = ({ "allDayEvents" | "dateInView" | "hiddenEventIds" | "timedEvents" >): Set => { const ids = new Set(); - const viewZone = getEffectiveTimeZone(); - const viewDay = inEffectiveTimeZone(dateInView, viewZone).format( - CIVIL_DAY_FORMAT, - ); for (const event of timedEvents) { if ( !event.calendarId || !isEventVisibleOnGrid(event, hiddenEventIds) || - timedEventCivilDay(event, viewZone) !== viewDay + event.isAllDay || + !timedEventOverlapsViewDay(event, dateInView) ) { continue; } From 23e16ce91fd4a2cc672bb088c2ceb196185ea5ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 04:28:55 +0000 Subject: [PATCH 2/3] test(web): reproduce merge-queue UTC day-column boundary Assert legacy civil-day column matching collapses to primary only at Nov 11 17:23 Denver with UTC browser and no calendar hint, while query-window overlap keeps both calendars. Co-authored-by: Tyler Dane --- .../Calendar/dayCalendarColumns.util.test.ts | 115 ++++++++++++++++-- .../src/views/Life/LifeSidebarContent.tsx | 11 +- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts index 462e325362..3ae7194010 100644 --- a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts +++ b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts @@ -6,17 +6,95 @@ import { import dayjs from "@core/util/date/dayjs"; import { type GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; +import { isEventVisibleOnGrid } from "@web/events/visibility/filter-events-visible-on-grid"; import { + getEffectiveTimeZone, resetEffectiveTimeZoneStoreForTests, setBrowserTimeZoneForTests, - setCalendarTimeZoneHint, } from "@web/timezone/effective-timezone.store"; +import { inEffectiveTimeZone } from "@web/timezone/in-time-zone"; +import { isAllDayEventOnDay } from "./dayAllDayRows.util"; import { calendarIdsWithVisibleEventsOnDay, getDayViewCalendars, } from "./dayCalendarColumns.util"; import { afterEach, describe, expect, it } from "bun:test"; +const CIVIL_DAY_FORMAT = "YYYY-MM-DD"; + +/** Pre-#5229 civil-day matching (main) kept in tests to prove the UTC-boundary miss. */ +function legacyCalendarIdsWithVisibleEventsOnDay({ + allDayEvents, + dateInView, + hiddenEventIds, + timedEvents, +}: { + allDayEvents: GridEvent[]; + dateInView: dayjs.Dayjs; + hiddenEventIds: ReadonlySet; + timedEvents: GridEvent[]; +}): Set { + const ids = new Set(); + const viewZone = getEffectiveTimeZone(); + const viewDay = inEffectiveTimeZone(dateInView, viewZone).format( + CIVIL_DAY_FORMAT, + ); + + for (const event of timedEvents) { + if ( + !event.calendarId || + !isEventVisibleOnGrid(event, hiddenEventIds) || + event.isAllDay + ) { + continue; + } + const eventDay = inEffectiveTimeZone( + event.startDate, + event.timeZone ?? viewZone, + ).format(CIVIL_DAY_FORMAT); + if (eventDay !== viewDay) continue; + ids.add(event.calendarId); + } + + for (const event of allDayEvents) { + if ( + !event.calendarId || + !isEventVisibleOnGrid(event, hiddenEventIds) || + !isAllDayEventOnDay(event, dateInView) + ) { + continue; + } + ids.add(event.calendarId); + } + + return ids; +} + +function legacyGetDayViewCalendars(input: { + allDayEvents: GridEvent[]; + calendars: Calendar[]; + dateInView: dayjs.Dayjs; + defaultTargetCalendar?: Calendar; + hiddenEventIds: ReadonlySet; + pinnedCalendarIds?: ReadonlySet; + timedEvents: GridEvent[]; +}): Calendar[] { + const ordered = input.calendars.filter((c) => c.isActive && c.isVisible); + const eventCalendarIds = legacyCalendarIdsWithVisibleEventsOnDay(input); + const withEventsOrPinned = ordered.filter( + (calendar) => + eventCalendarIds.has(calendar.id) || + input.pinnedCalendarIds?.has(calendar.id), + ); + if (withEventsOrPinned.length > 0) return withEventsOrPinned; + const fallback = + input.defaultTargetCalendar ?? + ordered.find((c) => c.isPrimary) ?? + ordered[0] ?? + input.calendars[0]; + return fallback ? [fallback] : []; +} + function makeCalendar(overrides: Partial): Calendar { return { id: "507f1f77bcf86cd799439011" as Calendar["id"], @@ -217,13 +295,11 @@ describe("calendarIdsWithVisibleEventsOnDay", () => { resetEffectiveTimeZoneStoreForTests(); }); - it("matches timed events to the viewed day via the day query window, not a separate civil-day string", () => { - setBrowserTimeZoneForTests("UTC"); - setCalendarTimeZoneHint("America/Denver"); - const viewDay = dayjs.tz("2026-11-11T00:15:00", "America/Denver"); + const mergeQueueBoundaryFixtures = () => { const primary = makeCalendar({ id: "507f1f77bcf86cd799439011" as Calendar["id"], name: "Acme", + isPrimary: true, }); const secondary = makeCalendar({ id: "507f1f77bcf86cd799439012" as Calendar["id"], @@ -245,16 +321,29 @@ describe("calendarIdsWithVisibleEventsOnDay", () => { timeZone: "America/Denver", }), ]; + /** Nov 11 in Denver at 17:23 (00:23 UTC Nov 12); no calendar TZ hint, browser UTC. */ + const dateInView = dayjs.tz("2026-11-11T17:23:00", "America/Denver"); + const input = { + allDayEvents: [] as GridEvent[], + calendars: [primary, secondary], + dateInView, + hiddenEventIds: new Set(), + timedEvents: timed, + }; + return { primary, secondary, input }; + }; + + it("reproduces merge-queue UTC boundary: legacy civil-day match collapses to primary only", () => { + setBrowserTimeZoneForTests("UTC"); + const { primary, secondary, input } = mergeQueueBoundaryFixtures(); expect( - getDayViewCalendars({ - allDayEvents: [], - calendars: [primary, secondary], - dateInView: viewDay, - hiddenEventIds: new Set(), - timedEvents: timed, - }), - ).toEqual([primary, secondary]); + legacyGetDayViewCalendars(input).map((calendar) => calendar.name), + ).toEqual(["Acme"]); + expect(getDayViewCalendars(input).map((calendar) => calendar.name)).toEqual( + ["Acme", "Design"], + ); + expect([primary, secondary].map((c) => c.name)).toEqual(["Acme", "Design"]); }); it("returns an empty set when every event is hidden", () => { diff --git a/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx b/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx index 47af1b1c56..d7542f6286 100644 --- a/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx +++ b/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx @@ -14,6 +14,10 @@ import { ID_LIFE_BIRTH_DATE } from "@web/common/constants/web.constants"; import { ArrowButton } from "@web/components/Button/ArrowButton"; import { NumberInput } from "@web/components/NumberInput/NumberInput"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; +import { + selectPageJumpHintsVisible, + usePageJumpHintStore, +} from "@web/shortcuts/page-jump/page-jump.store"; import { pageJumpAttrs, pageJumpChipAttrs, @@ -46,6 +50,7 @@ export function LifeSidebarContent({ today, onPreferencesChange, }: LifeSidebarContentProps) { + const pageJumpHintsVisible = usePageJumpHintStore(selectPageJumpHintsVisible); const [isBirthDatePickerOpen, setIsBirthDatePickerOpen] = useState(false); const [lifespanInput, setLifespanInput] = useState(() => String(preferences.lifespan), @@ -124,9 +129,9 @@ export function LifeSidebarContent({ /> - {/* Portaled Mod chips do not affect layout; reserve space so chip 3 - sits between the arrows and the title without covering the label. */} - + {pageJumpHintsVisible ? ( + + ) : null}

{LIFE_VARIATIONS[preferences.variation].label}

From a79e3b7924bfeda8ed02bf6e2b78bf4cfa2e7523 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 05:33:04 +0000 Subject: [PATCH 3/3] test(web): boundary regression only; drop Life sidebar leak Remove in-test legacy column matcher and restore LifeSidebarContent from main so #5229 stays scoped to day-view columns. Co-authored-by: Tyler Dane --- .../Calendar/dayCalendarColumns.util.test.ts | 115 +++--------------- .../src/views/Life/LifeSidebarContent.tsx | 11 +- 2 files changed, 17 insertions(+), 109 deletions(-) diff --git a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts index 3ae7194010..93caeff47e 100644 --- a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts +++ b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts @@ -6,95 +6,16 @@ import { import dayjs from "@core/util/date/dayjs"; import { type GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; -import { isEventVisibleOnGrid } from "@web/events/visibility/filter-events-visible-on-grid"; import { - getEffectiveTimeZone, resetEffectiveTimeZoneStoreForTests, setBrowserTimeZoneForTests, } from "@web/timezone/effective-timezone.store"; -import { inEffectiveTimeZone } from "@web/timezone/in-time-zone"; -import { isAllDayEventOnDay } from "./dayAllDayRows.util"; import { calendarIdsWithVisibleEventsOnDay, getDayViewCalendars, } from "./dayCalendarColumns.util"; import { afterEach, describe, expect, it } from "bun:test"; -const CIVIL_DAY_FORMAT = "YYYY-MM-DD"; - -/** Pre-#5229 civil-day matching (main) kept in tests to prove the UTC-boundary miss. */ -function legacyCalendarIdsWithVisibleEventsOnDay({ - allDayEvents, - dateInView, - hiddenEventIds, - timedEvents, -}: { - allDayEvents: GridEvent[]; - dateInView: dayjs.Dayjs; - hiddenEventIds: ReadonlySet; - timedEvents: GridEvent[]; -}): Set { - const ids = new Set(); - const viewZone = getEffectiveTimeZone(); - const viewDay = inEffectiveTimeZone(dateInView, viewZone).format( - CIVIL_DAY_FORMAT, - ); - - for (const event of timedEvents) { - if ( - !event.calendarId || - !isEventVisibleOnGrid(event, hiddenEventIds) || - event.isAllDay - ) { - continue; - } - const eventDay = inEffectiveTimeZone( - event.startDate, - event.timeZone ?? viewZone, - ).format(CIVIL_DAY_FORMAT); - if (eventDay !== viewDay) continue; - ids.add(event.calendarId); - } - - for (const event of allDayEvents) { - if ( - !event.calendarId || - !isEventVisibleOnGrid(event, hiddenEventIds) || - !isAllDayEventOnDay(event, dateInView) - ) { - continue; - } - ids.add(event.calendarId); - } - - return ids; -} - -function legacyGetDayViewCalendars(input: { - allDayEvents: GridEvent[]; - calendars: Calendar[]; - dateInView: dayjs.Dayjs; - defaultTargetCalendar?: Calendar; - hiddenEventIds: ReadonlySet; - pinnedCalendarIds?: ReadonlySet; - timedEvents: GridEvent[]; -}): Calendar[] { - const ordered = input.calendars.filter((c) => c.isActive && c.isVisible); - const eventCalendarIds = legacyCalendarIdsWithVisibleEventsOnDay(input); - const withEventsOrPinned = ordered.filter( - (calendar) => - eventCalendarIds.has(calendar.id) || - input.pinnedCalendarIds?.has(calendar.id), - ); - if (withEventsOrPinned.length > 0) return withEventsOrPinned; - const fallback = - input.defaultTargetCalendar ?? - ordered.find((c) => c.isPrimary) ?? - ordered[0] ?? - input.calendars[0]; - return fallback ? [fallback] : []; -} - function makeCalendar(overrides: Partial): Calendar { return { id: "507f1f77bcf86cd799439011" as Calendar["id"], @@ -295,7 +216,8 @@ describe("calendarIdsWithVisibleEventsOnDay", () => { resetEffectiveTimeZoneStoreForTests(); }); - const mergeQueueBoundaryFixtures = () => { + it("keeps both day columns at the merge-queue UTC boundary (browser UTC, no calendar hint)", () => { + setBrowserTimeZoneForTests("UTC"); const primary = makeCalendar({ id: "507f1f77bcf86cd799439011" as Calendar["id"], name: "Acme", @@ -305,6 +227,10 @@ describe("calendarIdsWithVisibleEventsOnDay", () => { id: "507f1f77bcf86cd799439012" as Calendar["id"], name: "Design", }); + const dateInViewBoundary = dayjs.tz( + "2026-11-11T17:23:00", + "America/Denver", + ); const timed = [ gridEvent({ _id: "a", @@ -321,29 +247,16 @@ describe("calendarIdsWithVisibleEventsOnDay", () => { timeZone: "America/Denver", }), ]; - /** Nov 11 in Denver at 17:23 (00:23 UTC Nov 12); no calendar TZ hint, browser UTC. */ - const dateInView = dayjs.tz("2026-11-11T17:23:00", "America/Denver"); - const input = { - allDayEvents: [] as GridEvent[], - calendars: [primary, secondary], - dateInView, - hiddenEventIds: new Set(), - timedEvents: timed, - }; - return { primary, secondary, input }; - }; - - it("reproduces merge-queue UTC boundary: legacy civil-day match collapses to primary only", () => { - setBrowserTimeZoneForTests("UTC"); - const { primary, secondary, input } = mergeQueueBoundaryFixtures(); expect( - legacyGetDayViewCalendars(input).map((calendar) => calendar.name), - ).toEqual(["Acme"]); - expect(getDayViewCalendars(input).map((calendar) => calendar.name)).toEqual( - ["Acme", "Design"], - ); - expect([primary, secondary].map((c) => c.name)).toEqual(["Acme", "Design"]); + getDayViewCalendars({ + allDayEvents: [], + calendars: [primary, secondary], + dateInView: dateInViewBoundary, + hiddenEventIds: new Set(), + timedEvents: timed, + }).map((calendar) => calendar.name), + ).toEqual(["Acme", "Design"]); }); it("returns an empty set when every event is hidden", () => { diff --git a/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx b/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx index d7542f6286..47af1b1c56 100644 --- a/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx +++ b/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx @@ -14,10 +14,6 @@ import { ID_LIFE_BIRTH_DATE } from "@web/common/constants/web.constants"; import { ArrowButton } from "@web/components/Button/ArrowButton"; import { NumberInput } from "@web/components/NumberInput/NumberInput"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; -import { - selectPageJumpHintsVisible, - usePageJumpHintStore, -} from "@web/shortcuts/page-jump/page-jump.store"; import { pageJumpAttrs, pageJumpChipAttrs, @@ -50,7 +46,6 @@ export function LifeSidebarContent({ today, onPreferencesChange, }: LifeSidebarContentProps) { - const pageJumpHintsVisible = usePageJumpHintStore(selectPageJumpHintsVisible); const [isBirthDatePickerOpen, setIsBirthDatePickerOpen] = useState(false); const [lifespanInput, setLifespanInput] = useState(() => String(preferences.lifespan), @@ -129,9 +124,9 @@ export function LifeSidebarContent({ /> - {pageJumpHintsVisible ? ( - - ) : null} + {/* Portaled Mod chips do not affect layout; reserve space so chip 3 + sits between the arrows and the title without covering the label. */} +

{LIFE_VARIATIONS[preferences.variation].label}