ae6db7e16f
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>
162 lines
6.1 KiB
TypeScript
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>
|
|
)
|
|
}
|