dbef997da4
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
499 lines
20 KiB
TypeScript
499 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|