Files
F-r-ldraledigplannerare/frontend/src/components/common/CalendarDay.tsx
T
jacobh2 ae6db7e16f Redesign the setup flow and consolidate the CSS into one primitives layer
Onboarding asks explicitly for one or two parents and shows one card per
parent for balances; the balance form is per parent with inline
validation; the partner can be filled from the FK deduction or added
from it. Settings and Dashboard reuse the same form.

CSS: bare buttons carry no colour any more; buttons, cards, choice
cards, fields, alerts, badges, eyebrow and modal chrome are defined once
in primitives.css. Media queries live only in responsive.css. A style
guard test blocks hex, !important and stray media queries. Bilingual
fixes: calendar selects size by content, header no longer wraps. Vitest
is capped at four workers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00

162 lines
6.1 KiB
TypeScript

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<HTMLDivElement>) => {
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 (
<div
data-testid="calendar-day"
onClick={onClick}
role="button"
tabIndex={0}
aria-label={ariaLabel}
title={title}
className={className}
onKeyDown={handleKeyDown}
onPointerDown={(e) => {
// 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)}
>
<div className="calendar-day__number">{dayNum}</div>
{(isRedDay || dayType === 'klamdag') && (
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
{dayType === 'klamdag' ? 'K' : 'R'}
</span>
)}
{cleanHolidayName && (
<div className="calendar-day__holiday-name" data-testid="calendar-day-holiday-name" title={cleanHolidayName}>
{cleanHolidayName}
</div>
)}
{isOverride && (
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
{overrideIsWorkingDay ? '⚒' : '✓'}
</span>
)}
{events.length > 0 && (
<div className="calendar-day__events" data-testid="calendar-day-events">
{events.map((event, idx) => (
<div
key={idx}
className="calendar-day__event"
style={{ backgroundColor: event.color }}
title={event.title ?? event.type}
data-testid={`event-${idx}`}
/>
))}
</div>
)}
</div>
)
}