import { ReactNode } from 'react' import { useTranslation } from '@/i18n' import { formatHolidayName } from '@/utils/dates' import type { CalendarDayType } from '@/types' export interface CalendarDayProps { date: string events?: { type: string; color: string; title?: string }[] | undefined isCurrentMonth?: boolean | undefined onClick?: (() => void) | undefined isToday?: boolean | undefined isSelected?: boolean | undefined onPointerDown?: ((date: string) => void) | undefined onPointerEnter?: ((date: string) => void) | undefined onActivate?: (() => void) | undefined /** §3.4 — statutory calendar classification for this date, from `GET /api/calendar/days`. */ dayType?: CalendarDayType | undefined /** Exact holiday name (possibly the combined "X; Söndag" form); only meaningful when `dayType === 'red_day'`. */ holidayName?: string | null | undefined /** §3.6 — true iff this date has a workspace day override. */ isOverride?: boolean | undefined /** The override's value when `isOverride` is true (true = marked as a working day). */ overrideIsWorkingDay?: boolean | null | undefined } export function CalendarDay({ date, events = [], isCurrentMonth = true, onClick, isToday = false, isSelected = false, onPointerDown, onPointerEnter, onActivate, dayType, holidayName, isOverride = false, overrideIsWorkingDay = null, }: CalendarDayProps): ReactNode { const { t } = useTranslation() const dateObj = new Date(date + 'T00:00:00Z') const dayNum = dateObj.getUTCDate() // §3.4 (Swedish almanacka parity) — the backend collapses Saturday and // Sunday into a single "weekend" day_type (see calendar_service.py), but // the almanacka the user compares against marks Sunday red and Saturday // neutral grey. A holiday that happens to land on Sunday already arrives // as day_type "red_day" (see get_red_days's exact-match on "Söndag" — a // *bare* Sunday is never "red_day"), so a plain Sunday is detected here // from the weekday, not from day_type. const weekday = dateObj.getUTCDay() // 0 = Sunday, 6 = Saturday const isBareSunday = dayType === 'weekend' && weekday === 0 const isPlainSaturday = dayType === 'weekend' && weekday === 6 const isRedDay = dayType === 'red_day' || isBareSunday const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { if (onActivate) { onActivate() } else { onClick?.() } } } const cleanHolidayName = holidayName ? formatHolidayName(holidayName) : null // Non-color signals describing the day's calendar status, appended to the // existing aria-label formula so unrelated call sites (no dayType/override // props supplied) keep their exact previous aria-label. const descriptors: string[] = [] if (cleanHolidayName) { descriptors.push(cleanHolidayName) } else if (dayType === 'klamdag') { descriptors.push(t('calendarDay.klamdagAria')) } else if (isBareSunday) { descriptors.push(t('calendarDay.sundayAria')) } if (isOverride) { descriptors.push( overrideIsWorkingDay ? t('calendarDay.overrideWorkingAria') : t('calendarDay.overrideFreeAria'), ) } const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel const title = descriptors.length > 0 ? descriptors.join(' · ') : undefined const className = [ 'calendar-day', !isCurrentMonth ? 'calendar-day--disabled' : '', events.length > 0 ? 'calendar-day--has-events' : '', isToday ? 'calendar-day--today' : '', isSelected ? 'calendar-day--selected' : '', isRedDay ? 'calendar-day--red-day' : '', dayType === 'klamdag' ? 'calendar-day--klamdag' : '', isPlainSaturday ? 'calendar-day--weekend' : '', isOverride ? 'calendar-day--override' : '', ] .filter(Boolean) .join(' ') return (
{ // Touch browsers implicitly capture the pointer to the element it // went down on, so subsequent `pointermove`s never fire // `pointerenter` on the cells the finger drags across — collapsing // a multi-day drag into a single-day tap. Releasing capture // immediately restores normal hover-style `pointerenter` targeting // for the rest of the drag. jsdom (unit tests) doesn't implement // the Pointer Capture API at all, hence the guards. const target = e.currentTarget if (typeof target.hasPointerCapture === 'function' && target.hasPointerCapture(e.pointerId)) { target.releasePointerCapture(e.pointerId) } onPointerDown?.(date) }} onPointerEnter={() => onPointerEnter?.(date)} >
{dayNum}
{(isRedDay || dayType === 'klamdag') && ( )} {cleanHolidayName && (
{cleanHolidayName}
)} {isOverride && ( )} {events.length > 0 && (
{events.map((event, idx) => (
))}
)}
) }