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).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', () => {
+25 -4
View File
@@ -39,7 +39,20 @@ export function CalendarDay({
overrideIsWorkingDay = null,
}: CalendarDayProps): ReactNode {
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>) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -61,6 +74,8 @@ export function CalendarDay({
descriptors.push(cleanHolidayName)
} else if (dayType === 'klamdag') {
descriptors.push(t('calendarDay.klamdagAria'))
} else if (isBareSunday) {
descriptors.push(t('calendarDay.sundayAria'))
}
if (isOverride) {
descriptors.push(
@@ -78,8 +93,9 @@ export function CalendarDay({
events.length > 0 ? 'calendar-day--has-events' : '',
isToday ? 'calendar-day--today' : '',
isSelected ? 'calendar-day--selected' : '',
dayType === 'red_day' ? 'calendar-day--red-day' : '',
isRedDay ? 'calendar-day--red-day' : '',
dayType === 'klamdag' ? 'calendar-day--klamdag' : '',
isPlainSaturday ? 'calendar-day--weekend' : '',
isOverride ? 'calendar-day--override' : '',
]
.filter(Boolean)
@@ -99,11 +115,16 @@ export function CalendarDay({
onPointerEnter={() => onPointerEnter?.(date)}
>
<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">
{dayType === 'red_day' ? 'R' : 'K'}
{dayType === 'klamdag' ? 'K' : 'R'}
</span>
)}
{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 ? '⚒' : '✓'}
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 { 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 {
weekDates: string[]
plannedDays: PlannedLeaveDay[]
@@ -19,117 +29,186 @@ export interface CalendarWeekProps {
onPointerEnter: (date: string) => void
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
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
endCol: number
parentUuid: string
dayType: string
isDubbeldag: boolean
/** True only in the week row containing the band's true first/last date —
* only that row gets a rounded cap; other rows are square continuations. */
isBandStart: boolean
isBandEnd: boolean
overallStart: string
overallEnd: string
dayInfos: BandDayInfo[]
lane: number
}
function buildBarsForWeek(
weekDates: string[],
plannedDays: PlannedLeaveDay[]
): BarData[] {
const dayToEntries = new Map<string, PlannedLeaveDay[]>()
/** Grouping key for banding: a wish-linked day bands with every other day
* from the SAME wish + parent regardless of its own day type (a period is
* one object even if its funding mix varies day to day); an ad-hoc day
* (no wish) falls back to the pre-existing parent/type/dubbeldag key. */
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) {
dayToEntries.set(date, plannedDays.filter((pd) => pd.date === date))
function addDaysISO(dateStr: string, delta: number): string {
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<
string,
{ bars: Array<{ startIdx: number; endIdx: number }> }
>()
function computeBandSegments(
weekDates: string[],
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) {
if (!weekDates.includes(pd.date)) continue
const key = `${pd.parentPersonalDataUuid}|${pd.dayType}|${pd.isDubbeldag}`
if (!series.has(key)) {
series.set(key, { bars: [] })
const key = bandKeyFor(pd)
const list = byKey.get(key)
if (list) {
list.push(pd)
} else {
byKey.set(key, [pd])
}
}
const result: Array<{
startCol: number
endCol: number
parentUuid: string
dayType: string
isDubbeldag: boolean
}> = []
const raw: Array<Omit<BandSegment, 'lane'>> = []
for (const [key] of series) {
const parts = key.split('|')
const parentUuid = parts[0] ?? ''
const dayType = parts[1] ?? ''
const isDubbeldag = parts[2] ?? 'false'
const isDoubleStr = isDubbeldag === 'true'
for (const [key, entries] of byKey) {
const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date))
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 date = weekDates[i]
const hasEntry = plannedDays.some(
(pd) =>
pd.date === date &&
pd.parentPersonalDataUuid === parentUuid &&
pd.dayType === dayType &&
pd.isDubbeldag === isDoubleStr
)
const run = sorted.slice(runStart, i)
runStart = i
if (hasEntry) {
if (startIdx === -1) {
startIdx = i
}
} else {
if (startIdx !== -1) {
result.push({
startCol: startIdx,
endCol: i - 1,
parentUuid,
dayType,
isDubbeldag: isDoubleStr,
})
startIdx = -1
const first = run[0]
const last = run[run.length - 1]
if (!first || !last) continue
const overallStart = first.date
const overallEnd = last.date
if (overallEnd < weekStart || overallStart > weekEnd) continue
let startCol = -1
let endCol = -1
for (let c = 0; c < weekDates.length; c++) {
const d = weekDates[c]
if (d !== undefined && d >= overallStart && d <= overallEnd) {
if (startCol === -1) startCol = c
endCol = c
}
}
}
if (startCol === -1 || endCol === -1) continue
if (startIdx !== -1) {
result.push({
startCol: startIdx,
endCol: weekDates.length - 1,
parentUuid,
dayType,
isDubbeldag: isDoubleStr,
const entryByDate = new Map(run.map((pd) => [pd.date, pd] as const))
const dayInfos: BandDayInfo[] = []
for (let c = startCol; c <= endCol; c++) {
const d = weekDates[c]
if (d === undefined) continue
const entry = entryByDate.get(d)
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 barLanes
return assignBandLanes(raw)
}
function assignLanes(
bars: Array<{ startCol: number; endCol: number; parentUuid: string; dayType: string; isDubbeldag: boolean }>
): BarData[] {
const sorted = [...bars].sort((a, b) => {
function assignBandLanes(segments: Array<Omit<BandSegment, 'lane'>>): BandSegment[] {
const sorted = [...segments].sort((a, b) => {
if (a.startCol !== b.startCol) return a.startCol - 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
for (let laneIdx = 0; laneIdx < lanes.length; 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
break
}
@@ -142,14 +221,7 @@ function assignLanes(
const targetLane = lanes[assignedLane]
if (targetLane) {
targetLane.push({
startCol: bar.startCol,
endCol: bar.endCol,
parentUuid: bar.parentUuid,
dayType: bar.dayType,
isDubbeldag: bar.isDubbeldag,
lane: assignedLane,
})
targetLane.push({ ...seg, lane: assignedLane })
}
}
@@ -168,40 +240,48 @@ export function CalendarWeek({
onPointerDown,
onPointerEnter,
calendarDaysByDate,
onBandClick,
}: CalendarWeekProps): ReactNode {
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 = 28
const laneGap = 4
const weekMinHeight = 84 + (maxLane + 1) * (barHeight + laneGap)
const barHeight = 20
const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 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'))
return (
<div
className="calendar-week"
style={{
display: 'contents',
minHeight: `${weekMinHeight}px`,
}}
className={`calendar-week${showWeekNumbers ? ' calendar-week--with-week-numbers' : ''}`}
style={{ gridTemplateRows }}
>
{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>
</div>
)}
{weekDates.map((date) => {
{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 calendarInfo = calendarDaysByDate?.get(date)
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
date={date}
isCurrentMonth={isCurrentMonth && dayIsCurrentMonth}
@@ -227,49 +307,78 @@ export function CalendarWeek({
}
}}
/>
</div>
)
})}
<div
className="calendar-week__day-bars"
style={{
position: 'absolute',
top: '80%',
left: 0,
right: 0,
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)
{bandSegments.map((segment) => {
const label = segment.parentUuid === personalDataUuid ? parentName : t('calendar.partner')
const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
const representativeType = firstRealDay?.dayType ?? 'vacation'
const typeLabel = getLabelForType(representativeType, t)
const color = getColorForType(representativeType)
const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
return (
<div
key={idx}
className="calendar-bar"
style={{
backgroundColor: color,
height: barHeight,
marginBottom: laneGap,
}}
data-testid={`calendar-bar-${idx}`}
>
<CalendarEvent
leaveType={typeLabel}
color={color}
isDubbeldag={bar.isDubbeldag}
parentLabel={label}
/>
</div>
)
})}
const classNames = [
'calendar-band',
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
]
.filter(Boolean)
.join(' ')
const fireClick = () =>
onBandClick?.({
wishId: segment.wishId,
parentPersonalDataUuid: segment.parentUuid,
startDate: segment.overallStart,
endDate: segment.overallEnd,
})
return (
<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>
)