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..de435a9de 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([ + 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); + }); + it("replace scope thisAndFollowing splits the series", async () => { const record = seriesRecord(); getAllEvents.mockResolvedValue([record]); @@ -399,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 fdcd4f61e..2a2a36a74 100644 --- a/packages/web/src/events/repositories/local.event.repository.ts +++ b/packages/web/src/events/repositories/local.event.repository.ts @@ -1,5 +1,9 @@ -import { DateTimeSchema, type EventId } from "@core/types/domain-primitives"; -import { type Event } from "@core/types/event.contracts"; +import { + DateOnlySchema, + DateTimeSchema, + type EventId, +} from "@core/types/domain-primitives"; +import { type Event, type EventSchedule } from "@core/types/event.contracts"; import { type CreateEventInput, type EventListQuery, @@ -31,13 +35,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 +91,73 @@ 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: 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 // `findSeriesRecord` check below; narrowing the return type here means // every call site can read `.rules` directly instead of re-deriving the @@ -128,6 +201,49 @@ export class LocalEventRepository implements EventRepository { return { record: record as SeriesRecord, occurrenceStart: parsed.start }; } + /** 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 }, + ): Promise { + const prefix = `${seriesId}::`; + const records = await this.store.getAllEvents(); + await Promise.all( + 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 ( + 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 +280,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 +293,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 +324,27 @@ 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. + await this.deleteSeriesOccurrenceOverrides( + record.id, + splits ? { atOrAfter: occurrenceStart } : undefined, + ); + if (!splits) { + 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(), }; @@ -262,11 +394,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..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 = ""; }); @@ -213,20 +207,29 @@ describe("useShiftHoldEventHints", () => { expect(result.current.hints).toEqual([]); }); - it("stays inert while app-locked", () => { - setAppLockReason("test-modal", true); + 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("does not activate while keyboard-only mode is on", () => { - keyboardOnlyActions.enter(); + it("stays inert while app-locked", () => { + setAppLockReason("test-modal", true); const { result } = mountHints(); act(() => { @@ -234,15 +237,10 @@ describe("useShiftHoldEventHints", () => { }); expect(useEventJumpStore.getState().isActive).toBe(false); - expect(useKeyboardOnlyStore.getState().isActive).toBe(true); + expect(result.current.hints).toEqual([]); }); - it("activates during the tour targetEvent lesson even with keyboard-only on", () => { - useOnboardingTourStore.setState({ - ...initialOnboardingTourState, - isActive: true, - stepId: "targetEvent", - }); + it("activates while keyboard-only mode is on", () => { keyboardOnlyActions.enter(); const { result } = mountHints(); @@ -252,6 +250,7 @@ describe("useShiftHoldEventHints", () => { expect(useEventJumpStore.getState().isActive).toBe(true); expect(result.current.hints.length).toBeGreaterThan(0); + expect(useKeyboardOnlyStore.getState().isActive).toBe(true); }); it("clears hints when Shift is tapped again or Escape is pressed", async () => { 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; 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..428d61b0a 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 { @@ -379,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", () => {