import { ReactNode, useMemo } from 'react' import type { PlannedLeaveDay, CalendarDayEntry } from '@/types' import { getColorForType, getInkForType, getLabelForType, getTypeKey } from '@/utils/leaveTypes' import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates' 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 /** The single date the user actually clicked inside the band, which is what * the popover's "this day" removes. Clicking the band anywhere else (or * activating it from the keyboard) reports the band's own start date, so * this is never empty. */ clickedDate: string } export interface CalendarWeekProps { weekDates: string[] plannedDays: PlannedLeaveDay[] selectedDates: Set parentName: string personalDataUuid: string /** Every workspace member's display name keyed by `personalDataUuid`, used * to label a band owned by someone other than the current user (a * partner's band must show their actual name, not the generic * `calendar.partner` string). Falls back to that generic string for a * `parentUuid` matching no known member (stale data, removed partner). */ parentNames?: Map | undefined /** The calendar's currently displayed year/month — each day in the week * is compared against these directly (not against `weekDates[0]`) so a * leading/trailing week that straddles a month boundary still activates * its in-month cells. See Calendar.tsx's week-building loop: a leading * week's `weekDates[0]` can belong entirely to the previous month even * though most of the row is the displayed month. */ displayedYear: number displayedMonth: number showWeekNumbers: boolean onDayClick: (date: string) => void onPointerDown: (date: string) => void onPointerEnter: (date: string) => void /** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */ calendarDaysByDate?: Map | undefined /** Dates the user has removed from the plan by hand (see * `PlannedDayExclusion`). A date the planner deliberately skipped inside an * otherwise-funded period is invisible otherwise, and the hole in the week * looks like a bug. */ excludedDates?: Set | undefined /** Opens the period editor for the clicked band's underlying period. */ onBandClick?: ((info: BandClickInfo) => void) | undefined /** §Task7 — the wishId of a just-created/just-edited period to briefly * highlight (see Calendar.tsx's preset rail: "scrolls to the resolved * placement and flashes the band"). `null`/omitted highlights nothing. */ flashWishId?: string | null | undefined /** The highest concurrent-lane count across every week in the displayed * month (computed once in Calendar.tsx via `computeWeekLaneCount` over * each week, then passed down uniformly to all of them). Without this, * `--band-reserved-height` was computed purely from THIS week's own bands, * so a week with more overlapping bands (or any bands at all) reserved * more bottom padding than a quiet week and rendered taller — every row in * a calendar grid must be the same height regardless of what it contains. * Omitted/undefined falls back to this week's own lane count (used by * existing unit tests that render a single week in isolation). */ monthMaxLaneCount?: number | undefined } /** 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 /** 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 } /** 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}` } 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): 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 } function computeBandSegments( weekDates: string[], plannedDays: PlannedLeaveDay[], calendarDaysByDate?: Map, ): BandSegment[] { const weekStart = weekDates[0] const weekEnd = weekDates[weekDates.length - 1] if (weekStart === undefined || weekEnd === undefined) return [] const byKey = new Map() for (const pd of plannedDays) { const key = bandKeyFor(pd) const list = byKey.get(key) if (list) { list.push(pd) } else { byKey.set(key, [pd]) } } const raw: Array> = [] for (const [key, entries] of byKey) { const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date)) 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 const run = sorted.slice(runStart, i) runStart = i 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 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, }) } } return assignBandLanes(raw) } /** The concurrent-lane count a single week's own bands would need — the same * computation `CalendarWeek` uses internally for `bandsReservedHeight`, * exposed so Calendar.tsx can compute it for every week up front and take * the month-wide max (see `monthMaxLaneCount` above). Kept as a thin wrapper * over `computeBandSegments` rather than duplicating its logic. */ export function computeWeekLaneCount( weekDates: string[], plannedDays: PlannedLeaveDay[], calendarDaysByDate?: Map, ): number { const segments = computeBandSegments(weekDates, plannedDays, calendarDaysByDate) return segments.length > 0 ? Math.max(...segments.map((b) => b.lane)) + 1 : 0 } function assignBandLanes(segments: Array>): 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: BandSegment[][] = [] for (const seg of sorted) { let assignedLane = -1 for (let laneIdx = 0; laneIdx < lanes.length; laneIdx++) { const lane = lanes[laneIdx] if (lane && lane.every((s) => !(s.startCol <= seg.endCol && s.endCol >= seg.startCol))) { assignedLane = laneIdx break } } if (assignedLane === -1) { assignedLane = lanes.length lanes.push([]) } const targetLane = lanes[assignedLane] if (targetLane) { targetLane.push({ ...seg, lane: assignedLane }) } } return lanes.flat() } export function CalendarWeek({ weekDates, plannedDays, selectedDates, parentName, personalDataUuid, parentNames, displayedYear, displayedMonth, showWeekNumbers, onDayClick, onPointerDown, onPointerEnter, calendarDaysByDate, excludedDates, onBandClick, flashWishId, monthMaxLaneCount, }: CalendarWeekProps): ReactNode { const { t } = useTranslation() const bandSegments = useMemo( () => computeBandSegments(weekDates, plannedDays, calendarDaysByDate), [weekDates, plannedDays, calendarDaysByDate], ) // Bands overlay the day-cell row itself (grid-row: 1, position: absolute) // instead of occupying separate rows below it — see the comment on // `.calendar-week` in calendar.css. `barHeight`/`laneGap`/`bandBottomInset` // together define both each lane's vertical offset from the cell's bottom // edge AND, via `bandsReservedHeight`, how much bottom padding // `.calendar-day` reserves so its number/badges/holiday name never sit // under a band. const barHeight = 18 const laneGap = 2 const bandBottomInset = 4 const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0 // Reserve space for the month's tallest week, not just this one — see // `monthMaxLaneCount`'s doc comment. `Math.max` with this week's own // `laneCount` is a safety floor (never reserve LESS than this row's own // bands actually need), never used to reserve more than the month's real // max since the caller always passes that max down to every week. const effectiveLaneCount = monthMaxLaneCount !== undefined ? Math.max(laneCount, monthMaxLaneCount) : laneCount const bandsReservedHeight = effectiveLaneCount > 0 ? bandBottomInset + effectiveLaneCount * (barHeight + laneGap) : 0 const colOffset = showWeekNumbers ? 1 : 0 const firstDate = weekDates[0] const weekNumber = getISOWeekNumber(new Date(firstDate + 'T00:00:00Z')) const weekStyle = { '--band-reserved-height': `${bandsReservedHeight}px`, } as React.CSSProperties & Record<'--band-reserved-height', string> return (
{showWeekNumbers && (
v.{weekNumber}
)} {weekDates.map((date, colIdx) => { const dateObj = new Date(date + 'T00:00:00Z') const dayIsCurrentMonth = dateObj.getUTCFullYear() === displayedYear && dateObj.getUTCMonth() === displayedMonth const calendarInfo = calendarDaysByDate?.get(date) return (
onDayClick(date)} onPointerDown={(d) => onPointerDown(d)} onPointerEnter={(d) => onPointerEnter(d)} />
) })} {bandSegments.map((segment) => { const label = segment.parentUuid === personalDataUuid ? parentName : (parentNames?.get(segment.parentUuid) ?? 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 ink = getInkForType(representativeType) const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag) // Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`, // see the --c-leave-* tokens) and owner by a 2px left cap // (`--self`/`--partner`) — never by hue, so the two axes stay // readable side by side and in greyscale. const classNames = [ 'calendar-band', `calendar-band--${getTypeKey(representativeType)}`, segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner', segment.isBandStart ? 'calendar-band--start' : '', segment.isBandEnd ? 'calendar-band--end' : '', segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '', ] .filter(Boolean) .join(' ') const fireClick = (clickedDate?: string) => onBandClick?.({ wishId: segment.wishId, parentPersonalDataUuid: segment.parentUuid, startDate: segment.overallStart, endDate: segment.overallEnd, // Falls back to the band's first date when the click didn't land // on a specific day slot (the band's padding, or the keyboard). clickedDate: clickedDate ?? segment.overallStart, }) return (
fireClick() : undefined} onKeyDown={ onBandClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() fireClick() } } : undefined } >
{segment.dayInfos.map((info) => ( { e.stopPropagation() fireClick(info.date) } : undefined } /> ))}
) })}
) }