Draw a leave period as one band, and make red days look red

A six-week summer used to render as a row of disconnected bars, because bars
were derived per week row and grouped by parent and day type. They carried no
identity and ignored pointer events, so there was nothing to click. Bands now
group by the wish that produced them, bridge the weekends and red days inside
a period, and round only the ends that are genuinely the first and last day.
Each day still shows what pays for it, and a hand-edited day is marked as one
the planner will leave alone.

Sundays and public holidays now render red and Saturdays a neutral grey,
following the printed almanacka people compare against, and a holiday's name
is visible in the cell rather than hidden in a tooltip. This also explains the
report that months looked unshaded: weekends had no styling at all, since the
day component handled only red days and klämdagar.

The year selector follows the range the calendar API actually serves instead
of a hardcoded span.
This commit is contained in:
2026-09-17 18:28:16 +02:00
parent d06c8c8449
commit 6a7c0ef255
12 changed files with 826 additions and 744 deletions
@@ -157,6 +157,51 @@ describe('CalendarDay', () => {
expect(day).toHaveAttribute('aria-label', '2026-06-15') expect(day).toHaveAttribute('aria-label', '2026-06-15')
expect(day).not.toHaveAttribute('title') expect(day).not.toHaveAttribute('title')
}) })
it('shows the holiday name as visible text in the cell, not just title/aria-label', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" dayType="red_day" holidayName="Midsommardagen" />
)
expect(getByTestId('calendar-day-holiday-name')).toHaveTextContent('Midsommardagen')
})
it('does not render holiday-name text for a plain working day', () => {
const { queryByTestId } = render(<CalendarDay date="2026-06-15" dayType="working_day" />)
expect(queryByTestId('calendar-day-holiday-name')).not.toBeInTheDocument()
})
})
describe('Saturday/Sunday almanacka parity', () => {
it('marks a plain Saturday with the neutral weekend class, not red', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-13" dayType="weekend" />)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--weekend')
expect(day).not.toHaveClass('calendar-day--red-day')
expect(day.querySelector('[data-testid="calendar-day-type-badge"]')).not.toBeInTheDocument()
})
it('marks a plain Sunday as a red day even without a holiday name', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-14" dayType="weekend" />)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--red-day')
expect(day).not.toHaveClass('calendar-day--weekend')
expect(getByTestId('calendar-day-type-badge')).toHaveTextContent('R')
expect(day.getAttribute('aria-label')).toContain('söndag')
})
it('does not show holiday-name text for a bare Sunday (there is no name to show)', () => {
const { queryByTestId } = render(<CalendarDay date="2026-06-14" dayType="weekend" />)
expect(queryByTestId('calendar-day-holiday-name')).not.toBeInTheDocument()
})
it('still treats a holiday landing on Saturday as a full red day, not neutral weekend', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-13" dayType="red_day" holidayName="Midsommarafton" />
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--red-day')
expect(day).not.toHaveClass('calendar-day--weekend')
})
}) })
describe('§3.6 workspace day overrides', () => { describe('§3.6 workspace day overrides', () => {
+25 -4
View File
@@ -39,7 +39,20 @@ export function CalendarDay({
overrideIsWorkingDay = null, overrideIsWorkingDay = null,
}: CalendarDayProps): ReactNode { }: CalendarDayProps): ReactNode {
const { t } = useTranslation() const { t } = useTranslation()
const dayNum = new Date(date + 'T00:00:00Z').getUTCDate() const dateObj = new Date(date + 'T00:00:00Z')
const dayNum = dateObj.getUTCDate()
// §3.4 (Swedish almanacka parity) — the backend collapses Saturday and
// Sunday into a single "weekend" day_type (see calendar_service.py), but
// the almanacka the user compares against marks Sunday red and Saturday
// neutral grey. A holiday that happens to land on Sunday already arrives
// as day_type "red_day" (see get_red_days's exact-match on "Söndag" — a
// *bare* Sunday is never "red_day"), so a plain Sunday is detected here
// from the weekday, not from day_type.
const weekday = dateObj.getUTCDay() // 0 = Sunday, 6 = Saturday
const isBareSunday = dayType === 'weekend' && weekday === 0
const isPlainSaturday = dayType === 'weekend' && weekday === 6
const isRedDay = dayType === 'red_day' || isBareSunday
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -61,6 +74,8 @@ export function CalendarDay({
descriptors.push(cleanHolidayName) descriptors.push(cleanHolidayName)
} else if (dayType === 'klamdag') { } else if (dayType === 'klamdag') {
descriptors.push(t('calendarDay.klamdagAria')) descriptors.push(t('calendarDay.klamdagAria'))
} else if (isBareSunday) {
descriptors.push(t('calendarDay.sundayAria'))
} }
if (isOverride) { if (isOverride) {
descriptors.push( descriptors.push(
@@ -78,8 +93,9 @@ export function CalendarDay({
events.length > 0 ? 'calendar-day--has-events' : '', events.length > 0 ? 'calendar-day--has-events' : '',
isToday ? 'calendar-day--today' : '', isToday ? 'calendar-day--today' : '',
isSelected ? 'calendar-day--selected' : '', isSelected ? 'calendar-day--selected' : '',
dayType === 'red_day' ? 'calendar-day--red-day' : '', isRedDay ? 'calendar-day--red-day' : '',
dayType === 'klamdag' ? 'calendar-day--klamdag' : '', dayType === 'klamdag' ? 'calendar-day--klamdag' : '',
isPlainSaturday ? 'calendar-day--weekend' : '',
isOverride ? 'calendar-day--override' : '', isOverride ? 'calendar-day--override' : '',
] ]
.filter(Boolean) .filter(Boolean)
@@ -99,11 +115,16 @@ export function CalendarDay({
onPointerEnter={() => onPointerEnter?.(date)} onPointerEnter={() => onPointerEnter?.(date)}
> >
<div className="calendar-day__number">{dayNum}</div> <div className="calendar-day__number">{dayNum}</div>
{(dayType === 'red_day' || dayType === 'klamdag') && ( {(isRedDay || dayType === 'klamdag') && (
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true"> <span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
{dayType === 'red_day' ? 'R' : 'K'} {dayType === 'klamdag' ? 'K' : 'R'}
</span> </span>
)} )}
{cleanHolidayName && (
<div className="calendar-day__holiday-name" data-testid="calendar-day-holiday-name" title={cleanHolidayName}>
{cleanHolidayName}
</div>
)}
{isOverride && ( {isOverride && (
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true"> <span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
{overrideIsWorkingDay ? '⚒' : '✓'} {overrideIsWorkingDay ? '⚒' : '✓'}
File diff suppressed because it is too large Load Diff
+245 -136
View File
@@ -6,6 +6,16 @@ import { useTranslation } from '@/i18n'
import { CalendarDay } from './CalendarDay' import { CalendarDay } from './CalendarDay'
import { CalendarEvent } from './CalendarEvent' import { CalendarEvent } from './CalendarEvent'
/** Fired when a band (a period of planned days, possibly spanning several
* weeks) is clicked. `wishId` is null for an ad-hoc period not tied to a
* `LeaveWish` — that null value IS the "ad-hoc" marker callers match on. */
export interface BandClickInfo {
wishId: string | null
parentPersonalDataUuid: string
startDate: string
endDate: string
}
export interface CalendarWeekProps { export interface CalendarWeekProps {
weekDates: string[] weekDates: string[]
plannedDays: PlannedLeaveDay[] plannedDays: PlannedLeaveDay[]
@@ -19,117 +29,186 @@ export interface CalendarWeekProps {
onPointerEnter: (date: string) => void onPointerEnter: (date: string) => void
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */ /** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
calendarDaysByDate?: Map<string, CalendarDayEntry> | undefined calendarDaysByDate?: Map<string, CalendarDayEntry> | undefined
/** Optional for now — a later task wires this to a period editor. */
onBandClick?: ((info: BandClickInfo) => void) | undefined
} }
interface BarData { /** One day's slot inside a band segment: either a real planned_leave_day
* (`isGap: false`, carrying its own funding type/dubbeldag/pinned flags) or
* a bridged free-day gap (a weekend/red day the band passes through without
* needing a funded row — see `isBridgeableGap`). */
interface BandDayInfo {
date: string
dayType: PlannedLeaveDay['dayType'] | null
isDubbeldag: boolean
isPinned: boolean
isGap: boolean
}
interface BandSegment {
id: string
wishId: string | null
parentUuid: string
startCol: number startCol: number
endCol: number endCol: number
parentUuid: string /** True only in the week row containing the band's true first/last date —
dayType: string * only that row gets a rounded cap; other rows are square continuations. */
isDubbeldag: boolean isBandStart: boolean
isBandEnd: boolean
overallStart: string
overallEnd: string
dayInfos: BandDayInfo[]
lane: number lane: number
} }
function buildBarsForWeek( /** Grouping key for banding: a wish-linked day bands with every other day
weekDates: string[], * from the SAME wish + parent regardless of its own day type (a period is
plannedDays: PlannedLeaveDay[] * one object even if its funding mix varies day to day); an ad-hoc day
): BarData[] { * (no wish) falls back to the pre-existing parent/type/dubbeldag key. */
const dayToEntries = new Map<string, PlannedLeaveDay[]>() function bandKeyFor(pd: PlannedLeaveDay): string {
return pd.wishId
? `wish:${pd.wishId}:${pd.parentPersonalDataUuid}`
: `adhoc:${pd.parentPersonalDataUuid}:${pd.dayType}:${pd.isDubbeldag}`
}
for (const date of weekDates) { function addDaysISO(dateStr: string, delta: number): string {
dayToEntries.set(date, plannedDays.filter((pd) => pd.date === date)) const d = new Date(dateStr + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + delta)
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* True if every calendar date strictly between `d1` and `d2` (both ISO
* dates, `d1 < d2`) is a free day (weekend or red day) — i.e. the gap can be
* bridged so the band still reads as one continuous period across it.
*
* This is what fixes the "six-week summer becomes many separate bars" bug:
* the planning engine only persists a `planned_leave_day` row for days that
* actually need funding, so weekends inside a wish window are simply absent
* from the data, not zero-cost rows. Without bridging, naive day-by-day
* contiguity fragments at every Saturday/Sunday.
*/
function isBridgeableGap(d1: string, d2: string, calendarDaysByDate?: Map<string, CalendarDayEntry>): boolean {
let cursor = addDaysISO(d1, 1)
if (cursor === d2) return true
while (cursor < d2) {
const info = calendarDaysByDate?.get(cursor)
if (!info || (info.dayType !== 'weekend' && info.dayType !== 'red_day')) {
return false
}
cursor = addDaysISO(cursor, 1)
} }
return true
}
const series = new Map< function computeBandSegments(
string, weekDates: string[],
{ bars: Array<{ startIdx: number; endIdx: number }> } plannedDays: PlannedLeaveDay[],
>() calendarDaysByDate?: Map<string, CalendarDayEntry>,
): BandSegment[] {
const weekStart = weekDates[0]
const weekEnd = weekDates[weekDates.length - 1]
if (weekStart === undefined || weekEnd === undefined) return []
const byKey = new Map<string, PlannedLeaveDay[]>()
for (const pd of plannedDays) { for (const pd of plannedDays) {
if (!weekDates.includes(pd.date)) continue const key = bandKeyFor(pd)
const list = byKey.get(key)
const key = `${pd.parentPersonalDataUuid}|${pd.dayType}|${pd.isDubbeldag}` if (list) {
if (!series.has(key)) { list.push(pd)
series.set(key, { bars: [] }) } else {
byKey.set(key, [pd])
} }
} }
const result: Array<{ const raw: Array<Omit<BandSegment, 'lane'>> = []
startCol: number
endCol: number
parentUuid: string
dayType: string
isDubbeldag: boolean
}> = []
for (const [key] of series) { for (const [key, entries] of byKey) {
const parts = key.split('|') const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date))
const parentUuid = parts[0] ?? ''
const dayType = parts[1] ?? ''
const isDubbeldag = parts[2] ?? 'false'
const isDoubleStr = isDubbeldag === 'true'
let startIdx = -1 let runStart = 0
for (let i = 1; i <= sorted.length; i++) {
const prev = sorted[i - 1]
const cur = sorted[i]
const bridgeable = prev && cur ? isBridgeableGap(prev.date, cur.date, calendarDaysByDate) : false
if (cur && bridgeable) continue
for (let i = 0; i < weekDates.length; i++) { const run = sorted.slice(runStart, i)
const date = weekDates[i] runStart = i
const hasEntry = plannedDays.some(
(pd) =>
pd.date === date &&
pd.parentPersonalDataUuid === parentUuid &&
pd.dayType === dayType &&
pd.isDubbeldag === isDoubleStr
)
if (hasEntry) { const first = run[0]
if (startIdx === -1) { const last = run[run.length - 1]
startIdx = i if (!first || !last) continue
}
} else { const overallStart = first.date
if (startIdx !== -1) { const overallEnd = last.date
result.push({ if (overallEnd < weekStart || overallStart > weekEnd) continue
startCol: startIdx,
endCol: i - 1, let startCol = -1
parentUuid, let endCol = -1
dayType, for (let c = 0; c < weekDates.length; c++) {
isDubbeldag: isDoubleStr, const d = weekDates[c]
}) if (d !== undefined && d >= overallStart && d <= overallEnd) {
startIdx = -1 if (startCol === -1) startCol = c
endCol = c
} }
} }
} if (startCol === -1 || endCol === -1) continue
if (startIdx !== -1) { const entryByDate = new Map(run.map((pd) => [pd.date, pd] as const))
result.push({ const dayInfos: BandDayInfo[] = []
startCol: startIdx, for (let c = startCol; c <= endCol; c++) {
endCol: weekDates.length - 1, const d = weekDates[c]
parentUuid, if (d === undefined) continue
dayType, const entry = entryByDate.get(d)
isDubbeldag: isDoubleStr, dayInfos.push(
entry
? {
date: d,
dayType: entry.dayType,
isDubbeldag: entry.isDubbeldag,
isPinned: entry.isPinned ?? false,
isGap: false,
}
: { date: d, dayType: null, isDubbeldag: false, isPinned: false, isGap: true },
)
}
raw.push({
id: `${key}::${overallStart}`,
wishId: first.wishId ?? null,
parentUuid: first.parentPersonalDataUuid,
startCol,
endCol,
isBandStart: weekDates[startCol] === overallStart,
isBandEnd: weekDates[endCol] === overallEnd,
overallStart,
overallEnd,
dayInfos,
}) })
} }
} }
const barLanes = assignLanes(result) return assignBandLanes(raw)
return barLanes
} }
function assignLanes( function assignBandLanes(segments: Array<Omit<BandSegment, 'lane'>>): BandSegment[] {
bars: Array<{ startCol: number; endCol: number; parentUuid: string; dayType: string; isDubbeldag: boolean }> const sorted = [...segments].sort((a, b) => {
): BarData[] {
const sorted = [...bars].sort((a, b) => {
if (a.startCol !== b.startCol) return a.startCol - b.startCol if (a.startCol !== b.startCol) return a.startCol - b.startCol
return b.endCol - a.endCol - (a.endCol - a.startCol) - (b.endCol - b.startCol) return b.endCol - a.endCol - (a.endCol - a.startCol) - (b.endCol - b.startCol)
}) })
const lanes: BarData[][] = [] const lanes: BandSegment[][] = []
for (const bar of sorted) { for (const seg of sorted) {
let assignedLane = -1 let assignedLane = -1
for (let laneIdx = 0; laneIdx < lanes.length; laneIdx++) { for (let laneIdx = 0; laneIdx < lanes.length; laneIdx++) {
const lane = lanes[laneIdx] const lane = lanes[laneIdx]
if (lane && lane.every((b) => !(b.startCol <= bar.endCol && b.endCol >= bar.startCol))) { if (lane && lane.every((s) => !(s.startCol <= seg.endCol && s.endCol >= seg.startCol))) {
assignedLane = laneIdx assignedLane = laneIdx
break break
} }
@@ -142,14 +221,7 @@ function assignLanes(
const targetLane = lanes[assignedLane] const targetLane = lanes[assignedLane]
if (targetLane) { if (targetLane) {
targetLane.push({ targetLane.push({ ...seg, lane: assignedLane })
startCol: bar.startCol,
endCol: bar.endCol,
parentUuid: bar.parentUuid,
dayType: bar.dayType,
isDubbeldag: bar.isDubbeldag,
lane: assignedLane,
})
} }
} }
@@ -168,40 +240,48 @@ export function CalendarWeek({
onPointerDown, onPointerDown,
onPointerEnter, onPointerEnter,
calendarDaysByDate, calendarDaysByDate,
onBandClick,
}: CalendarWeekProps): ReactNode { }: CalendarWeekProps): ReactNode {
const { t } = useTranslation() const { t } = useTranslation()
const bars = useMemo(() => buildBarsForWeek(weekDates, plannedDays), [weekDates, plannedDays]) const bandSegments = useMemo(
() => computeBandSegments(weekDates, plannedDays, calendarDaysByDate),
[weekDates, plannedDays, calendarDaysByDate],
)
const maxLane = bars.length > 0 ? Math.max(...bars.map((b) => b.lane)) : 0 const barHeight = 20
const barHeight = 28 const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0
const laneGap = 4 const colOffset = showWeekNumbers ? 1 : 0
const weekMinHeight = 84 + (maxLane + 1) * (barHeight + laneGap) const gridTemplateRows = laneCount > 0 ? `auto repeat(${laneCount}, ${barHeight}px)` : 'auto'
const firstDate = weekDates[0] const firstDate = weekDates[0]
const weekNumber = getISOWeekNumber(new Date(firstDate + 'T00:00:00Z')) const weekNumber = getISOWeekNumber(new Date(firstDate + 'T00:00:00Z'))
return ( return (
<div <div
className="calendar-week" className={`calendar-week${showWeekNumbers ? ' calendar-week--with-week-numbers' : ''}`}
style={{ style={{ gridTemplateRows }}
display: 'contents',
minHeight: `${weekMinHeight}px`,
}}
> >
{showWeekNumbers && ( {showWeekNumbers && (
<div className="calendar__week-number"> <div
className="calendar__week-number"
style={{ gridColumn: '1', gridRow: `1 / ${laneCount + 2}` }}
>
<span className="calendar__week-number-text">v.{weekNumber}</span> <span className="calendar__week-number-text">v.{weekNumber}</span>
</div> </div>
)} )}
{weekDates.map((date) => { {weekDates.map((date, colIdx) => {
const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z') const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z')
const dateObj = new Date(date + 'T00:00:00Z') const dateObj = new Date(date + 'T00:00:00Z')
const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth() const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth()
const calendarInfo = calendarDaysByDate?.get(date) const calendarInfo = calendarDaysByDate?.get(date)
return ( return (
<div key={date} className="calendar-week__day-cell" style={{ position: 'relative' }}> <div
key={date}
className="calendar-week__day-cell"
style={{ gridColumn: String(colIdx + 1 + colOffset), gridRow: '1' }}
>
<CalendarDay <CalendarDay
date={date} date={date}
isCurrentMonth={isCurrentMonth && dayIsCurrentMonth} isCurrentMonth={isCurrentMonth && dayIsCurrentMonth}
@@ -227,49 +307,78 @@ export function CalendarWeek({
} }
}} }}
/> />
</div>
)
})}
<div {bandSegments.map((segment) => {
className="calendar-week__day-bars" const label = segment.parentUuid === personalDataUuid ? parentName : t('calendar.partner')
style={{ const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
position: 'absolute', const representativeType = firstRealDay?.dayType ?? 'vacation'
top: '80%', const typeLabel = getLabelForType(representativeType, t)
left: 0, const color = getColorForType(representativeType)
right: 0, const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
bottom: 0,
pointerEvents: 'none',
}}
>
{bars
.filter((b) => {
const dateIdx = weekDates.indexOf(date)
return dateIdx >= b.startCol && dateIdx <= b.endCol
})
.map((bar, idx) => {
const parentUuid = bar.parentUuid
const label = parentUuid === personalDataUuid ? parentName : t('calendar.partner')
const typeLabel = getLabelForType(bar.dayType, t)
const color = getColorForType(bar.dayType)
return ( const classNames = [
<div 'calendar-band',
key={idx} segment.isBandStart ? 'calendar-band--start' : '',
className="calendar-bar" segment.isBandEnd ? 'calendar-band--end' : '',
style={{ ]
backgroundColor: color, .filter(Boolean)
height: barHeight, .join(' ')
marginBottom: laneGap,
}} const fireClick = () =>
data-testid={`calendar-bar-${idx}`} onBandClick?.({
> wishId: segment.wishId,
<CalendarEvent parentPersonalDataUuid: segment.parentUuid,
leaveType={typeLabel} startDate: segment.overallStart,
color={color} endDate: segment.overallEnd,
isDubbeldag={bar.isDubbeldag} })
parentLabel={label}
/> return (
</div> <div
) key={segment.id}
})} className={classNames}
style={{
gridColumn: `${segment.startCol + 1 + colOffset} / ${segment.endCol + 2 + colOffset}`,
gridRow: String(segment.lane + 2),
backgroundColor: color,
}}
data-testid={`calendar-band-${segment.id}`}
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
role={onBandClick ? 'button' : undefined}
tabIndex={onBandClick ? 0 : undefined}
onClick={onBandClick ? fireClick : undefined}
onKeyDown={
onBandClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fireClick()
}
}
: undefined
}
>
<div className="calendar-band__days">
{segment.dayInfos.map((info) => (
<span
key={info.date}
className={[
'calendar-band__day',
info.isGap ? 'calendar-band__day--gap' : '',
info.isPinned ? 'calendar-band__day--pinned' : '',
]
.filter(Boolean)
.join(' ')}
style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }}
title={info.isPinned ? t('calendarDay.pinnedAria') : undefined}
data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined}
/>
))}
</div>
<div className="calendar-band__label">
<CalendarEvent leaveType={typeLabel} color={color} isDubbeldag={anyDubbeldag} parentLabel={label} />
</div> </div>
</div> </div>
) )
+2
View File
@@ -33,6 +33,8 @@ export const calendar = {
'calendarDay.klamdagAria': 'bridge day', 'calendarDay.klamdagAria': 'bridge day',
'calendarDay.overrideFreeAria': 'marked as a day off (exception)', 'calendarDay.overrideFreeAria': 'marked as a day off (exception)',
'calendarDay.overrideWorkingAria': 'marked as a working day (exception)', 'calendarDay.overrideWorkingAria': 'marked as a working day (exception)',
'calendarDay.pinnedAria': 'pinned by you — the planner will not retype this day',
'calendarDay.sundayAria': 'Sunday',
'calendarEvent.dismissAria': 'Dismiss {leaveType}', 'calendarEvent.dismissAria': 'Dismiss {leaveType}',
'plannedDayModal.cancelButton': 'Cancel', 'plannedDayModal.cancelButton': 'Cancel',
'plannedDayModal.closeAria': 'Close', 'plannedDayModal.closeAria': 'Close',
+2
View File
@@ -33,6 +33,8 @@ export const calendar = {
'calendarDay.klamdagAria': 'klämdag', 'calendarDay.klamdagAria': 'klämdag',
'calendarDay.overrideFreeAria': 'markerad som ledig dag (undantag)', 'calendarDay.overrideFreeAria': 'markerad som ledig dag (undantag)',
'calendarDay.overrideWorkingAria': 'markerad som arbetsdag (undantag)', 'calendarDay.overrideWorkingAria': 'markerad som arbetsdag (undantag)',
'calendarDay.pinnedAria': 'låst av dig – planeraren ändrar inte denna dag',
'calendarDay.sundayAria': 'söndag',
'calendarEvent.dismissAria': 'Avfärda {leaveType}', 'calendarEvent.dismissAria': 'Avfärda {leaveType}',
'plannedDayModal.cancelButton': 'Avbryt', 'plannedDayModal.cancelButton': 'Avbryt',
'plannedDayModal.closeAria': 'Stäng', 'plannedDayModal.closeAria': 'Stäng',
+39 -2
View File
@@ -264,9 +264,14 @@ describe('Calendar', () => {
expect(yearSelect.value).toBe('2026') expect(yearSelect.value).toBe('2026')
}) })
it('year dropdown includes all supported years from 2026 to 2055', () => { it('year dropdown is derived from the calendar window\'s minYear/maxYear, not a hardcoded literal', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }),
}),
)
render(<Calendar />) render(<Calendar />)
@@ -275,12 +280,44 @@ describe('Calendar', () => {
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value)) const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
// Verify the range is correct // Verify the range comes from the API's reported bounds
expect(Math.min(...options)).toBe(2026) expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2055) expect(Math.max(...options)).toBe(2055)
expect(options).toHaveLength(30) // 2026 to 2055 inclusive is 30 years expect(options).toHaveLength(30) // 2026 to 2055 inclusive is 30 years
}) })
it('year dropdown reflects a narrower calendar window when the API reports one', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
// Default fixture window: minYear 2026, maxYear 2035 (see makeCalendarWindow).
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2035)
expect(options).toHaveLength(10)
})
it('always includes the current year in the dropdown even before the calendar window has loaded', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({ data: null, isLoading: true }),
)
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(options).toContain(new Date().getFullYear())
})
it('month and year selects have proper accessibility labels', () => { it('month and year selects have proper accessibility labels', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
+27 -2
View File
@@ -11,7 +11,7 @@ import {
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types' import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import { Layout } from '@/components/layout/Layout' import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { CalendarWeek } from '@/components/common/CalendarWeek' import { CalendarWeek, type BandClickInfo } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal' import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { getColorForType } from '@/utils/leaveTypes' import { getColorForType } from '@/utils/leaveTypes'
import { datesInRange } from '@/utils/dates' import { datesInRange } from '@/utils/dates'
@@ -102,6 +102,17 @@ export function Calendar(): ReactNode {
const year = currentMonth.getFullYear() const year = currentMonth.getFullYear()
const month = currentMonth.getMonth() const month = currentMonth.getMonth()
// The year <select> must cover the API's real supported range (from
// `CalendarWindow.minYear`/`maxYear`, not a hardcoded literal), while
// always including the currently-selected year even before the window has
// loaded (or if it somehow falls outside the reported bounds).
const yearRangeMin = Math.min(calendarWindow?.minYear ?? year, year)
const yearRangeMax = Math.max(calendarWindow?.maxYear ?? year, year)
const yearOptions = Array.from(
{ length: yearRangeMax - yearRangeMin + 1 },
(_, i) => yearRangeMin + i,
)
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0) const lastDay = new Date(year, month + 1, 0)
const daysInMonth = lastDay.getDate() const daysInMonth = lastDay.getDate()
@@ -283,6 +294,13 @@ export function Calendar(): ReactNode {
} }
} }
// Exposed cleanly for a later task: clicking a band (a planned period,
// possibly spanning several weeks) will open a period editor. That editor
// doesn't exist yet, so this is intentionally a no-op for now.
const handleBandClick = (_info: BandClickInfo): void => {
// TODO: open a period editor for this band once it exists.
}
const defaultLeaveType = getLastLeaveType() ?? 'vacation' const defaultLeaveType = getLastLeaveType() ?? 'vacation'
const firstSelectedDate = Array.from(selectedDates).sort()[0] const firstSelectedDate = Array.from(selectedDates).sort()[0]
@@ -323,7 +341,7 @@ export function Calendar(): ReactNode {
className="calendar__year-select" className="calendar__year-select"
aria-label={t('calendar.selectYearAria')} aria-label={t('calendar.selectYearAria')}
> >
{Array.from({ length: 2055 - 2026 + 1 }, (_, i) => 2026 + i).map((y) => ( {yearOptions.map((y) => (
<option key={y} value={y}> <option key={y} value={y}>
{y} {y}
</option> </option>
@@ -364,7 +382,13 @@ export function Calendar(): ReactNode {
{t(key)} {t(key)}
</div> </div>
))} ))}
</div>
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
calendar.css) so a multi-day band can span several day columns as
one element — that only works if each week is a separate grid,
not a flat row of the header grid above. */}
<div className="calendar__weeks">
{weeks.map((week, weekIdx) => { {weeks.map((week, weekIdx) => {
const firstDate = week[0] const firstDate = week[0]
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
@@ -382,6 +406,7 @@ export function Calendar(): ReactNode {
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter} onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate} calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
/> />
) )
})} })}
+120 -18
View File
@@ -81,6 +81,19 @@
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 8px; gap: 8px;
margin-bottom: 8px;
}
/* The weeks themselves now live in a separate flex column below the header
grid (see .calendar-week below) so each week can be its own small CSS
Grid — that lets a multi-day band render as ONE element spanning several
day columns within a week row, with rounded caps only at its true start/
end, instead of a duplicated chip re-rendered inside every day cell it
passes through. */
.calendar__weeks {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -190,6 +203,28 @@
background-color: #fcf0c8; background-color: #fcf0c8;
} }
/* Plain Saturday — neutral grey, deliberately NOT red. Matches the Swedish
almanacka: only Sunday and public/de-facto holidays are red; Saturday is
an ordinary rest day. See CalendarDay.tsx for the weekday-based split
(the backend collapses both into a single "weekend" day_type). */
.calendar-day--weekend {
background-color: #f0f0f0;
border-color: #cfcfcf;
}
.calendar-day--weekend:hover {
background-color: #e8e8e8;
}
.calendar-day__holiday-name {
font-size: 10px;
color: #a8453d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
/* §3.6 — a workspace day override must look distinct from a plain statutory /* §3.6 — a workspace day override must look distinct from a plain statutory
day (and from a statutory red day/klämdag it overrides), regardless of day (and from a statutory red day/klämdag it overrides), regardless of
color, hence the dashed border and the separate badge below. */ color, hence the dashed border and the separate badge below. */
@@ -233,38 +268,105 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Each week is its own small CSS Grid (not a `display: contents` pass-
through) so a band segment can be placed with explicit grid-column/
grid-row spanning several day columns as ONE element. Row 1 holds the 7
(or 8, with week numbers) day cells; row 2+ are "lanes" for band
segments, sized via the `gridTemplateRows` computed in CalendarWeek.tsx
from the max number of concurrent lanes that week. */
.calendar-week { .calendar-week {
position: relative; display: grid;
grid-template-columns: repeat(7, 1fr);
column-gap: 8px;
row-gap: 3px;
}
.calendar-week--with-week-numbers {
grid-template-columns: 50px repeat(7, 1fr);
} }
.calendar-week__day-cell { .calendar-week__day-cell {
display: flex;
flex-direction: column;
position: relative; position: relative;
} }
.calendar-week__days-grid { /* One continuous band segment for this week row. Square by default —
display: contents; .calendar-band--start/--end round only the true first/last row of the
} whole (possibly multi-week) band, so a period crossing a week boundary
reads as one connected object, not a chain of separate chips. */
.calendar-week__day-bars { .calendar-band {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; justify-content: center;
padding: 0 2px; overflow: hidden;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
min-width: 0;
} }
.calendar-bar { .calendar-band--start {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.calendar-band--end {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
.calendar-band:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 1px;
}
.calendar-band__label {
position: absolute;
inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 4px 8px; padding: 0 6px;
border-radius: 3px; font-size: 11px;
font-size: 12px;
color: #fff; color: #fff;
overflow: hidden; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
text-overflow: ellipsis; pointer-events: none;
white-space: nowrap; z-index: 1;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); }
/* The per-day funding-type strip: a period is ONE band, but each day still
shows what pays for it (vacation/sjukpenning/lägsta) via a colored tick,
so the mix inside a period stays visible even though it renders as one
object. */
.calendar-band__days {
display: flex;
height: 100%;
width: 100%;
}
.calendar-band__day {
flex: 1 1 0;
min-width: 0;
height: 100%;
}
/* A bridged gap day (weekend/red day the band passes through without a
funded planned_leave_day row) — no funding color, just a light neutral
fill so the band still reads as continuous. */
.calendar-band__day--gap {
background-color: rgba(255, 255, 255, 0.35);
}
/* §5/§6 — hand-edited ("pinned") day: the planner must not retype it. */
.calendar-band__day--pinned {
position: relative;
}
.calendar-band__day--pinned::after {
content: '📌';
position: absolute;
bottom: 1px;
right: 1px;
font-size: 8px;
line-height: 1;
} }
.calendar-event { .calendar-event {
+4
View File
@@ -183,6 +183,8 @@ interface RawPlannedLeaveDay {
date: string date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation' day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag: boolean is_dubbeldag: boolean
wish_id: string | null
is_pinned: boolean
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -196,6 +198,8 @@ function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay {
date: raw.date, date: raw.date,
dayType: raw.day_type, dayType: raw.day_type,
isDubbeldag: raw.is_dubbeldag, isDubbeldag: raw.is_dubbeldag,
wishId: raw.wish_id,
isPinned: raw.is_pinned,
createdAt: raw.created_at, createdAt: raw.created_at,
updatedAt: raw.updated_at, updatedAt: raw.updated_at,
} }
+8
View File
@@ -104,6 +104,14 @@ export interface PlannedLeaveDay {
date: string // ISO-8601 date date: string // ISO-8601 date
dayType: 'sjukpenning' | 'lagsta' | 'vacation' dayType: 'sjukpenning' | 'lagsta' | 'vacation'
isDubbeldag: boolean isDubbeldag: boolean
// Which `LeaveWish` produced this day; null/omitted means ad-hoc (not tied to a wish).
// Optional (rather than required) so existing object literals elsewhere in the
// codebase that predate this field keep type-checking; `toPlannedLeaveDay` in
// services/api.ts always populates it from the wire response.
wishId?: string | null
// True when the user hand-edited this day's type — the planner must not retype it.
// Optional for the same reason as `wishId` above.
isPinned?: boolean
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
+2
View File
@@ -166,6 +166,8 @@ export function makePlannedLeaveDay(overrides: Partial<PlannedLeaveDay> = {}): P
date: '2026-06-15', date: '2026-06-15',
dayType: 'vacation', dayType: 'vacation',
isDubbeldag: false, isDubbeldag: false,
wishId: null,
isPinned: false,
createdAt: '2026-01-01T00:00:00', createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00', updatedAt: '2026-01-01T00:00:00',
...overrides, ...overrides,