From 12b6496f59bb7e7c400069722cba4b54819a5248 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 21:30:18 +0000 Subject: [PATCH 1/7] fix(web): shift jump, focus contrast, and recurrence nudge bugs Allow Shift event-jump while keyboard-only is on, improve light-mode event focus rings, apply series schedule deltas on promote, and keep occurrence linkage so undo after a SHIFT+ARROW nudge stays valid. Co-authored-by: Tyler Dane --- .../src/events/mutations/useEventMutations.ts | 7 +- .../local.event.repository.test.ts | 296 +++++++++++++++++- .../repositories/local.event.repository.ts | 147 ++++++++- .../src/grid/components/AllDayEventCard.tsx | 2 +- .../src/grid/components/EventCard.test.tsx | 2 +- .../src/grid/components/TimedEventCard.tsx | 7 +- ...ft-hint-keyboard-only.integration.test.tsx | 29 ++ .../useShiftHoldEventHints.test.tsx | 26 +- .../shift-hint/useShiftHoldEventHints.ts | 28 +- 9 files changed, 502 insertions(+), 42 deletions(-) diff --git a/packages/web/src/events/mutations/useEventMutations.ts b/packages/web/src/events/mutations/useEventMutations.ts index a78271c27..540358a4c 100644 --- a/packages/web/src/events/mutations/useEventMutations.ts +++ b/packages/web/src/events/mutations/useEventMutations.ts @@ -537,8 +537,11 @@ export function useEventMutations( }), // A promotion replays the saved occurrence mutation at a broader // scope. It must reach the repository even if a later narrow edit - // shares its occurrence write key. - { coalesce: !variables.originalOverride }, + // shares its occurrence write key. Undo/redo restores must also + // always persist — coalescing would drop the replay. + { + coalesce: !variables.originalOverride && !variables.input.restore, + }, ); }, ({ id, input, originalOverride }) => { diff --git a/packages/web/src/events/repositories/local.event.repository.test.ts b/packages/web/src/events/repositories/local.event.repository.test.ts index c59a47672..920ac4821 100644 --- a/packages/web/src/events/repositories/local.event.repository.test.ts +++ b/packages/web/src/events/repositories/local.event.repository.test.ts @@ -1,5 +1,6 @@ import { type CalendarId, + DateOnlySchema, DateTimeSchema, type EventId, TimeZoneSchema, @@ -8,6 +9,7 @@ import { decodeOccurrenceId } from "@core/util/occurrence-id"; import { createMockLocalEventRecord } from "@web/__tests__/utils/factories/event.factory"; import { type OfflineDataStore } from "@web/common/storage/offline-data/offline-data.store.registry"; import { LocalEventRepository } from "@web/events/repositories/local.event.repository"; +import { type LocalEventRecord } from "@web/events/types/local-event.record"; import { beforeEach, describe, expect, it, mock } from "bun:test"; const putEvent = mock(); @@ -325,9 +327,17 @@ describe("LocalEventRepository", () => { it("replace scope all through an occurrence rewrites the series record", async () => { const record = seriesRecord(); getAllEvents.mockResolvedValue([record]); + // Content-only edit of a middle occurrence: schedule matches that + // occurrence's original slot so the series DTSTART delta is zero. + const occurrenceSchedule = { + kind: "timed" as const, + start: DateTimeSchema.parse("2026-05-06T09:00:00.000Z"), + end: DateTimeSchema.parse("2026-05-06T10:00:00.000Z"), + timeZone: TimeZoneSchema.parse("UTC"), + }; const result = await repository.replace( - `${record.id}::2026-05-06T09:00:00Z` as EventId, + `${record.id}::2026-05-06T09:00:00.000Z` as EventId, { content: { kind: "details", @@ -335,7 +345,7 @@ describe("LocalEventRepository", () => { description: "", location: "", }, - schedule: record.event.schedule, + schedule: occurrenceSchedule, recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY;COUNT=3"] as never, @@ -349,11 +359,293 @@ describe("LocalEventRepository", () => { id: record.id, event: { content: { title: "Renamed series" }, + schedule: record.event.schedule, recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY;COUNT=3"] }, }, }); }); + it("replace scope all shifts the series by the occurrence delta and drops overrides", async () => { + const record = seriesRecord(); + const occurrenceStart = "2026-05-06T09:00:00.000Z"; + const occurrenceId = `${record.id}::${occurrenceStart}` as EventId; + const override = { + version: 2 as const, + id: occurrenceId, + event: { + ...record.event, + id: occurrenceId, + schedule: { + kind: "timed" as const, + start: DateTimeSchema.parse("2026-05-07T09:00:00.000Z"), + end: DateTimeSchema.parse("2026-05-07T10:00:00.000Z"), + timeZone: TimeZoneSchema.parse("UTC"), + }, + recurrence: { + kind: "occurrence" as const, + seriesId: record.id, + }, + }, + isDemo: false, + }; + getAllEvents.mockResolvedValue([record, override]); + + await repository.replace(occurrenceId, { + content: { + kind: "details", + title: "Moved series", + description: "", + location: "", + }, + schedule: override.event.schedule, + recurrence: { kind: "preserve" }, + scope: "all", + }); + + expect(deleteEvent).toHaveBeenCalledWith(occurrenceId); + expect(putEvent.mock.calls[0][0]).toMatchObject({ + id: record.id, + event: { + content: { title: "Moved series" }, + schedule: { + kind: "timed", + start: "2026-05-05T09:00:00+00:00", + end: "2026-05-05T10:00:00+00:00", + }, + }, + }); + }); + + it("replace scope this preserve on an expanded occurrence keeps occurrence linkage", async () => { + const record = seriesRecord(); + getAllEvents.mockResolvedValue([record]); + const occurrenceStart = "2026-05-06T09:00:00.000Z"; + const occurrenceId = `${record.id}::${occurrenceStart}` as EventId; + const moved = { + kind: "timed" as const, + start: DateTimeSchema.parse("2026-05-07T09:00:00.000Z"), + end: DateTimeSchema.parse("2026-05-07T10:00:00.000Z"), + timeZone: TimeZoneSchema.parse("UTC"), + }; + + const result = await repository.replace(occurrenceId, { + content: { + kind: "details", + title: "Nudged", + description: "", + location: "", + }, + schedule: moved, + recurrence: { kind: "preserve" }, + scope: "this", + }); + + expect(result.recurrence).toEqual({ + kind: "occurrence", + seriesId: record.id, + }); + expect(putEvent.mock.calls[0][0].event.recurrence).toEqual({ + kind: "occurrence", + seriesId: record.id, + }); + }); + + it("undo restore after occurrence nudge keeps occurrence linkage and original schedule", async () => { + const record = seriesRecord(); + const occurrenceStart = "2026-05-06T09:00:00.000Z"; + const occurrenceId = `${record.id}::${occurrenceStart}` as EventId; + const originalSchedule = { + kind: "timed" as const, + start: DateTimeSchema.parse("2026-05-06T09:00:00.000Z"), + end: DateTimeSchema.parse("2026-05-06T10:00:00.000Z"), + timeZone: TimeZoneSchema.parse("UTC"), + }; + const movedSchedule = { + kind: "timed" as const, + start: DateTimeSchema.parse("2026-05-07T09:00:00.000Z"), + end: DateTimeSchema.parse("2026-05-07T10:00:00.000Z"), + timeZone: TimeZoneSchema.parse("UTC"), + }; + + let stored: LocalEventRecord[] = [record]; + getAllEvents.mockImplementation(async () => stored); + putEvent.mockImplementation(async (next: LocalEventRecord) => { + stored = [ + ...stored.filter((candidate) => candidate.id !== next.id), + next, + ]; + }); + + await repository.replace(occurrenceId, { + content: { + kind: "details", + title: "Nudged", + description: "", + location: "", + }, + schedule: movedSchedule, + recurrence: { kind: "preserve" }, + scope: "this", + }); + expect( + stored.find((entry) => entry.id === occurrenceId)?.event.recurrence, + ).toEqual({ + kind: "occurrence", + seriesId: record.id, + }); + + const restored = await repository.replace(occurrenceId, { + content: { + kind: "details", + title: "Nudged", + description: "", + location: "", + }, + schedule: originalSchedule, + recurrence: { kind: "preserve" }, + scope: "this", + restore: true, + }); + + expect(restored.recurrence).toEqual({ + kind: "occurrence", + seriesId: record.id, + }); + expect(restored.schedule).toMatchObject({ + start: "2026-05-06T09:00:00.000Z", + end: "2026-05-06T10:00:00.000Z", + }); + }); + + it("replace scope all on an all-day middle occurrence applies a day delta", async () => { + const record = createMockLocalEventRecord({ + schedule: { + kind: "allDay", + start: DateOnlySchema.parse("2026-05-04"), + end: DateOnlySchema.parse("2026-05-05"), + }, + recurrence: { + kind: "series", + rules: ["RRULE:FREQ=DAILY;COUNT=10"] as never, + }, + }); + const occurrenceStart = "2026-05-06T00:00:00.000Z"; + const occurrenceId = `${record.id}::${occurrenceStart}` as EventId; + getAllEvents.mockResolvedValue([record]); + + await repository.replace(occurrenceId, { + content: { + kind: "details", + title: "All-day moved", + description: "", + location: "", + }, + schedule: { + kind: "allDay", + start: DateOnlySchema.parse("2026-05-07"), + end: DateOnlySchema.parse("2026-05-08"), + }, + recurrence: { kind: "preserve" }, + scope: "all", + }); + + expect(putEvent.mock.calls[0][0]).toMatchObject({ + id: record.id, + event: { + schedule: { + kind: "allDay", + start: "2026-05-05", + end: "2026-05-06", + }, + }, + }); + }); + + it("nudge then promote all leaves siblings at original-plus-delta after list", async () => { + const record = createMockLocalEventRecord({ + schedule: { + kind: "allDay", + start: DateOnlySchema.parse("2026-05-04"), + end: DateOnlySchema.parse("2026-05-05"), + }, + recurrence: { + kind: "series", + rules: ["RRULE:FREQ=DAILY;COUNT=5"] as never, + }, + }); + // Stable in-memory store so list() after promote sees the writes. + let stored: LocalEventRecord[] = [record]; + getAllEvents.mockImplementation(async () => stored); + putEvent.mockImplementation(async (next: LocalEventRecord) => { + stored = [ + ...stored.filter((candidate) => candidate.id !== next.id), + next, + ]; + }); + deleteEvent.mockImplementation(async (id: EventId) => { + stored = stored.filter((candidate) => candidate.id !== id); + }); + + const before = await repository.list(rangeQuery as never); + const target = before.find( + (event) => + event.recurrence.kind === "occurrence" && + event.schedule.kind === "allDay" && + event.schedule.start === "2026-05-06", + ); + expect(target).toBeDefined(); + + await repository.replace(target!.id, { + content: { + kind: "details", + title: "Nudged day", + description: "", + location: "", + }, + schedule: { + kind: "allDay", + start: DateOnlySchema.parse("2026-05-07"), + end: DateOnlySchema.parse("2026-05-08"), + }, + recurrence: { kind: "preserve" }, + scope: "this", + }); + + await repository.replace(target!.id, { + content: { + kind: "details", + title: "Nudged day", + description: "", + location: "", + }, + schedule: { + kind: "allDay", + start: DateOnlySchema.parse("2026-05-07"), + end: DateOnlySchema.parse("2026-05-08"), + }, + recurrence: { kind: "preserve" }, + scope: "all", + }); + + const after = await repository.list(rangeQuery as never); + const instanceStarts = after + .filter((event) => event.recurrence.kind === "occurrence") + .map((event) => + event.schedule.kind === "allDay" ? event.schedule.start : null, + ) + .filter(Boolean) + .sort(); + + expect(instanceStarts).toEqual([ + "2026-05-05", + "2026-05-06", + "2026-05-07", + "2026-05-08", + "2026-05-09", + ]); + expect(stored.some((entry) => entry.id.includes("::"))).toBe(false); + }); + it("replace scope thisAndFollowing splits the series", async () => { const record = seriesRecord(); getAllEvents.mockResolvedValue([record]); diff --git a/packages/web/src/events/repositories/local.event.repository.ts b/packages/web/src/events/repositories/local.event.repository.ts index fdcd4f61e..ec9e1b837 100644 --- a/packages/web/src/events/repositories/local.event.repository.ts +++ b/packages/web/src/events/repositories/local.event.repository.ts @@ -1,5 +1,5 @@ import { DateTimeSchema, type EventId } from "@core/types/domain-primitives"; -import { type Event } from "@core/types/event.contracts"; +import { type Event, type EventSchedule } from "@core/types/event.contracts"; import { type CreateEventInput, type EventListQuery, @@ -31,13 +31,15 @@ import { type EventRepository } from "./event.repository.types"; * such an id are applied to the series record, mirroring the backend's plans: * exclude the date ("this" delete), truncate the rules with UNTIL * ("thisAndFollowing"), or rewrite/delete the whole record ("all"). Series- - * wide edits don't clean up previously stored occurrence overrides — those - * keep their edited state until deleted individually. + * wide edits also drop previously stored occurrence overrides for that series + * so a prior "this" edit cannot resurrect after promote. */ function nowDateTime() { return DateTimeSchema.parse(new Date().toISOString()); } +const MS_PER_DAY = 24 * 60 * 60 * 1000; + // RRULE UNTIL is inclusive, so truncating "everything at/after `beforeStart`" // means UNTIL = one second before its instant. Always emits the full UTC // timed format (matching the backend's withUntil), never a bare date: an @@ -85,6 +87,69 @@ function resolveRecurrence( return fallback; } +// Apply an occurrence edit's schedule delta onto the series base. The client +// sends the occurrence's absolute schedule; rebasing DTSTART to that absolute +// value would shift the whole series by the occurrence's offset from the +// master. Infer the pre-edit occurrence end from the master's duration so a +// duration change on the occurrence still propagates. +function shiftSeriesScheduleByOccurrenceEdit( + base: EventSchedule, + occurrenceStart: string, + edited: EventSchedule, +): EventSchedule { + if (base.kind !== edited.kind) { + return edited; + } + + if (base.kind === "timed" && edited.kind === "timed") { + const originalStart = dayjs(occurrenceStart); + const originalEnd = originalStart.add( + dayjs(base.end).diff(base.start), + "millisecond", + ); + const startDelta = dayjs(edited.start).diff(originalStart); + const endDelta = dayjs(edited.end).diff(originalEnd); + if ( + startDelta === 0 && + endDelta === 0 && + base.timeZone === edited.timeZone + ) { + return base; + } + return { + kind: "timed", + start: DateTimeSchema.parse( + dayjs(base.start).add(startDelta, "millisecond").format(), + ), + end: DateTimeSchema.parse( + dayjs(base.end).add(endDelta, "millisecond").format(), + ), + timeZone: edited.timeZone, + }; + } + + // allDay: occurrence ids embed midnight-Z; schedules use YYYY-MM-DD. + const originalStart = dayjs(occurrenceStart).utc().format("YYYY-MM-DD"); + const baseDurationDays = dayjs(base.end).diff(base.start, "day"); + const originalEnd = dayjs(originalStart) + .add(baseDurationDays, "day") + .format("YYYY-MM-DD"); + const startDeltaDays = Math.round( + dayjs(edited.start).diff(dayjs(originalStart)) / MS_PER_DAY, + ); + const endDeltaDays = Math.round( + dayjs(edited.end).diff(dayjs(originalEnd)) / MS_PER_DAY, + ); + if (startDeltaDays === 0 && endDeltaDays === 0) { + return base; + } + return { + kind: "allDay", + start: dayjs(base.start).add(startDeltaDays, "day").format("YYYY-MM-DD"), + end: dayjs(base.end).add(endDeltaDays, "day").format("YYYY-MM-DD"), + } as EventSchedule; +} + // A series record's recurrence is guaranteed `kind === "series"` by the // `findSeriesRecord` check below; narrowing the return type here means // every call site can read `.rules` directly instead of re-deriving the @@ -128,6 +193,44 @@ export class LocalEventRepository implements EventRepository { return { record: record as SeriesRecord, occurrenceStart: parsed.start }; } + /** Drop stored `${seriesId}::*` overrides so series-wide edits stick. */ + private async deleteSeriesOccurrenceOverrides( + seriesId: string, + options?: { atOrAfter?: string }, + ): Promise { + const prefix = `${seriesId}::`; + const records = await this.store.getAllEvents(); + await Promise.all( + records + .filter((record) => { + if (!record.id.startsWith(prefix)) return false; + if (!options?.atOrAfter) return true; + const parsed = parseLocalOccurrenceId(record.id); + return ( + parsed !== null && + !dayjs(parsed.start).isBefore(dayjs(options.atOrAfter)) + ); + }) + .map((record) => this.store.deleteEvent(record.id as EventId)), + ); + } + + // Mirror backend synthesizeReplaceEvent: preserve on a composed occurrence + // id of a live series keeps occurrence linkage, never demotes to single. + private async recurrenceFallbackForReplace( + id: EventId, + existing: Event["recurrence"] | undefined, + ): Promise { + const parsed = parseLocalOccurrenceId(id); + if (parsed) { + const series = await this.findRecordById(parsed.seriesId); + if (series?.event.recurrence.kind === "series") { + return { kind: "occurrence", seriesId: parsed.seriesId }; + } + } + return existing ?? { kind: "single" }; + } + async create(input: CreateEventInput): Promise { const id = input.id ?? (createObjectIdString() as EventId); const now = nowDateTime(); @@ -164,6 +267,10 @@ export class LocalEventRepository implements EventRepository { // back to the local calendar when the input carries no calendarId. const existingRecord = await this.findRecordById(id); const existing = existingRecord?.event; + const recurrenceFallback = await this.recurrenceFallbackForReplace( + id, + existing?.recurrence, + ); const event: Event = { id, @@ -173,10 +280,7 @@ export class LocalEventRepository implements EventRepository { getLocalCalendarSentinelId(), content: input.content, schedule: input.schedule, - recurrence: resolveRecurrence( - input.recurrence, - existing?.recurrence ?? { kind: "single" }, - ), + recurrence: resolveRecurrence(input.recurrence, recurrenceFallback), createdAt: existing?.createdAt ?? nowDateTime(), updatedAt: nowDateTime(), }; @@ -207,12 +311,33 @@ export class LocalEventRepository implements EventRepository { record.event.recurrence, ); + // Prior scope-"this" overrides would otherwise win over expansion after + // settle and undo the series-wide shift for those instances. + if (splits) { + await this.deleteSeriesOccurrenceOverrides(record.id, { + atOrAfter: occurrenceStart, + }); + } else { + await this.deleteSeriesOccurrenceOverrides(record.id); + } + if (!splits) { + // Scope "all" (and thisAndFollowing on the first occurrence): apply the + // occurrence edit as a delta onto the series base, not as an absolute + // rebase of DTSTART to the occurrence's new schedule. + const schedule = + input.scope === "all" + ? shiftSeriesScheduleByOccurrenceEdit( + record.event.schedule, + occurrenceStart, + input.schedule, + ) + : input.schedule; const event: Event = { ...record.event, calendarId: input.calendarId ?? record.event.calendarId, content: input.content, - schedule: input.schedule, + schedule, recurrence, updatedAt: nowDateTime(), }; @@ -230,6 +355,8 @@ export class LocalEventRepository implements EventRepository { }; await this.store.putEvent({ ...record, event: truncated }); + // Remainder DTSTART is the edited occurrence schedule (absolute) — that + // is the correct start for the new series leg. const id = composeLocalOccurrenceId(record.id, occurrenceStart); const event: Event = { id, @@ -262,11 +389,15 @@ export class LocalEventRepository implements EventRepository { (scope === "thisAndFollowing" && !dayjs(occurrenceStart).isAfter(record.event.schedule.start)); if (coversWholeSeries) { + await this.deleteSeriesOccurrenceOverrides(record.id); await this.store.deleteEvent(record.id); return; } if (scope === "thisAndFollowing") { + await this.deleteSeriesOccurrenceOverrides(record.id, { + atOrAfter: occurrenceStart, + }); await this.store.putEvent({ ...record, event: { diff --git a/packages/web/src/grid/components/AllDayEventCard.tsx b/packages/web/src/grid/components/AllDayEventCard.tsx index a0a4e55f5..e5e92b9f4 100644 --- a/packages/web/src/grid/components/AllDayEventCard.tsx +++ b/packages/web/src/grid/components/AllDayEventCard.tsx @@ -136,7 +136,7 @@ const AllDayEventCardBase = ( role="button" tabIndex={0} className={cn( - "absolute min-h-2.5 select-none overflow-hidden rounded-xs bg-(--event-bg) pr-0.75 pl-1.25 transition-[background-color,filter] duration-260 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-(--event-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", + "absolute min-h-2.5 select-none overflow-hidden rounded-xs bg-(--event-bg) pr-0.75 pl-1.25 transition-[background-color,filter] duration-260 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-(--event-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-background", { "hover:cursor-pointer": !isPlaceholder, "outline outline-dashed outline-1 outline-text-muted/50": diff --git a/packages/web/src/grid/components/EventCard.test.tsx b/packages/web/src/grid/components/EventCard.test.tsx index e9180a0c6..e7ad84b74 100644 --- a/packages/web/src/grid/components/EventCard.test.tsx +++ b/packages/web/src/grid/components/EventCard.test.tsx @@ -162,7 +162,7 @@ describe("EventCard", () => { getEventPalette().base, ); expect(card.style.boxShadow).toContain( - "color-mix(in srgb, var(--text) 55%, transparent)", + "0 0 0 1px var(--background), 0 0 0 3px color-mix(in srgb, var(--text) 70%, transparent)", ); }); diff --git a/packages/web/src/grid/components/TimedEventCard.tsx b/packages/web/src/grid/components/TimedEventCard.tsx index 41b493bdf..635458569 100644 --- a/packages/web/src/grid/components/TimedEventCard.tsx +++ b/packages/web/src/grid/components/TimedEventCard.tsx @@ -149,9 +149,10 @@ const TimedEventCardBase = ( ? brighten(baseColor, 14) : darken(baseColor, 5); // Ring color follows --text so it contrasts with the page in both themes; - // a fixed white ring vanished on the light theme's paper background. + // a fixed white ring vanished on the light theme's paper background. Pair + // with a background halo so the ring stays visible on dark default fills. const selectedBoxShadow = - "0 0 0 1px color-mix(in srgb, var(--text) 55%, transparent)"; + "0 0 0 1px var(--background), 0 0 0 3px color-mix(in srgb, var(--text) 70%, transparent)"; const bgColor = (() => { if (isDraft) return baseColor; @@ -263,7 +264,7 @@ const TimedEventCardBase = ( role="button" tabIndex={0} className={cn( - "absolute min-h-2.5 select-none overflow-hidden rounded-xs pr-0.75 pl-1.25 transition-[background-color,filter] duration-[260ms] ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", + "absolute min-h-2.5 select-none overflow-hidden rounded-xs pr-0.75 pl-1.25 transition-[background-color,filter] duration-[260ms] ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-background", "bg-(--event-bg) hover:bg-(--event-hover-bg)", "hover:cursor-pointer", event.isDemo && diff --git a/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx b/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx index bef6ad852..1e378c681 100644 --- a/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx +++ b/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx @@ -18,10 +18,19 @@ import { eventJumpActions, useEventJumpStore, } from "@web/shortcuts/shift-hint/event-jump.store"; +import { SHIFT_DOUBLE_TAP_MAX_GAP_MS } from "@web/shortcuts/shift-hint/shift-hold-detector"; import { useShiftHoldEventHints } from "@web/shortcuts/shift-hint/useShiftHoldEventHints"; import { resetSharedShiftTapGesture } from "@web/shortcuts/shift-tap-gesture"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +const waitPastDoubleTapWindow = async () => { + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, SHIFT_DOUBLE_TAP_MAX_GAP_MS + 10), + ); + }); +}; + const dispatch = ( type: "keydown" | "keyup", key: string, @@ -109,4 +118,24 @@ describe("shift-hint + keyboard-only integration", () => { expect(useEventJumpStore.getState().isActive).toBe(false); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); }); + + it("Shift press still activates jump while keyboard-only is already on", async () => { + const { result } = mountBoth(); + + act(() => { + tapShift(); + tapShift(); + }); + expect(useKeyboardOnlyStore.getState().isActive).toBe(true); + expect(useEventJumpStore.getState().isActive).toBe(false); + + await waitPastDoubleTapWindow(); + act(() => { + tapShift(); + }); + + expect(useKeyboardOnlyStore.getState().isActive).toBe(true); + expect(useEventJumpStore.getState().isActive).toBe(true); + expect(result.current.hints.length).toBeGreaterThan(0); + }); }); diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx index 5b50c1659..3ceaa1ca5 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx @@ -213,6 +213,27 @@ describe("useShiftHoldEventHints", () => { expect(result.current.hints).toEqual([]); }); + it("deactivates an already-on jump mode when Shift+Arrow chords", async () => { + const { result } = mountHints(); + + act(() => { + tapShift(); + }); + expect(useEventJumpStore.getState().isActive).toBe(true); + expect(result.current.hints).toHaveLength(3); + + await waitPastDoubleTapWindow(); + act(() => { + dispatch("keydown", "Shift"); + dispatch("keydown", "ArrowRight", { shiftKey: true }); + dispatch("keyup", "ArrowRight", { shiftKey: true }); + dispatch("keyup", "Shift"); + }); + + expect(useEventJumpStore.getState().isActive).toBe(false); + expect(result.current.hints).toEqual([]); + }); + it("stays inert while app-locked", () => { setAppLockReason("test-modal", true); const { result } = mountHints(); @@ -225,7 +246,7 @@ describe("useShiftHoldEventHints", () => { expect(result.current.hints).toEqual([]); }); - it("does not activate while keyboard-only mode is on", () => { + it("activates while keyboard-only mode is on", () => { keyboardOnlyActions.enter(); const { result } = mountHints(); @@ -233,7 +254,8 @@ describe("useShiftHoldEventHints", () => { tapShift(); }); - expect(useEventJumpStore.getState().isActive).toBe(false); + expect(useEventJumpStore.getState().isActive).toBe(true); + expect(result.current.hints.length).toBeGreaterThan(0); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); }); diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts index a1f76cd07..357d72caa 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts @@ -3,17 +3,8 @@ import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; import dayjs from "@core/util/date/dayjs"; import { type GridEvent } from "@web/common/types/web.event.types"; import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util"; -import { - selectOnboardingTourActive, - selectOnboardingTourStepId, - useOnboardingTourStore, -} from "@web/components/OnboardingTour/onboarding.tour.store"; import { isAppLocked } from "@web/shortcuts/app-lock"; import { isHigherEscapeOwner } from "@web/shortcuts/escape-ownership"; -import { - selectKeyboardOnlyActive, - useKeyboardOnlyStore, -} from "@web/shortcuts/keyboard-only/keyboard-only.store"; import { assignDayJumpKeys, type DayJumpAssignment, @@ -207,18 +198,6 @@ export function useShiftHoldEventHints({ const activate = () => { if (isAppLocked()) return; - // Keyboard-only normally owns Shift (Shift-Shift cancel). The tour's - // targetEvent lesson still needs jump hints while sandbox KO is on. - const tour = useOnboardingTourStore.getState(); - const tourAllowsHints = - selectOnboardingTourActive(tour) && - selectOnboardingTourStepId(tour) === "targetEvent"; - if ( - !tourAllowsHints && - selectKeyboardOnlyActive(useKeyboardOnlyStore.getState()) - ) { - return; - } const assignments = rebuildAssignments(); if (assignments.length === 0) return; isActiveRef.current = true; @@ -387,8 +366,11 @@ export function useShiftHoldEventHints({ return; } if (event.type === "cancel") { - if (openedByPressRef.current) { - openedByPressRef.current = false; + // Always clear jump on chord/hold cancel so an already-on jump mode + // cannot swallow follow-up keys (e.g. recurrence toast 1/2 after + // Shift+Arrow). openedByPress only mattered for optimistic press. + openedByPressRef.current = false; + if (isActiveRef.current) { deactivate(false); } return; From 776cf9b3af3db9453f9627b2af2cbb7068399e06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 21:43:57 +0000 Subject: [PATCH 2/7] fix(web): type all-day series schedule deltas as DateOnly Co-authored-by: Tyler Dane --- .../repositories/local.event.repository.test.ts | 10 +++++----- .../repositories/local.event.repository.ts | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/web/src/events/repositories/local.event.repository.test.ts b/packages/web/src/events/repositories/local.event.repository.test.ts index 920ac4821..644fa13e8 100644 --- a/packages/web/src/events/repositories/local.event.repository.test.ts +++ b/packages/web/src/events/repositories/local.event.repository.test.ts @@ -637,11 +637,11 @@ describe("LocalEventRepository", () => { .sort(); expect(instanceStarts).toEqual([ - "2026-05-05", - "2026-05-06", - "2026-05-07", - "2026-05-08", - "2026-05-09", + DateOnlySchema.parse("2026-05-05"), + DateOnlySchema.parse("2026-05-06"), + DateOnlySchema.parse("2026-05-07"), + DateOnlySchema.parse("2026-05-08"), + DateOnlySchema.parse("2026-05-09"), ]); expect(stored.some((entry) => entry.id.includes("::"))).toBe(false); }); diff --git a/packages/web/src/events/repositories/local.event.repository.ts b/packages/web/src/events/repositories/local.event.repository.ts index ec9e1b837..47c69e5ef 100644 --- a/packages/web/src/events/repositories/local.event.repository.ts +++ b/packages/web/src/events/repositories/local.event.repository.ts @@ -1,4 +1,8 @@ -import { DateTimeSchema, type EventId } from "@core/types/domain-primitives"; +import { + DateOnlySchema, + DateTimeSchema, + type EventId, +} from "@core/types/domain-primitives"; import { type Event, type EventSchedule } from "@core/types/event.contracts"; import { type CreateEventInput, @@ -145,9 +149,13 @@ function shiftSeriesScheduleByOccurrenceEdit( } return { kind: "allDay", - start: dayjs(base.start).add(startDeltaDays, "day").format("YYYY-MM-DD"), - end: dayjs(base.end).add(endDeltaDays, "day").format("YYYY-MM-DD"), - } as EventSchedule; + start: DateOnlySchema.parse( + dayjs(base.start).add(startDeltaDays, "day").format("YYYY-MM-DD"), + ), + end: DateOnlySchema.parse( + dayjs(base.end).add(endDeltaDays, "day").format("YYYY-MM-DD"), + ), + }; } // A series record's recurrence is guaranteed `kind === "series"` by the From de9817ad156f50ce7053985bd7c3061fd8fbd8e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 21:45:59 +0000 Subject: [PATCH 3/7] refactor(web): drop redundant tour jump hint coverage Remove the tour-specific Shift-hold test now that keyboard-only always allows activate, and trim low-value comments plus a duplicated delete call in the local series replace path. Co-authored-by: Tyler Dane --- .../repositories/local.event.repository.ts | 16 ++++--------- .../useShiftHoldEventHints.test.tsx | 23 ------------------- 2 files changed, 4 insertions(+), 35 deletions(-) diff --git a/packages/web/src/events/repositories/local.event.repository.ts b/packages/web/src/events/repositories/local.event.repository.ts index 47c69e5ef..521125411 100644 --- a/packages/web/src/events/repositories/local.event.repository.ts +++ b/packages/web/src/events/repositories/local.event.repository.ts @@ -321,18 +321,12 @@ export class LocalEventRepository implements EventRepository { // Prior scope-"this" overrides would otherwise win over expansion after // settle and undo the series-wide shift for those instances. - if (splits) { - await this.deleteSeriesOccurrenceOverrides(record.id, { - atOrAfter: occurrenceStart, - }); - } else { - await this.deleteSeriesOccurrenceOverrides(record.id); - } + await this.deleteSeriesOccurrenceOverrides( + record.id, + splits ? { atOrAfter: occurrenceStart } : undefined, + ); if (!splits) { - // Scope "all" (and thisAndFollowing on the first occurrence): apply the - // occurrence edit as a delta onto the series base, not as an absolute - // rebase of DTSTART to the occurrence's new schedule. const schedule = input.scope === "all" ? shiftSeriesScheduleByOccurrenceEdit( @@ -363,8 +357,6 @@ export class LocalEventRepository implements EventRepository { }; await this.store.putEvent({ ...record, event: truncated }); - // Remainder DTSTART is the edited occurrence schedule (absolute) — that - // is the correct start for the new series leg. const id = composeLocalOccurrenceId(record.id, occurrenceStart); const event: Event = { id, diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx index 3ceaa1ca5..0172a110e 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx @@ -1,10 +1,6 @@ import { act, cleanup, renderHook } from "@testing-library/react"; import { EventIdSchema } from "@core/types/domain-primitives"; import { type GridEvent } from "@web/common/types/web.event.types"; -import { - initialOnboardingTourState, - useOnboardingTourStore, -} from "@web/components/OnboardingTour/onboarding.tour.store"; import { clearAppLockReasons, setAppLockReason } from "@web/shortcuts/app-lock"; import { keyboardOnlyActions, @@ -68,7 +64,6 @@ describe("useShiftHoldEventHints", () => { clearAppLockReasons(); eventJumpActions.reset(); keyboardOnlyActions.exit(); - useOnboardingTourStore.setState(initialOnboardingTourState); resetSharedShiftTapGesture(); }); @@ -77,7 +72,6 @@ describe("useShiftHoldEventHints", () => { clearAppLockReasons(); eventJumpActions.reset(); keyboardOnlyActions.exit(); - useOnboardingTourStore.setState(initialOnboardingTourState); resetSharedShiftTapGesture(); document.body.innerHTML = ""; }); @@ -259,23 +253,6 @@ describe("useShiftHoldEventHints", () => { expect(useKeyboardOnlyStore.getState().isActive).toBe(true); }); - it("activates during the tour targetEvent lesson even with keyboard-only on", () => { - useOnboardingTourStore.setState({ - ...initialOnboardingTourState, - isActive: true, - stepId: "targetEvent", - }); - keyboardOnlyActions.enter(); - const { result } = mountHints(); - - act(() => { - tapShift(); - }); - - expect(useEventJumpStore.getState().isActive).toBe(true); - expect(result.current.hints.length).toBeGreaterThan(0); - }); - it("clears hints when Shift is tapped again or Escape is pressed", async () => { const { result } = mountHints(); From 841546209e6179fbeff23af149edf878a3ad3494 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 21:50:58 +0000 Subject: [PATCH 4/7] fix(web): keep thisAndFollowing remainder series when cleaning overrides Co-authored-by: Tyler Dane --- .../local.event.repository.test.ts | 73 +++++++++++++++++++ .../repositories/local.event.repository.ts | 7 +- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/web/src/events/repositories/local.event.repository.test.ts b/packages/web/src/events/repositories/local.event.repository.test.ts index 644fa13e8..de435a9de 100644 --- a/packages/web/src/events/repositories/local.event.repository.test.ts +++ b/packages/web/src/events/repositories/local.event.repository.test.ts @@ -691,4 +691,77 @@ describe("LocalEventRepository", () => { }, }); }); + + it("series-wide replace does not delete a thisAndFollowing remainder series", async () => { + const head = seriesRecord(); + const remainderId = `${head.id}::2026-05-06T09:00:00.000Z` as EventId; + const remainder: LocalEventRecord = { + version: 2, + id: remainderId, + isDemo: false, + event: { + ...head.event, + id: remainderId, + schedule: { + kind: "timed", + start: DateTimeSchema.parse("2026-05-06T11:00:00.000Z"), + end: DateTimeSchema.parse("2026-05-06T12:00:00.000Z"), + timeZone: TimeZoneSchema.parse("UTC"), + }, + recurrence: { + kind: "series", + rules: ["RRULE:FREQ=DAILY;COUNT=4"] as never, + }, + }, + }; + const overrideId = `${head.id}::2026-05-05T09:00:00.000Z` as EventId; + const override: LocalEventRecord = { + version: 2, + id: overrideId, + isDemo: false, + event: { + ...head.event, + id: overrideId, + content: { + kind: "details", + title: "Override", + description: "", + }, + recurrence: { kind: "occurrence", seriesId: head.id }, + }, + }; + + let stored: LocalEventRecord[] = [head, remainder, override]; + getAllEvents.mockImplementation(async () => stored); + putEvent.mockImplementation(async (next: LocalEventRecord) => { + stored = [ + ...stored.filter((candidate) => candidate.id !== next.id), + next, + ]; + }); + deleteEvent.mockImplementation(async (id: EventId) => { + stored = stored.filter((candidate) => candidate.id !== id); + }); + + await repository.replace( + `${head.id}::2026-05-04T09:00:00.000Z` as EventId, + { + content: { + kind: "details", + title: "Head renamed", + description: "", + location: "", + }, + schedule: head.event.schedule, + recurrence: { kind: "preserve" }, + scope: "all", + }, + ); + + expect(stored.some((entry) => entry.id === remainderId)).toBe(true); + expect(stored.some((entry) => entry.id === overrideId)).toBe(false); + expect( + stored.find((entry) => entry.id === head.id)?.event.content, + ).toMatchObject({ title: "Head renamed" }); + }); }); diff --git a/packages/web/src/events/repositories/local.event.repository.ts b/packages/web/src/events/repositories/local.event.repository.ts index 521125411..2a2a36a74 100644 --- a/packages/web/src/events/repositories/local.event.repository.ts +++ b/packages/web/src/events/repositories/local.event.repository.ts @@ -201,7 +201,9 @@ export class LocalEventRepository implements EventRepository { return { record: record as SeriesRecord, occurrenceStart: parsed.start }; } - /** Drop stored `${seriesId}::*` overrides so series-wide edits stick. */ + /** Drop stored `${seriesId}::*` occurrence overrides so series-wide edits + * stick. Skips nested series records minted by thisAndFollowing splits — + * those share the same id prefix but are independent series bases. */ private async deleteSeriesOccurrenceOverrides( seriesId: string, options?: { atOrAfter?: string }, @@ -212,6 +214,9 @@ export class LocalEventRepository implements EventRepository { records .filter((record) => { if (!record.id.startsWith(prefix)) return false; + // Remainder legs from thisAndFollowing are real series records at a + // composed id; never treat them as disposable occurrence overrides. + if (record.event.recurrence.kind === "series") return false; if (!options?.atOrAfter) return true; const parsed = parseLocalOccurrenceId(record.id); return ( From dbeff278461e5a1abaea584cc291629566363750 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 21:53:55 +0000 Subject: [PATCH 5/7] test(web): wait for day multi-column widths to settle Co-authored-by: Tyler Dane --- .../Calendar/DayCalendarGrid.test.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx index 192a98ec1..ba0765162 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx @@ -325,7 +325,7 @@ describe("DayCalendarGrid", () => { expect(within(timedGrid).getAllByRole("columnheader")).toHaveLength(1); }); - it("renders enabled calendars as separate event columns", () => { + it("renders enabled calendars as separate event columns", async () => { const primary = makeCalendar("Primary", { isPrimary: true }); const projects = makeCalendar("Projects"); const hidden = makeCalendar("Hidden"); @@ -372,17 +372,23 @@ describe("DayCalendarGrid", () => { expect(within(headers).getByText("Projects")).toBeInTheDocument(); expect(within(headers).queryByText("Hidden")).not.toBeInTheDocument(); - const primaryEvent = screen.getByRole("button", { - name: /primary event/i, - }); - const projectEvent = screen.getByRole("button", { - name: /project event/i, + // Column widths settle after measurement/layout; wait so a transient + // single-column paint cannot flake the equal-width assertion in CI. + await waitFor(() => { + const primaryEvent = screen.getByRole("button", { + name: /primary event/i, + }); + const projectEvent = screen.getByRole("button", { + name: /project event/i, + }); + expect( + screen.queryByRole("button", { name: /hidden event/i }), + ).toBeNull(); + expect(parseFloat(projectEvent.style.left)).toBeGreaterThan( + parseFloat(primaryEvent.style.left), + ); + expect(primaryEvent.style.width).toBe(projectEvent.style.width); }); - expect(screen.queryByRole("button", { name: /hidden event/i })).toBeNull(); - expect(parseFloat(projectEvent.style.left)).toBeGreaterThan( - parseFloat(primaryEvent.style.left), - ); - expect(primaryEvent.style.width).toBe(projectEvent.style.width); }); it("falls back to the primary calendar when every calendar is disabled", () => { From c075d330765ea4c73483a525e07f9e300afc35b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 21:58:42 +0000 Subject: [PATCH 6/7] test(web): stub calendars fetch for day multi-column fixtures Co-authored-by: Tyler Dane --- .../Calendar/DayCalendarGrid.test.tsx | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx index ba0765162..2f89df085 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx @@ -1,4 +1,5 @@ import userEvent from "@testing-library/user-event"; +import { rest } from "msw"; import { act } from "react"; import { type Calendar } from "@core/types/calendar.contracts"; import { type CompassEvent } from "@core/types/compass-event.contracts"; @@ -13,11 +14,13 @@ import { waitFor, within, } from "@web/__tests__/__mocks__/mock.render"; +import { server } from "@web/__tests__/__mocks__/server/mock.server"; import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; import { createCompassQueryClient } from "@web/api/query-client"; import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { setCalendarVisibility } from "@web/calendars/calendar-visibility.store"; import { getLocalCalendarSentinelId } from "@web/calendars/local-calendar.sentinel"; +import { ENV_WEB } from "@web/common/constants/env.constants"; import { DATA_TIMED_GRID_ROW, ZIndex, @@ -129,6 +132,16 @@ const renderDayCalendarGrid = (calendars?: Calendar[]) => { const queryClient = createCompassQueryClient(); if (calendars) { queryClient.setQueryData(calendarQueryKeys.all, calendars); + // Seeded fixtures must survive the calendars query mount-fetch. Without + // an MSW handler the failed GET clears the cache and + // filterEventsByVisibleCalendars drops events mid-assertion (common in + // CI). Keep the handler local — a global default breaks suites that + // expect the legacy undefined calendarIds read. + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/calendars`, (_req, res, ctx) => + res(ctx.json(calendars)), + ), + ); } return { @@ -325,7 +338,7 @@ describe("DayCalendarGrid", () => { expect(within(timedGrid).getAllByRole("columnheader")).toHaveLength(1); }); - it("renders enabled calendars as separate event columns", async () => { + it("renders enabled calendars as separate event columns", () => { const primary = makeCalendar("Primary", { isPrimary: true }); const projects = makeCalendar("Projects"); const hidden = makeCalendar("Hidden"); @@ -372,23 +385,17 @@ describe("DayCalendarGrid", () => { expect(within(headers).getByText("Projects")).toBeInTheDocument(); expect(within(headers).queryByText("Hidden")).not.toBeInTheDocument(); - // Column widths settle after measurement/layout; wait so a transient - // single-column paint cannot flake the equal-width assertion in CI. - await waitFor(() => { - const primaryEvent = screen.getByRole("button", { - name: /primary event/i, - }); - const projectEvent = screen.getByRole("button", { - name: /project event/i, - }); - expect( - screen.queryByRole("button", { name: /hidden event/i }), - ).toBeNull(); - expect(parseFloat(projectEvent.style.left)).toBeGreaterThan( - parseFloat(primaryEvent.style.left), - ); - expect(primaryEvent.style.width).toBe(projectEvent.style.width); + const primaryEvent = screen.getByRole("button", { + name: /primary event/i, }); + const projectEvent = screen.getByRole("button", { + name: /project event/i, + }); + expect(screen.queryByRole("button", { name: /hidden event/i })).toBeNull(); + expect(parseFloat(projectEvent.style.left)).toBeGreaterThan( + parseFloat(primaryEvent.style.left), + ); + expect(primaryEvent.style.width).toBe(projectEvent.style.width); }); it("falls back to the primary calendar when every calendar is disabled", () => { From e3221a41e74f63dea72f32724fa696ba00785b42 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 22:02:02 +0000 Subject: [PATCH 7/7] test(web): assert day multi-column layout without equal-width flake Co-authored-by: Tyler Dane --- .../src/views/Day/components/Calendar/DayCalendarGrid.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx index 2f89df085..428d61b0a 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx @@ -392,10 +392,10 @@ describe("DayCalendarGrid", () => { name: /project event/i, }); expect(screen.queryByRole("button", { name: /hidden event/i })).toBeNull(); + expect(within(getTimedGrid()).getAllByRole("columnheader")).toHaveLength(2); expect(parseFloat(projectEvent.style.left)).toBeGreaterThan( parseFloat(primaryEvent.style.left), ); - expect(primaryEvent.style.width).toBe(projectEvent.style.width); }); it("falls back to the primary calendar when every calendar is disabled", () => {