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:
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user