Files
F-r-ldraledigplannerare/frontend/src/components/common/CalendarWeek.tsx
T
jacobh2 dbef997da4 Redraw the calendar as an almanac: gridless cells, typed bands
Day cells lose their borders and fills; red days, klämdagar and
Saturdays are told apart by the numeral's ink with a 12px R/K mark,
today is a filled circle, overrides and exclusions mark the numeral.
Bands carry their funding type by fill, ink and hatching and their
owner by a left cap. The preset rail becomes a chip row, the legend a
help popover, the period popover's three delete buttons fold behind
one "Ta bort…", and solo users pick a type directly in the day modal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-19 19:02:02 +02:00

499 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>
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<string, string> | 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<string, CalendarDayEntry> | 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<string> | 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<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
}
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) {
const key = bandKeyFor(pd)
const list = byKey.get(key)
if (list) {
list.push(pd)
} else {
byKey.set(key, [pd])
}
}
const raw: Array<Omit<BandSegment, 'lane'>> = []
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<string, CalendarDayEntry>,
): number {
const segments = computeBandSegments(weekDates, plannedDays, calendarDaysByDate)
return segments.length > 0 ? Math.max(...segments.map((b) => b.lane)) + 1 : 0
}
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: 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 (
<div
className={`calendar-week${showWeekNumbers ? ' calendar-week--with-week-numbers' : ''}`}
style={weekStyle}
>
{showWeekNumbers && (
<div className="calendar__week-number" style={{ gridColumn: '1', gridRow: '1' }}>
<span className="calendar__week-number-text">v.{weekNumber}</span>
</div>
)}
{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 (
<div
key={date}
className="calendar-week__day-cell"
style={{ gridColumn: String(colIdx + 1 + colOffset), gridRow: '1' }}
>
<CalendarDay
date={date}
isCurrentMonth={dayIsCurrentMonth}
isToday={date === formatDateLocalISO(new Date())}
isSelected={selectedDates.has(date)}
dayType={calendarInfo?.dayType}
holidayName={calendarInfo?.name ?? null}
isOverride={calendarInfo?.isOverride ?? false}
overrideIsWorkingDay={calendarInfo?.overrideIsWorkingDay ?? null}
isExcluded={excludedDates?.has(date) ?? false}
// Every cell is clickable/drag-selectable regardless of which
// month it visually belongs to — `dayIsCurrentMonth` only
// controls the dimmed `calendar-day--disabled` styling above.
// Two failure modes this avoids, both seen in production:
// (1) gating on `weekDates[0]`'s month killed an entire
// leading week; (2) gating per-cell on the day's own month
// (the previous fix here) still killed every adjacent-month
// cell, so a drag could never cross a month boundary — see
// the regression this closes in Calendar.tsx's pointerup
// handler / drag-create-delete.spec.ts. Clicking a visible
// adjacent-month day plans that day, same as clicking any
// other visible day; that's a deliberate call, not an
// oversight — silently doing nothing on a visible cell is
// worse than acting on it.
onClick={() => onDayClick(date)}
onPointerDown={(d) => onPointerDown(d)}
onPointerEnter={(d) => onPointerEnter(d)}
/>
</div>
)
})}
{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 (
<div
key={segment.id}
className={classNames}
style={{
gridColumn: `${segment.startCol + 1 + colOffset} / ${segment.endCol + 2 + colOffset}`,
gridRow: '1',
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
height: `${barHeight}px`,
backgroundColor: color,
color: ink,
}}
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' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
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}
// Clicking one day slot opens the same popover as clicking
// the band, but tells it WHICH date was clicked — "this
// day" must be able to remove a day in the middle of a
// period, not only its first.
onClick={
onBandClick
? (e) => {
e.stopPropagation()
fireClick(info.date)
}
: undefined
}
/>
))}
</div>
<div className="calendar-band__label">
<CalendarEvent
leaveType={typeLabel}
color={color}
isDubbeldag={anyDubbeldag}
parentLabel={label}
paintBackground={false}
/>
</div>
</div>
)
})}
</div>
)
}