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:
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -33,6 +33,8 @@ export const calendar = {
|
||||
'calendarDay.klamdagAria': 'bridge day',
|
||||
'calendarDay.overrideFreeAria': 'marked as a day off (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}',
|
||||
'plannedDayModal.cancelButton': 'Cancel',
|
||||
'plannedDayModal.closeAria': 'Close',
|
||||
|
||||
@@ -33,6 +33,8 @@ export const calendar = {
|
||||
'calendarDay.klamdagAria': 'klämdag',
|
||||
'calendarDay.overrideFreeAria': 'markerad som ledig dag (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}',
|
||||
'plannedDayModal.cancelButton': 'Avbryt',
|
||||
'plannedDayModal.closeAria': 'Stäng',
|
||||
|
||||
@@ -264,9 +264,14 @@ describe('Calendar', () => {
|
||||
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(usePlannedDays).mockReturnValue(mockPlannedDays)
|
||||
vi.mocked(useCalendarDays).mockReturnValue(
|
||||
makeCalendarDaysResult({
|
||||
data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }),
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
@@ -275,12 +280,44 @@ describe('Calendar', () => {
|
||||
|
||||
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.max(...options)).toBe(2055)
|
||||
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', () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
|
||||
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
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 { getColorForType } from '@/utils/leaveTypes'
|
||||
import { datesInRange } from '@/utils/dates'
|
||||
@@ -102,6 +102,17 @@ export function Calendar(): ReactNode {
|
||||
const year = currentMonth.getFullYear()
|
||||
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 lastDay = new Date(year, month + 1, 0)
|
||||
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 firstSelectedDate = Array.from(selectedDates).sort()[0]
|
||||
|
||||
@@ -323,7 +341,7 @@ export function Calendar(): ReactNode {
|
||||
className="calendar__year-select"
|
||||
aria-label={t('calendar.selectYearAria')}
|
||||
>
|
||||
{Array.from({ length: 2055 - 2026 + 1 }, (_, i) => 2026 + i).map((y) => (
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
@@ -364,7 +382,13 @@ export function Calendar(): ReactNode {
|
||||
{t(key)}
|
||||
</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) => {
|
||||
const firstDate = week[0]
|
||||
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
|
||||
@@ -382,6 +406,7 @@ export function Calendar(): ReactNode {
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
calendarDaysByDate={calendarDaysByDate}
|
||||
onBandClick={handleBandClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
+120
-18
@@ -81,6 +81,19 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -190,6 +203,28 @@
|
||||
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
|
||||
day (and from a statutory red day/klämdag it overrides), regardless of
|
||||
color, hence the dashed border and the separate badge below. */
|
||||
@@ -233,38 +268,105 @@
|
||||
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 {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-week__days-grid {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.calendar-week__day-bars {
|
||||
/* One continuous band segment for this week row. Square by default —
|
||||
.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-band {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 2px;
|
||||
justify-content: center;
|
||||
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;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 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 {
|
||||
|
||||
@@ -183,6 +183,8 @@ interface RawPlannedLeaveDay {
|
||||
date: string
|
||||
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
is_dubbeldag: boolean
|
||||
wish_id: string | null
|
||||
is_pinned: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -196,6 +198,8 @@ function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay {
|
||||
date: raw.date,
|
||||
dayType: raw.day_type,
|
||||
isDubbeldag: raw.is_dubbeldag,
|
||||
wishId: raw.wish_id,
|
||||
isPinned: raw.is_pinned,
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
}
|
||||
|
||||
@@ -104,6 +104,14 @@ export interface PlannedLeaveDay {
|
||||
date: string // ISO-8601 date
|
||||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
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
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user