From bc6fee75562fa32355de4620befcb4001226ea3a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:22:24 +0100 Subject: [PATCH] feat: let timed events show as coloured text or a dot in the month view #1449 --- .../calendar-month-view-timed-style.test.tsx | 136 ++++++++++++++++++ components/calendar/calendar-month-view.tsx | 8 +- components/calendar/event-card.tsx | 65 ++++++++- components/settings/calendar-settings.tsx | 18 ++- lib/settings-search.ts | 1 + locales/ar/common.json | 5 + locales/ca/common.json | 5 + locales/cs/common.json | 5 + locales/da/common.json | 5 + locales/de/common.json | 5 + locales/en/common.json | 5 + locales/es/common.json | 5 + locales/et/common.json | 5 + locales/fa/common.json | 5 + locales/fr/common.json | 5 + locales/he/common.json | 5 + locales/hu/common.json | 5 + locales/it/common.json | 5 + locales/ja/common.json | 5 + locales/ko/common.json | 5 + locales/lv/common.json | 5 + locales/mn/common.json | 5 + locales/nb/common.json | 5 + locales/nl/common.json | 5 + locales/pl/common.json | 5 + locales/pt/common.json | 5 + locales/ro/common.json | 5 + locales/ru/common.json | 5 + locales/sk/common.json | 5 + locales/th/common.json | 5 + locales/tr/common.json | 5 + locales/uk/common.json | 5 + locales/zh-TW/common.json | 5 + locales/zh/common.json | 5 + stores/settings-store.ts | 12 ++ 35 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 components/calendar/__tests__/calendar-month-view-timed-style.test.tsx diff --git a/components/calendar/__tests__/calendar-month-view-timed-style.test.tsx b/components/calendar/__tests__/calendar-month-view-timed-style.test.tsx new file mode 100644 index 000000000..24f19cfb8 --- /dev/null +++ b/components/calendar/__tests__/calendar-month-view-timed-style.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { CalendarMonthView } from '../calendar-month-view'; +import type { CalendarEvent, Calendar, CalendarParticipant } from '@/lib/jmap/types'; +import { useSettingsStore, adminPolicyPatch } from '@/stores/settings-store'; + +// #1449: the month view can draw timed events as coloured text or as a dot +// and text, so they don't look like all-day events. All-day and multi-day +// events, and declined or cancelled ones, stay bars. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function makeEvent(id: string, overrides: Partial = {}): CalendarEvent { + return { + id, + '@type': 'Event', + uid: id, + title: 'Event ' + id, + start: '2026-09-09T09:30:00', + duration: 'PT30M', + showWithoutTime: false, + status: 'confirmed', + calendarIds: { 'cal-1': true }, + ...overrides, + } as unknown as CalendarEvent; +} + +function renderView(events: CalendarEvent[]) { + return render( + , + ); +} + +function card(title: string): HTMLElement { + return screen.getByText(title).closest('button')!; +} + +const timed = makeEvent('timed'); +const allDay = makeEvent('allday', { start: '2026-09-10T00:00:00', duration: 'P1D', showWithoutTime: true }); +const multiDay = makeEvent('multi', { start: '2026-09-14T22:00:00', duration: 'PT26H' }); +const cancelled = makeEvent('cancelled', { start: '2026-09-11T10:00:00', status: 'cancelled' }); +const declined = makeEvent('declined', { + start: '2026-09-11T11:00:00', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', participationStatus: 'declined' }, + } as unknown as Record, +}); +const all = [timed, allDay, multiDay, cancelled, declined]; + +describe('month view timed event style (#1449)', () => { + afterEach(() => { + cleanup(); + useSettingsStore.setState({ calendarMonthTimedEvents: 'bar', showTimeInMonthView: false }); + }); + + it('draws every event as a bar by default', () => { + renderView(all); + for (const title of ['Event timed', 'Event allday', 'Event multi']) { + expect(card(title).style.backgroundColor, title).not.toBe(''); + expect(card(title).hasAttribute('data-timed-style'), title).toBe(false); + } + }); + + it('draws a timed event as its time and title in the calendar colour', () => { + useSettingsStore.setState({ calendarMonthTimedEvents: 'text' }); + renderView(all); + const el = card('Event timed'); + expect(el.getAttribute('data-timed-style')).toBe('text'); + expect(el.style.backgroundColor).toBe(''); + expect(el.style.color).toContain('color-mix'); + expect(el.style.color).toContain('#123456'); + // The start time shows even with "Show time in month view" off. + expect(el.textContent).toContain('09:30'); + expect(el.querySelector('[data-event-dot]')).toBeNull(); + }); + + it('puts a dot in the calendar colour before a timed event in the text colour', () => { + useSettingsStore.setState({ calendarMonthTimedEvents: 'dot' }); + renderView(all); + const el = card('Event timed'); + expect(el.getAttribute('data-timed-style')).toBe('dot'); + expect(el.style.backgroundColor).toBe(''); + expect(el.style.color).toBe('var(--color-foreground)'); + expect(el.querySelector('[data-event-dot]')?.style.backgroundColor).toBe('rgb(18, 52, 86)'); + expect(el.textContent).toContain('09:30'); + }); + + it.each(['text', 'dot'] as const)('keeps all-day, multi-day, cancelled and declined events as they were (%s)', (style) => { + useSettingsStore.setState({ calendarMonthTimedEvents: style }); + renderView(all); + for (const title of ['Event allday', 'Event multi']) { + expect(card(title).hasAttribute('data-timed-style'), title).toBe(false); + expect(card(title).style.backgroundColor, title).not.toBe(''); + } + for (const title of ['Event cancelled', 'Event declined']) { + expect(card(title).hasAttribute('data-timed-style'), title).toBe(false); + expect(card(title).style.boxShadow, title).toContain('inset 0 0 0 1px'); + expect(screen.getByText(title)).toHaveClass('line-through'); + } + }); + + it('keeps the selection ring on a text-style event', () => { + useSettingsStore.setState({ calendarMonthTimedEvents: 'text' }); + render( + , + ); + expect(card('Event timed').style.boxShadow).toContain('var(--color-primary)'); + }); + + it('can be preset by an admin policy, and only with a known style', () => { + expect(adminPolicyPatch({}, { defaults: { calendarMonthTimedEvents: 'dot' } })).toEqual({ calendarMonthTimedEvents: 'dot' }); + expect(adminPolicyPatch({}, { defaults: { calendarMonthTimedEvents: 'stripes' } })).toEqual({}); + }); +}); diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index d7ed181bd..b4b82b1d4 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -13,7 +13,7 @@ import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { movedEventUpdates } from "@/lib/calendar-participants"; -import { useSettingsStore } from "@/stores/settings-store"; +import { useSettingsStore, type MonthTimedEventStyle } from "@/stores/settings-store"; import type { PendingEventPreview } from "./event-modal"; import { toast } from "@/stores/toast-store"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; @@ -98,6 +98,7 @@ export function CalendarMonthView({ const paint = useCvdColor(); const describeError = useCalendarErrorMessage(); const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); + const timedStyle = useSettingsStore((state) => state.calendarMonthTimedEvents); // On mobile the month view collapses events to dots unless the user opted // into full entries via "Show time in month view" (#666). const showChips = !isMobile || showTimeInMonthView; @@ -531,6 +532,7 @@ export function CalendarMonthView({ currentUserEmails={currentUserEmails} canEditEvent={canEditEvent} inView={(d) => checkIsSameMonth(d, visibleMonthDate)} + timedStyle={timedStyle} onSelectEvent={onSelectEvent} onHoverEvent={onHoverEvent} onHoverLeave={onHoverLeave} @@ -567,7 +569,7 @@ type WeekSegment = ReturnType[number]; */ function MonthCellEntries({ dayIndex, week, segments, rowLimit, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight, - isMobile, calendarMap, pendingPreview, selectedEventId, currentUserEmails, canEditEvent, inView, + isMobile, calendarMap, pendingPreview, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle, onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, paint, }: { dayIndex: number; @@ -589,6 +591,7 @@ function MonthCellEntries({ canEditEvent: (event: CalendarEvent) => boolean; /** Whether a day is in the month in view: only those take Tab. */ inView: (day: Date) => boolean; + timedStyle?: MonthTimedEventStyle; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; @@ -636,6 +639,7 @@ function MonthCellEntries({ isSelected={segment.event.id === selectedEventId} calendar={calId ? calendarMap.get(calId) : undefined} variant="span" + timedStyle={segment.span === 1 ? timedStyle : undefined} continuesBefore={segment.continuesBefore} continuesAfter={segment.continuesAfter} onClick={(rect) => onSelectEvent(segment.event, rect)} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index f7cbe1e31..d53f88b36 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -8,7 +8,7 @@ import { format } from "date-fns"; import { ChevronDown, ChevronUp, Users } from "@/components/icons"; import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants"; import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; -import { useSettingsStore } from "@/stores/settings-store"; +import { useSettingsStore, type MonthTimedEventStyle } from "@/stores/settings-store"; import { readableTextOn } from "@/lib/color-transform"; import { useLongPress } from "@/hooks/use-long-press"; import { useCvdColor } from "@/hooks/use-cvd-color"; @@ -36,6 +36,12 @@ interface EventCardProps { currentUserEmails?: string[]; /** False for a day scrolled out of view: Tab skips it (arrows still reach it). */ tabbable?: boolean; + /** + * Month view only (span variant): draw a timed event as coloured text or a + * dot and text instead of a bar (#1449). All-day events, events that run + * on past the drawn day, and declined or cancelled ones keep their look. + */ + timedStyle?: MonthTimedEventStyle; } function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string { @@ -61,6 +67,15 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string { // (eventFillClass) and text in the text colour. // Everything is drawn with box-shadow, so the selection ring joins the list // here and the browser's focus outline stays free. +/** + * The event colour as text on the page background: mixed with the text + * colour so a light calendar colour stays readable in the light theme and a + * dark one in the dark theme. + */ +export function eventTextColor(color: string): string { + return `color-mix(in srgb, ${color} 55%, var(--color-foreground))`; +} + export function eventFillStyle( color: string, { inactive, separated, selected, tinted }: { inactive: boolean; separated?: boolean; selected?: boolean; tinted?: boolean }, @@ -70,7 +85,7 @@ export function eventFillStyle( else if (separated) shadows.push("0 0 0 1px var(--color-background)"); if (inactive) shadows.push(`inset 0 0 0 1px ${color}`); const fill: CSSProperties = inactive - ? { backgroundColor: "var(--color-background)", color: `color-mix(in srgb, ${color} 55%, var(--color-foreground))` } + ? { backgroundColor: "var(--color-background)", color: eventTextColor(color) } : tinted ? { backgroundColor: `color-mix(in srgb, ${color} 20%, var(--color-background))`, @@ -115,7 +130,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string) return el; } -export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true }: EventCardProps) { +export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true, timedStyle = "bar" }: EventCardProps) { const t = useTranslations("calendar"); const [isBeingDragged, setIsBeingDragged] = useState(false); const paint = useCvdColor(); @@ -246,6 +261,50 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM } if (variant === "span") { + // Coloured text or dot and text for a timed event that starts and ends + // on the drawn day (#1449). Anything else stays a bar, so all-day and + // multi-day events read as blocked days. + const textStyle = (timedStyle === "text" || timedStyle === "dot") + && !event.showWithoutTime && !continuesBefore && !continuesAfter && !isInactive + ? timedStyle : null; + if (textStyle) { + return ( + + ); + } return (