Redraw the calendar as an almanac: gridless cells, typed bands

Day cells lose their borders and fills; red days, klämdagar and
Saturdays are told apart by the numeral's ink with a 12px R/K mark,
today is a filled circle, overrides and exclusions mark the numeral.
Bands carry their funding type by fill, ink and hatching and their
owner by a left cap. The preset rail becomes a chip row, the legend a
help popover, the period popover's three delete buttons fold behind
one "Ta bort…", and solo users pick a type directly in the day modal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-19 19:02:02 +02:00
parent d14f86478a
commit dbef997da4
28 changed files with 1486 additions and 1109 deletions
@@ -36,7 +36,14 @@ describe('CalendarDay', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" />)
const day = getByTestId('calendar-day')
expect(day).toBeInTheDocument()
expect(day.textContent).toContain('15')
expect(day.querySelector('.calendar-day__number')).toHaveTextContent('15')
})
it('keeps the R mark beside the numeral, inside the same head row', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-14" dayType="weekend" />)
const head = getByTestId('calendar-day').querySelector('.calendar-day__head')
expect(head?.querySelector('.calendar-day__number')).toHaveTextContent('14')
expect(head?.querySelector('[data-testid="calendar-day-type-badge"]')).toHaveTextContent('R')
})
it('renders as disabled when isCurrentMonth is false', () => {
@@ -47,32 +54,6 @@ describe('CalendarDay', () => {
expect(day).toHaveClass('calendar-day--disabled')
})
it('renders events when provided', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733', title: 'Vacation' },
{ type: 'sick', color: '#FFD700', title: 'Sick leave' },
]}
/>
)
const events = getByTestId('calendar-day-events')
expect(events).toBeInTheDocument()
expect(events.children).toHaveLength(2)
})
it('applies has-events class when events exist', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[{ type: 'vacation', color: '#FF5733' }]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--has-events')
})
it('calls onClick when clicked', () => {
const onClick = vi.fn()
const { getByTestId } = render(
@@ -115,26 +96,6 @@ describe('CalendarDay', () => {
expect(onClick).toHaveBeenCalledTimes(1)
})
it('provides accessibility label with event count', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733' },
{ type: 'sick', color: '#FFD700' },
]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveAttribute('aria-label', '2026-06-15 med 2 händelse(r)')
})
it('renders without events by default', () => {
const { queryByTestId } = render(<CalendarDay date="2026-06-15" />)
const events = queryByTestId('calendar-day-events')
expect(events).not.toBeInTheDocument()
})
it('applies today class when isToday is true', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isToday={true} />
@@ -234,23 +195,25 @@ describe('CalendarDay', () => {
})
describe('§3.6 workspace day overrides', () => {
it('marks an override with a distinct class and badge, working-day variant', () => {
it('marks an override with a distinct class and a briefcase icon, working-day variant', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={true} />
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--override')
expect(day.getAttribute('aria-label')).toContain('arbetsdag')
expect(getByTestId('calendar-day-override-badge')).toBeInTheDocument()
const badge = getByTestId('calendar-day-override-badge')
expect(badge.querySelector('[data-icon="briefcase"]')).toBeInTheDocument()
})
it('marks an override with a distinct class and badge, free-day variant', () => {
it('marks an override with a distinct class and a check icon, free-day variant', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={false} />
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--override')
expect(day.getAttribute('aria-label')).toContain('ledig dag')
expect(getByTestId('calendar-day-override-badge').querySelector('[data-icon="check"]')).toBeInTheDocument()
})
it('combines a statutory red day with an override distinctly', () => {
@@ -272,11 +235,13 @@ describe('CalendarDay', () => {
})
describe('user-removed days (PlannedDayExclusion)', () => {
it('marks a removed day with its own class, badge and aria descriptor', () => {
it('marks a removed day with its own class, a hidden badge hook and an aria descriptor', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" isExcluded={true} />)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--excluded')
expect(getByTestId('calendar-day-excluded-badge')).toBeInTheDocument()
// The strike-through numeral is the visible mark; the badge only
// keeps a findable hook and stays out of sight.
expect(getByTestId('calendar-day-excluded-badge')).toHaveClass('visually-hidden')
expect(day.getAttribute('aria-label')).toContain('borttagen dag')
})
+26 -30
View File
@@ -2,10 +2,10 @@ import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import { formatHolidayName } from '@/utils/dates'
import type { CalendarDayType } from '@/types'
import { Icon } from './Icon'
export interface CalendarDayProps {
date: string
events?: { type: string; color: string; title?: string }[] | undefined
isCurrentMonth?: boolean | undefined
onClick?: (() => void) | undefined
isToday?: boolean | undefined
@@ -28,9 +28,16 @@ export interface CalendarDayProps {
isExcluded?: boolean | undefined
}
/**
* One day cell. Every state is carried by the numeral, never by a cell fill
* (see calendar.css): a red day is a red numeral with a small "R", a
* klämdag an ochre numeral with a "K", today a filled circle, an override a
* dotted underline plus a 16px icon, a removed day a struck-through numeral.
* The R/K/removed marks keep their `data-testid`s so the e2e suite and the
* unit tests can find them regardless of how they are drawn.
*/
export function CalendarDay({
date,
events = [],
isCurrentMonth = true,
onClick,
isToday = false,
@@ -92,14 +99,12 @@ export function CalendarDay({
descriptors.push(t('calendarDay.excludedAria'))
}
const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date
const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel
const ariaLabel = descriptors.length > 0 ? `${date}, ${descriptors.join(', ')}` : date
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' : '',
@@ -137,44 +142,35 @@ export function CalendarDay({
}}
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>
)}
<div className="calendar-day__head">
<span className="calendar-day__number">{dayNum}</span>
{(isRedDay || dayType === 'klamdag') && (
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
{dayType === 'klamdag' ? 'K' : 'R'}
</span>
)}
{isOverride && (
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
<Icon name={overrideIsWorkingDay ? 'briefcase' : 'check'} size={16} />
</span>
)}
</div>
{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>
)}
{isExcluded && (
// The strike-through numeral is the visible mark; this span only
// keeps a findable hook for tests and the e2e suite.
<span
className="calendar-day__excluded-badge"
className="calendar-day__excluded-badge visually-hidden"
data-testid="calendar-day-excluded-badge"
aria-hidden="true"
>
⊘
</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>
)
}
@@ -33,7 +33,8 @@ describe('CalendarEvent', () => {
)
const button = getByTestId('dismiss-button')
expect(button).toBeInTheDocument()
expect(button.textContent).toBe('×')
expect(button).toHaveAttribute('aria-label', 'Avfärda vacation')
expect(button.querySelector('[data-icon="close"]')).toBeInTheDocument()
})
it('does not render dismiss button when onDismiss not provided', () => {
@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import { Icon } from './Icon'
export interface CalendarEventProps {
leaveType: string
@@ -51,7 +52,7 @@ export function CalendarEvent({
aria-label={t('calendarEvent.dismissAria', { leaveType })}
data-testid="dismiss-button"
>
×
<Icon name="close" size={16} />
</button>
)}
</div>
@@ -411,6 +411,57 @@ describe('CalendarWeek', () => {
})
})
describe('band modifiers: type by fill/ink/outline, owner by the left cap', () => {
it('names the representative funding type as a bare modifier', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', dayType: 'lagsta' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]
expect(band).toHaveClass('calendar-band--lagsta')
expect(band).not.toHaveClass('calendar-band--vacation')
})
it('marks the logged-in parent\'s band --self and anyone else\'s --partner', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const bands = screen.getAllByTestId(/^calendar-band-/)
const own = bands.find((b) => b.getAttribute('data-testid')?.includes('parent-1'))
const partner = bands.find((b) => b.getAttribute('data-testid')?.includes('other-parent'))
expect(own).toHaveClass('calendar-band--self')
expect(own).not.toHaveClass('calendar-band--partner')
expect(partner).toHaveClass('calendar-band--partner')
expect(partner).not.toHaveClass('calendar-band--self')
})
it('gives each funded tick its own type modifier, and none to a bridged gap', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-09', wishId: 'wish-1', dayType: 'sjukpenning' }),
pd({ id: '2', date: '2026-01-12', wishId: 'wish-1', dayType: 'vacation' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })],
['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })],
])
const { container } = render(
<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />,
)
const ticks = Array.from(container.querySelectorAll('.calendar-band__day'))
expect(ticks[0]).toHaveClass('calendar-band__day--sjukpenning')
expect(ticks[1]).toHaveClass('calendar-band__day--gap')
expect(ticks[1]?.className).not.toMatch(/calendar-band__day--(vacation|sjukpenning|lagsta)/)
})
it('sets the band height and lane offsets from the 18px bar geometry', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
expect(band.style.height).toBe('18px')
expect(band.style.bottom).toBe('4px')
})
})
describe('band caps: rounded only at the true start/end of the whole period', () => {
it('rounds both ends when the whole band fits inside this week', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-06' }), pd({ id: '2', date: '2026-01-07' })]
@@ -642,8 +693,8 @@ describe('CalendarWeek', () => {
// every row in the month renders the same height.
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} monthMaxLaneCount={2} />)
const week = container.querySelector('.calendar-week') as HTMLElement
// bandBottomInset(4) + 2 lanes * (barHeight(20) + laneGap(2)) = 48px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
// bandBottomInset(4) + 2 lanes * (barHeight(18) + laneGap(2)) = 44px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('44px')
})
it('does not shrink below what this week\'s own bands need, even if monthMaxLaneCount is smaller', () => {
@@ -660,15 +711,15 @@ describe('CalendarWeek', () => {
)
const week = container.querySelector('.calendar-week') as HTMLElement
// This week's own 2 lanes win over the smaller monthMaxLaneCount.
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('44px')
})
it('falls back to this week\'s own lane count when monthMaxLaneCount is omitted (single-week render)', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const week = container.querySelector('.calendar-week') as HTMLElement
// bandBottomInset(4) + 1 lane * (barHeight(20) + laneGap(2)) = 26px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('26px')
// bandBottomInset(4) + 1 lane * (barHeight(18) + laneGap(2)) = 24px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('24px')
})
it('gives every week row the same --band-reserved-height when rendered together with a shared monthMaxLaneCount, even though only one has bands', () => {
@@ -695,7 +746,7 @@ describe('CalendarWeek', () => {
expect(weeks).toHaveLength(2)
const heights = weeks.map((week) => week.style.getPropertyValue('--band-reserved-height'))
expect(new Set(heights).size).toBe(1)
expect(heights[0]).toBe('48px')
expect(heights[0]).toBe('44px')
})
})
@@ -1,6 +1,6 @@
import { ReactNode, useMemo } from 'react'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
import { getColorForType, getInkForType, getLabelForType, getTypeKey } from '@/utils/leaveTypes'
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
import { useTranslation } from '@/i18n'
import { CalendarDay } from './CalendarDay'
@@ -311,7 +311,7 @@ export function CalendarWeek({
// 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 barHeight = 18
const laneGap = 2
const bandBottomInset = 4
const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0
@@ -398,9 +398,14 @@ export function CalendarWeek({
const ink = getInkForType(representativeType)
const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
// Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`,
// see the --c-leave-* tokens) and owner by a 2px left cap
// (`--self`/`--partner`) — never by hue, so the two axes stay
// readable side by side and in greyscale.
const classNames = [
'calendar-band',
`calendar-band--${getTypeModifier(representativeType)}`,
`calendar-band--${getTypeKey(representativeType)}`,
segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner',
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
@@ -453,7 +458,7 @@ export function CalendarWeek({
key={info.date}
className={[
'calendar-band__day',
info.isGap ? 'calendar-band__day--gap' : '',
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
info.isPinned ? 'calendar-band__day--pinned' : '',
]
.filter(Boolean)