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