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>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent e4719cb9ae
commit ae6db7e16f
105 changed files with 5683 additions and 3690 deletions
@@ -3,6 +3,35 @@ import { render, fireEvent } from '@testing-library/react'
import { CalendarDay } from './CalendarDay'
describe('CalendarDay', () => {
describe('drag-select pointer handling', () => {
it('calls onPointerDown even when the browser does not implement the Pointer Capture API (jsdom)', () => {
// jsdom has no hasPointerCapture/releasePointerCapture at all — the
// handler must guard for that rather than assume every environment
// implements it.
const onPointerDown = vi.fn()
const { getByTestId } = render(<CalendarDay date="2026-06-15" onPointerDown={onPointerDown} />)
const day = getByTestId('calendar-day')
expect(() => fireEvent.pointerDown(day)).not.toThrow()
expect(onPointerDown).toHaveBeenCalledWith('2026-06-15')
})
it('releases an implicitly captured pointer on pointerdown, so later pointerenters on other cells still fire (touch drag)', () => {
const onPointerDown = vi.fn()
const { getByTestId } = render(<CalendarDay date="2026-06-15" onPointerDown={onPointerDown} />)
const day = getByTestId('calendar-day')
const releasePointerCapture = vi.fn()
// Simulate a touch browser's implicit pointer capture, which jsdom
// itself doesn't provide.
Object.defineProperty(day, 'hasPointerCapture', { value: vi.fn(() => true), configurable: true })
Object.defineProperty(day, 'releasePointerCapture', { value: releasePointerCapture, configurable: true })
fireEvent.pointerDown(day)
expect(releasePointerCapture).toHaveBeenCalledTimes(1)
expect(onPointerDown).toHaveBeenCalledWith('2026-06-15')
})
})
it('renders day number', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" />)
const day = getByTestId('calendar-day')
+14 -1
View File
@@ -111,7 +111,20 @@ export function CalendarDay({
title={title}
className={className}
onKeyDown={handleKeyDown}
onPointerDown={() => onPointerDown?.(date)}
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>
@@ -8,6 +8,14 @@ export interface CalendarEventProps {
onDismiss?: () => void
isDubbeldag?: boolean | undefined
parentLabel?: string | undefined
/**
* Whether this event paints its own background. Defaults to true for
* standalone use. `CalendarWeek` renders this as the label overlaid on a
* `.calendar-band` that already shows its own per-day funding-type colors
* underneath (see `.calendar-band__day`) — painting a second solid color
* here would hide that strip, so it passes `false`.
*/
paintBackground?: boolean | undefined
}
export function CalendarEvent({
@@ -17,6 +25,7 @@ export function CalendarEvent({
onDismiss,
isDubbeldag = false,
parentLabel,
paintBackground = true,
}: CalendarEventProps): ReactNode {
const { t } = useTranslation()
const displayLabel = parentLabel ? `${parentLabel} · ${leaveType}` : leaveType
@@ -25,7 +34,7 @@ export function CalendarEvent({
<div
data-testid="calendar-event"
className="calendar-event"
style={{ backgroundColor: color }}
style={paintBackground ? { backgroundColor: color } : undefined}
>
<span className="calendar-event__label" title={title ?? leaveType}>
{displayLabel}
@@ -33,6 +42,7 @@ export function CalendarEvent({
{isDubbeldag && <span className="calendar-event__badge">2×</span>}
{onDismiss && (
<button
type="button"
className="calendar-event__dismiss"
onClick={(e) => {
e.stopPropagation()
@@ -92,7 +92,9 @@ describe('CalendarWeek', () => {
selectedDates: new Set<string>(),
parentName: 'Test Parent',
personalDataUuid: 'parent-1',
isCurrentMonth: true,
// defaultProps.weekDates is 2026-01-05..2026-01-11, all January.
displayedYear: 2026,
displayedMonth: 0,
showWeekNumbers: false,
onDayClick: vi.fn(),
onPointerDown: vi.fn(),
@@ -136,18 +138,24 @@ describe('CalendarWeek', () => {
})
})
it('marks all days as current month when isCurrentMonth is true', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
it('marks all days as current month when they match the displayed year/month', () => {
render(<CalendarWeek {...defaultProps} />)
defaultProps.weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toHaveClass('current-month')
})
})
it('marks days outside the current month with current-month class set to false', () => {
it('marks a leading week\'s in-month days as current month and its previous-month days as not, per date — not per weekDates[0]', () => {
// Regression guard: this used to compare every day in the week
// against `weekDates[0]`'s own month, so an entire leading week
// (partly previous month) was judged by its FIRST day alone. A week
// spanning Dec 29 – Jan 4, displayed as January, must activate the
// January days (01-01..01-04) and deactivate the December ones
// (12-29..12-31) — not the other way around.
const prevMonthDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
render(<CalendarWeek {...defaultProps} weekDates={prevMonthDates} isCurrentMonth={true} />)
expect(screen.getByTestId('calendar-day-2025-12-29')).toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-01-01')).not.toHaveClass('current-month')
render(<CalendarWeek {...defaultProps} weekDates={prevMonthDates} />)
expect(screen.getByTestId('calendar-day-2025-12-29')).not.toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-01-01')).toHaveClass('current-month')
})
it('highlights today when date matches current date', () => {
@@ -205,6 +213,28 @@ describe('CalendarWeek', () => {
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('resolves a partner band label from parentNames, not the generic "Partner" string', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'partner-uuid' }),
]
const parentNames = new Map([
['parent-1', 'Test Parent'],
['partner-uuid', 'Erik'],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentNames={parentNames} />)
expect(screen.getByText(/Test Parent · Semester/)).toBeInTheDocument()
expect(screen.getByText(/Erik · Semester/)).toBeInTheDocument()
expect(screen.queryByText(/Partner · Semester/)).not.toBeInTheDocument()
})
it('falls back to the generic "Partner" string when the band\'s parentUuid matches no known member', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'stale-uuid' })]
const parentNames = new Map([['parent-1', 'Test Parent']])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentNames={parentNames} />)
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('renders different day types with localized labels', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', dayType: 'sjukpenning' }),
@@ -510,66 +540,211 @@ describe('CalendarWeek', () => {
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('stacks overlapping bands at different bottom offsets within the same grid row, not different rows', () => {
// Regression guard for the overlay rework: lanes used to be separate
// grid rows (gridRow 2, 3, ...) below the day cells. Now every band
// shares gridRow 1 with the day cells and is stacked purely via its
// inline `bottom` offset, so overlapping bands must render at
// DIFFERENT `bottom` values while both staying in row 1.
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '3', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
pd({ id: '4', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
const bands = screen.getAllByTestId(/^calendar-band-/)
expect(bands).toHaveLength(2)
bands.forEach((band) => expect(band.style.gridRow).toBe('1'))
const bottoms = bands.map((band) => band.style.bottom)
expect(new Set(bottoms).size).toBe(2)
// Both lanes share the same band height.
expect(new Set(bands.map((band) => band.style.height)).size).toBe(1)
})
})
describe('band overlay: bands render on the day-cell row, not in a gutter below it', () => {
it('places a band at gridRow 1 with a grid-column span matching its start/end columns', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-06' }),
pd({ id: '2', date: '2026-01-07' }),
pd({ id: '3', date: '2026-01-08' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
expect(band.style.gridRow).toBe('1')
// 01-06/07/08 are columns 2/3/4 (1-indexed, no week-number offset).
expect(band.style.gridColumn).toBe('2 / 5')
})
it('accounts for the week-number column offset in the band\'s grid-column span', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} showWeekNumbers={true} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
// 01-05 is column 1 of the week, shifted right by the week-number column.
expect(band.style.gridColumn).toBe('2 / 3')
})
it('reserves bottom space on the week container via --band-reserved-height when a band is present', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const week = container.querySelector('.calendar-week') as HTMLElement
expect(week.style.getPropertyValue('--band-reserved-height')).not.toBe('0px')
expect(week.style.getPropertyValue('--band-reserved-height')).toMatch(/^\d+px$/)
})
it('reserves zero space on the week container when there are no bands', () => {
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} />)
const week = container.querySelector('.calendar-week') as HTMLElement
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('0px')
})
it('still fires the day cell\'s onClick/onPointerDown/onPointerEnter for a date under a band', () => {
// The band overlays the day cell (same grid row); the day cell must
// stay reachable for its own click/drag-select handlers underneath.
const onDayClick = vi.fn()
const onPointerDown = vi.fn()
const onPointerEnter = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(
<CalendarWeek
{...defaultProps}
plannedDays={plannedDays}
onDayClick={onDayClick}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
/>,
)
const dayCell = screen.getByTestId('calendar-day-2026-01-05')
fireEvent.click(dayCell)
fireEvent.pointerDown(dayCell)
fireEvent.pointerEnter(dayCell)
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
expect(onPointerDown).toHaveBeenCalledWith('2026-01-05')
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-05')
})
it('still fires onBandClick for a band overlaying a date, alongside the day cell staying clickable', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const onDayClick = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' })]
render(
<CalendarWeek
{...defaultProps}
plannedDays={plannedDays}
onBandClick={onBandClick}
onDayClick={onDayClick}
/>,
)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(onBandClick).toHaveBeenCalledWith({
wishId: 'wish-1',
parentPersonalDataUuid: 'parent-1',
startDate: '2026-01-05',
endDate: '2026-01-05',
})
// Clicking the band must not also trigger the day cell's own click.
expect(onDayClick).not.toHaveBeenCalled()
})
})
describe('event handlers', () => {
it('calls onDayClick when clicking a day in current month', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-05'))
expect(defaultProps.onDayClick).toHaveBeenCalledWith('2026-01-05')
})
it('does not call onDayClick for days outside the week\'s reference month', () => {
it('calls onDayClick for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
// Regression guard for the drag-select gating bug: a leading week's
// in-month days must be clickable even though `weekDates[0]` belongs
// to the previous month.
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} isCurrentMonth={true} onDayClick={onDayClick} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-01'))
expect(onDayClick).not.toHaveBeenCalled()
expect(onDayClick).toHaveBeenCalledWith('2026-01-01')
})
it('does not call onDayClick when isCurrentMonth is false', () => {
it('still calls onDayClick for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
// Regression guard for the drag-across-month-boundary bug: gating
// click/pointer handlers on `dayIsCurrentMonth` per-cell killed every
// adjacent-month cell's interactivity, which broke a drag that
// crosses a month boundary (see Calendar.tsx's pointerup handler /
// drag-create-delete.spec.ts). `isCurrentMonth` still only drives the
// dimmed `current-month`/disabled styling; clicking a visible
// adjacent-month day is a deliberate, supported way to plan that day.
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} isCurrentMonth={false} onDayClick={onDayClick} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2025-12-29'))
expect(onDayClick).toHaveBeenCalledWith('2025-12-29')
})
it('still calls onDayClick when no day in the week matches the displayed year/month', () => {
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} displayedMonth={5} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-05'))
expect(onDayClick).not.toHaveBeenCalled()
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
})
it('calls onPointerDown when starting drag on a current-month day', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2026-01-05'))
expect(defaultProps.onPointerDown).toHaveBeenCalledWith('2026-01-05')
})
it('does not call onPointerDown for days outside the week\'s reference month', () => {
it('calls onPointerDown for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerDown = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} isCurrentMonth={true} onPointerDown={onPointerDown} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerDown={onPointerDown} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2026-01-01'))
expect(onPointerDown).not.toHaveBeenCalled()
expect(onPointerDown).toHaveBeenCalledWith('2026-01-01')
})
it('still calls onPointerDown for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerDown = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerDown={onPointerDown} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2025-12-29'))
expect(onPointerDown).toHaveBeenCalledWith('2025-12-29')
})
it('calls onPointerEnter when dragging through a current-month day', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2026-01-06'))
expect(defaultProps.onPointerEnter).toHaveBeenCalledWith('2026-01-06')
})
it('does not call onPointerEnter for days outside the week\'s reference month', () => {
it('calls onPointerEnter for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerEnter = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} isCurrentMonth={true} onPointerEnter={onPointerEnter} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerEnter={onPointerEnter} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2026-01-01'))
expect(onPointerEnter).not.toHaveBeenCalled()
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-01')
})
it('does not call event handlers when isCurrentMonth is false', () => {
it('still calls onPointerEnter for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerEnter = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerEnter={onPointerEnter} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2025-12-29'))
expect(onPointerEnter).toHaveBeenCalledWith('2025-12-29')
})
it('still calls event handlers when no day in the week matches the displayed year/month, so a drag can cross a month boundary', () => {
// A drag whose anchor is the last day of one month and whose current
// cell is the first day of the next crosses a week row where NONE of
// the days match the still-displayed month — that cell must still be
// reachable, or the drag can never enter the new month at all.
const onDayClick = vi.fn()
const onPointerDown = vi.fn()
const onPointerEnter = vi.fn()
render(
<CalendarWeek
{...defaultProps}
isCurrentMonth={false}
displayedMonth={5}
onDayClick={onDayClick}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
@@ -579,23 +754,23 @@ describe('CalendarWeek', () => {
fireEvent.click(dayElement)
fireEvent.pointerDown(dayElement)
fireEvent.pointerEnter(dayElement)
expect(onDayClick).not.toHaveBeenCalled()
expect(onPointerDown).not.toHaveBeenCalled()
expect(onPointerEnter).not.toHaveBeenCalled()
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
expect(onPointerDown).toHaveBeenCalledWith('2026-01-05')
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-05')
})
})
describe('month boundary edge cases', () => {
it('correctly identifies days at month boundaries', () => {
const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
expect(screen.getByTestId('calendar-day-2026-01-31')).toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-02-01')).not.toHaveClass('current-month')
})
it('handles start of month week', () => {
const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
})
@@ -603,7 +778,7 @@ describe('CalendarWeek', () => {
it('handles end of month week', () => {
const weekDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
})
+67 -29
View File
@@ -22,7 +22,20 @@ export interface CalendarWeekProps {
selectedDates: Set<string>
parentName: string
personalDataUuid: string
isCurrentMonth: boolean
/** Every workspace member's display name keyed by `personalDataUuid`, used
* to label a band owned by someone other than the current user (a
* partner's band must show their actual name, not the generic
* `calendar.partner` string). Falls back to that generic string for a
* `parentUuid` matching no known member (stale data, removed partner). */
parentNames?: Map<string, string> | undefined
/** The calendar's currently displayed year/month — each day in the week
* is compared against these directly (not against `weekDates[0]`) so a
* leading/trailing week that straddles a month boundary still activates
* its in-month cells. See Calendar.tsx's week-building loop: a leading
* week's `weekDates[0]` can belong entirely to the previous month even
* though most of the row is the displayed month. */
displayedYear: number
displayedMonth: number
showWeekNumbers: boolean
onDayClick: (date: string) => void
onPointerDown: (date: string) => void
@@ -238,7 +251,9 @@ export function CalendarWeek({
selectedDates,
parentName,
personalDataUuid,
isCurrentMonth,
parentNames,
displayedYear,
displayedMonth,
showWeekNumbers,
onDayClick,
onPointerDown,
@@ -253,32 +268,42 @@ export function CalendarWeek({
[weekDates, plannedDays, calendarDaysByDate],
)
// Bands overlay the day-cell row itself (grid-row: 1, position: absolute)
// instead of occupying separate rows below it — see the comment on
// `.calendar-week` in calendar.css. `barHeight`/`laneGap`/`bandBottomInset`
// together define both each lane's vertical offset from the cell's bottom
// edge AND, via `bandsReservedHeight`, how much bottom padding
// `.calendar-day` reserves so its number/badges/holiday name never sit
// under a band.
const barHeight = 20
const laneGap = 2
const bandBottomInset = 4
const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0
const bandsReservedHeight = laneCount > 0 ? bandBottomInset + laneCount * (barHeight + laneGap) : 0
const colOffset = showWeekNumbers ? 1 : 0
const gridTemplateRows = laneCount > 0 ? `auto repeat(${laneCount}, ${barHeight}px)` : 'auto'
const firstDate = weekDates[0]
const weekNumber = getISOWeekNumber(new Date(firstDate + 'T00:00:00Z'))
const weekStyle = {
'--band-reserved-height': `${bandsReservedHeight}px`,
} as React.CSSProperties & Record<'--band-reserved-height', string>
return (
<div
className={`calendar-week${showWeekNumbers ? ' calendar-week--with-week-numbers' : ''}`}
style={{ gridTemplateRows }}
style={weekStyle}
>
{showWeekNumbers && (
<div
className="calendar__week-number"
style={{ gridColumn: '1', gridRow: `1 / ${laneCount + 2}` }}
>
<div className="calendar__week-number" style={{ gridColumn: '1', gridRow: '1' }}>
<span className="calendar__week-number-text">v.{weekNumber}</span>
</div>
)}
{weekDates.map((date, colIdx) => {
const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z')
const dateObj = new Date(date + 'T00:00:00Z')
const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth()
const dayIsCurrentMonth =
dateObj.getUTCFullYear() === displayedYear && dateObj.getUTCMonth() === displayedMonth
const calendarInfo = calendarDaysByDate?.get(date)
return (
@@ -289,35 +314,40 @@ export function CalendarWeek({
>
<CalendarDay
date={date}
isCurrentMonth={isCurrentMonth && dayIsCurrentMonth}
isCurrentMonth={dayIsCurrentMonth}
isToday={date === formatDateLocalISO(new Date())}
isSelected={selectedDates.has(date)}
dayType={calendarInfo?.dayType}
holidayName={calendarInfo?.name ?? null}
isOverride={calendarInfo?.isOverride ?? false}
overrideIsWorkingDay={calendarInfo?.overrideIsWorkingDay ?? null}
onClick={() => {
if (isCurrentMonth && dayIsCurrentMonth) {
onDayClick(date)
}
}}
onPointerDown={(d) => {
if (isCurrentMonth && dayIsCurrentMonth) {
onPointerDown(d)
}
}}
onPointerEnter={(d) => {
if (isCurrentMonth && dayIsCurrentMonth) {
onPointerEnter(d)
}
}}
// Every cell is clickable/drag-selectable regardless of which
// month it visually belongs to — `dayIsCurrentMonth` only
// controls the dimmed `calendar-day--disabled` styling above.
// Two failure modes this avoids, both seen in production:
// (1) gating on `weekDates[0]`'s month killed an entire
// leading week; (2) gating per-cell on the day's own month
// (the previous fix here) still killed every adjacent-month
// cell, so a drag could never cross a month boundary — see
// the regression this closes in Calendar.tsx's pointerup
// handler / drag-create-delete.spec.ts. Clicking a visible
// adjacent-month day plans that day, same as clicking any
// other visible day; that's a deliberate call, not an
// oversight — silently doing nothing on a visible cell is
// worse than acting on it.
onClick={() => onDayClick(date)}
onPointerDown={(d) => onPointerDown(d)}
onPointerEnter={(d) => onPointerEnter(d)}
/>
</div>
)
})}
{bandSegments.map((segment) => {
const label = segment.parentUuid === personalDataUuid ? parentName : t('calendar.partner')
const label =
segment.parentUuid === personalDataUuid
? parentName
: (parentNames?.get(segment.parentUuid) ?? t('calendar.partner'))
const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
const representativeType = firstRealDay?.dayType ?? 'vacation'
const typeLabel = getLabelForType(representativeType, t)
@@ -347,7 +377,9 @@ export function CalendarWeek({
className={classNames}
style={{
gridColumn: `${segment.startCol + 1 + colOffset} / ${segment.endCol + 2 + colOffset}`,
gridRow: String(segment.lane + 2),
gridRow: '1',
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
height: `${barHeight}px`,
backgroundColor: color,
}}
data-testid={`calendar-band-${segment.id}`}
@@ -384,7 +416,13 @@ export function CalendarWeek({
))}
</div>
<div className="calendar-band__label">
<CalendarEvent leaveType={typeLabel} color={color} isDubbeldag={anyDubbeldag} parentLabel={label} />
<CalendarEvent
leaveType={typeLabel}
color={color}
isDubbeldag={anyDubbeldag}
parentLabel={label}
paintBackground={false}
/>
</div>
</div>
)
@@ -10,7 +10,7 @@
}
.loading-spinner__icon {
font-size: 48px;
font-size: var(--icon-lg);
color: var(--c-accent);
animation: spin 2s linear infinite;
display: block;
@@ -30,10 +30,3 @@
transform: rotate(360deg);
}
}
/* Reduce Motion */
@media (prefers-reduced-motion: reduce) {
.loading-spinner__icon {
animation: none;
}
}