diff --git a/components/calendar/__tests__/calendar-agenda-view.test.tsx b/components/calendar/__tests__/calendar-agenda-view.test.tsx index f56572a56..c340e117a 100644 --- a/components/calendar/__tests__/calendar-agenda-view.test.tsx +++ b/components/calendar/__tests__/calendar-agenda-view.test.tsx @@ -169,4 +169,20 @@ describe('CalendarAgendaView infinite scroll', () => { const titles = screen.getAllByText(/^Event /).map((el) => el.textContent); expect(titles).toEqual(['Event sooner', 'Event later']); }); + + it('strikes through events the user declined (#1110)', () => { + const invite = (id: string, status: string) => ({ + ...makeEvent(id, '2026-09-12T09:00:00'), + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', participationStatus: status }, + }, + }) as unknown as CalendarEvent; + renderView({ + events: [invite('declined', 'declined'), invite('accepted', 'accepted')], + currentUserEmails: ['me@example.com'], + }); + expect(screen.getByText('Event declined')).toHaveClass('line-through'); + expect(screen.getByText('Event declined').closest('button')).toHaveClass('opacity-60'); + expect(screen.getByText('Event accepted')).not.toHaveClass('line-through'); + }); }); diff --git a/components/calendar/__tests__/event-card.test.tsx b/components/calendar/__tests__/event-card.test.tsx new file mode 100644 index 000000000..27bbb69ac --- /dev/null +++ b/components/calendar/__tests__/event-card.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { EventCard } from '../event-card'; +import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; + +// #1110: an invitation the user declined - the whole series or one occurrence +// of an accepted series - stays on the calendar, struck through and dimmed +// like a cancelled event, instead of looking like one they will attend. + +function makeOccurrence(id: string, myStatus: CalendarParticipant['participationStatus']): CalendarEvent { + return { + id, + '@type': 'Event', + uid: 'series', + title: 'Standup ' + id, + start: '2026-09-28T09:00:00', + duration: 'PT30M', + showWithoutTime: false, + status: 'confirmed', + calendarIds: { 'cal-1': true }, + participants: { + org: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.com', participationStatus: 'accepted' }, + me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', participationStatus: myStatus }, + }, + } as unknown as CalendarEvent; +} + +const me = ['me@example.com']; + +describe('EventCard', () => { + afterEach(() => { + cleanup(); + }); + + it.each(['chip', 'span', 'block'] as const)('strikes through an occurrence the user declined (%s)', (variant) => { + render(); + const card = screen.getByRole('button'); + expect(card).toHaveClass('opacity-60'); + expect(card.getAttribute('aria-label')).toMatch(/, participants\.declined$/); + expect(screen.getByText('Standup a')).toHaveClass('line-through'); + }); + + it('leaves accepted and unanswered occurrences alone', () => { + render( + <> + + + + ); + for (const card of screen.getAllByRole('button')) { + expect(card).not.toHaveClass('opacity-60'); + expect(card.getAttribute('aria-label')).not.toMatch(/declined/); + } + expect(screen.getByText('Standup a')).not.toHaveClass('line-through'); + }); + + it('only looks at the user\'s own participant entry', () => { + const event = makeOccurrence('a', 'accepted'); + event.participants!.org.participationStatus = 'declined'; + render(); + expect(screen.getByRole('button')).not.toHaveClass('opacity-60'); + }); + + it('labels a cancelled event as cancelled even when the user declined it', () => { + const event = { ...makeOccurrence('a', 'declined'), status: 'cancelled' } as CalendarEvent; + render(); + expect(screen.getByRole('button').getAttribute('aria-label')).toMatch(/, detail\.cancelled$/); + }); +}); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 4c39cb2c3..fe9d84f81 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"; import { getEventColor } from "./event-card"; import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { displayNow, isDisplayToday } from "@/lib/timezone"; -import { getParticipantCount } from "@/lib/calendar-participants"; +import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants"; import { useScrollWindow } from "@/hooks/use-scroll-window"; import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -22,6 +22,8 @@ interface CalendarAgendaViewProps extends ScrollWindowViewProps { onHoverLeave?: () => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; timeFormat?: "12h" | "24h"; + /** The user's calendar addresses, to mark events they declined (#1110). */ + currentUserEmails?: string[]; } interface DayGroup { @@ -45,6 +47,7 @@ export function CalendarAgendaView({ onHoverLeave, onContextMenuEvent, timeFormat = "24h", + currentUserEmails, }: CalendarAgendaViewProps) { const t = useTranslations("calendar"); // Grid days / event dates are display dates (local fields = wall-clock in @@ -207,8 +210,9 @@ export function CalendarAgendaView({ const start = getEventStartDate(ev); const end = getEventEndDate(ev); // iTIP CANCEL marks the attendee's copy with status "cancelled" - // instead of deleting it (#572). - const isCancelled = ev.status === "cancelled"; + // instead of deleting it (#572); a declined invitation stays + // listed too (#1110). + const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails); const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null; @@ -222,7 +226,7 @@ export function CalendarAgendaView({ onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined} className={cn( "w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start", - isCancelled && "opacity-60" + isInactive && "opacity-60" )} style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} > @@ -245,7 +249,7 @@ export function CalendarAgendaView({ />
-
+
{ev.title || t("events.no_title")}
{locationName && ( diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index c7b21de67..9bde310bf 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1494,6 +1494,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = tasks={calendarTasks} onToggleTaskComplete={handleToggleTaskComplete} onSelectTask={openEditTaskModal} + currentUserEmails={currentUserEmails} /> ); case "week": @@ -1517,6 +1518,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = tasks={calendarTasks} onToggleTaskComplete={handleToggleTaskComplete} onSelectTask={openEditTaskModal} + currentUserEmails={currentUserEmails} /> ); case "day": @@ -1538,6 +1540,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = tasks={calendarTasks} onToggleTaskComplete={handleToggleTaskComplete} onSelectTask={openEditTaskModal} + currentUserEmails={currentUserEmails} /> ); case "agenda": @@ -1551,6 +1554,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} timeFormat={timeFormat} + currentUserEmails={currentUserEmails} /> ); case "tasks": diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 400a8edeb..7cf86fcab 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -33,6 +33,8 @@ interface CalendarDayViewProps extends ScrollWindowViewProps { tasks?: CalendarTask[]; onToggleTaskComplete?: (task: CalendarTask) => void; onSelectTask?: (task: CalendarTask) => void; + /** The user's calendar addresses, to mark events they declined (#1110). */ + currentUserEmails?: string[]; } const HOUR_HEIGHT = 64; @@ -63,6 +65,7 @@ export function CalendarDayView({ tasks, onToggleTaskComplete, onSelectTask, + currentUserEmails, }: CalendarDayViewProps) { const t = useTranslations("calendar"); // Grid days / event dates are display dates (local fields = wall-clock in @@ -328,6 +331,7 @@ export function CalendarDayView({ onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + currentUserEmails={currentUserEmails} /> ); })} @@ -456,6 +460,7 @@ export function CalendarDayView({ onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + currentUserEmails={currentUserEmails} draggable />
void; onSelectTask?: (task: CalendarTask) => void; + /** The user's calendar addresses, to mark events they declined (#1110). */ + currentUserEmails?: string[]; } /** Fraction of the viewport height at which the "current month" is sampled. */ @@ -64,6 +67,7 @@ export function CalendarMonthView({ tasks, onToggleTaskComplete, onSelectTask, + currentUserEmails, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); @@ -344,10 +348,11 @@ export function CalendarMonthView({ const calId = getPrimaryCalendarId(ev); const cal = calId ? calendarMap.get(calId) : undefined; const evColor = ev.color || cal?.color || "#3b82f6"; + const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails); return ( ); @@ -436,6 +441,7 @@ export function CalendarMonthView({ onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + currentUserEmails={currentUserEmails} draggable className={isMobile ? "text-[10px] px-1" : undefined} /> diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 093f65e4d..733bb2a5d 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -37,6 +37,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps { tasks?: CalendarTask[]; onToggleTaskComplete?: (task: CalendarTask) => void; onSelectTask?: (task: CalendarTask) => void; + /** The user's calendar addresses, to mark events they declined (#1110). */ + currentUserEmails?: string[]; } const HOUR_HEIGHT = 60; @@ -70,6 +72,7 @@ export function CalendarWeekView({ tasks, onToggleTaskComplete, onSelectTask, + currentUserEmails, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); // Grid days / event dates are display dates (local fields = wall-clock in @@ -361,6 +364,7 @@ export function CalendarWeekView({ onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + currentUserEmails={currentUserEmails} />
); @@ -513,6 +517,7 @@ export function CalendarWeekView({ onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + currentUserEmails={currentUserEmails} draggable />
{ e.stopPropagation(); @@ -139,7 +145,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM "hover:opacity-80 transition-opacity", isSelected && "ring-2 ring-primary", isBeingDragged && "opacity-50", - isCancelled && !isBeingDragged && "opacity-60", + isInactive && !isBeingDragged && "opacity-60", className )} style={{ backgroundColor: `${color}20`, color, ...style }} @@ -148,7 +154,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} /> - {event.title || t("events.no_title")} + {event.title || t("events.no_title")} ); } @@ -169,7 +175,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM continuesAfter && "pe-2", isSelected && "ring-2 ring-primary", isBeingDragged && "opacity-50", - isCancelled && !isBeingDragged && "opacity-60", + isInactive && !isBeingDragged && "opacity-60", className )} style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }} @@ -178,7 +184,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {showTimeInMonthView && !event.showWithoutTime && ( {format(startDate, timeFmt)} )} - {event.title || t("events.no_title")} + {event.title || t("events.no_title")}
); @@ -198,12 +204,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM "hover:opacity-90 transition-opacity cursor-pointer", isSelected && "ring-2 ring-primary", isBeingDragged && "opacity-50", - isCancelled && !isBeingDragged && "opacity-60", + isInactive && !isBeingDragged && "opacity-60", className )} style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }} > -
{event.title || t("events.no_title")}
+
{event.title || t("events.no_title")}
{!event.showWithoutTime && (
{timeString} diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index ea340e93f..9169d9660 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -5,6 +5,7 @@ import { collectUserCalendarAddresses, getUserParticipantId, getUserStatus, + isDeclinedByUser, getParticipantList, getStatusCounts, getParticipantCount, @@ -284,6 +285,29 @@ describe('getUserStatus', () => { }); }); +describe('isDeclinedByUser', () => { + it("is true only for the user's own declined entry", () => { + const event = makeEvent({ att1: attendeeParticipant, dave: declinedAttendee }); + expect(isDeclinedByUser(event, ['Dave@Example.com'])).toBe(true); + expect(isDeclinedByUser(event, ['bob@example.com'])).toBe(false); + }); + + it('matches the calendar address an occurrence override carries', () => { + // A reply to one occurrence comes back from Stalwart as an override + // participant with only a calendarAddress (#1110). + const event = makeEvent({ + att: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', participationStatus: 'declined' }, + }); + expect(isDeclinedByUser(event, ['alias@example.com', 'bob@example.com'])).toBe(true); + }); + + it('is false without user addresses or participants', () => { + expect(isDeclinedByUser(makeEvent({ dave: declinedAttendee }), [])).toBe(false); + expect(isDeclinedByUser(makeEvent({ dave: declinedAttendee }), undefined)).toBe(false); + expect(isDeclinedByUser(makeEvent(null), ['dave@example.com'])).toBe(false); + }); +}); + describe('getParticipantList', () => { it('returns all participants as info objects', () => { const event = makeEvent({ diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index d7eae1066..25ebb9ffd 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -123,6 +123,15 @@ export function getUserStatus( return null; } +/** + * True when the user declined this event. An occurrence carries its override's + * participants, so one declined instance of an accepted series counts (#1110). + */ +export function isDeclinedByUser(event: CalendarEvent, userEmails: string[] | undefined): boolean { + if (!userEmails || userEmails.length === 0) return false; + return getUserStatus(event, userEmails) === 'declined'; +} + /** When the same address appears in two participant entries, a real RSVP on * either one beats a missing/"needs-action" one — the duplicate is always the * entry that never replied (the needs-action side). Between two real replies