fix: show events you declined struck through in the calendar #1110

This commit is contained in:
Linus Rath
2026-09-26 21:32:08 +02:00
parent 078e9f9676
commit 09e0e2cfb3
10 changed files with 164 additions and 16 deletions
@@ -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');
});
});
@@ -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(<EventCard event={makeOccurrence('a', 'declined')} variant={variant} currentUserEmails={me} />);
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(
<>
<EventCard event={makeOccurrence('a', 'accepted')} variant="block" currentUserEmails={me} />
<EventCard event={makeOccurrence('b', 'needs-action')} variant="block" currentUserEmails={me} />
</>
);
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(<EventCard event={event} variant="block" currentUserEmails={me} />);
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(<EventCard event={event} variant="block" currentUserEmails={me} />);
expect(screen.getByRole('button').getAttribute('aria-label')).toMatch(/, detail\.cancelled$/);
});
});
+9 -5
View File
@@ -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({
/>
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
<div className={cn("text-sm font-medium truncate", isInactive && "line-through")}>
{ev.title || t("events.no_title")}
</div>
{locationName && (
+4
View File
@@ -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":
@@ -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
/>
<div
+7 -1
View File
@@ -8,6 +8,7 @@ import { EventCard } from "./event-card";
import { CalendarTaskChip } from "./task-chip";
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import { isDeclinedByUser } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -35,6 +36,8 @@ interface CalendarMonthViewProps 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[];
}
/** 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 (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
className={cn("w-1.5 h-1.5 rounded-full", inactive && "opacity-40")}
style={{ backgroundColor: evColor }}
/>
);
@@ -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}
/>
@@ -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}
/>
</div>
);
@@ -513,6 +517,7 @@ export function CalendarWeekView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
/>
<div
+16 -10
View File
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format } from "date-fns";
import { Users } from "@/components/icons";
import { getParticipantCount } from "@/lib/calendar-participants";
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
@@ -24,6 +24,8 @@ interface EventCardProps {
continuesAfter?: boolean;
className?: string;
style?: CSSProperties;
/** The user's calendar addresses, to tell whether they declined the event. */
currentUserEmails?: string[];
}
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
@@ -71,7 +73,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style, currentUserEmails }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -89,9 +91,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
};
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
// deleting it (#572) - render it struck through and dimmed.
// deleting it (#572), and a declined invitation stays on the calendar too
// (#1110) - render both struck through and dimmed.
const isCancelled = event.status === "cancelled";
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
const isDeclined = !isCancelled && isDeclinedByUser(event, currentUserEmails);
const isInactive = isCancelled || isDeclined;
const statusLabel = isCancelled ? t("detail.cancelled") : isDeclined ? t("participants.declined") : null;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
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 }}
/>
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
<span className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -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 && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
)}
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
<span className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -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 }}
>
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
<div className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</div>
{!event.showWithoutTime && (
<div className="opacity-80 text-[10px]">
{timeString}
@@ -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({
+9
View File
@@ -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