Update calendar with multi-select

This commit is contained in:
2026-06-14 22:20:32 +02:00
parent 88afa17666
commit dead5e4897
14 changed files with 955 additions and 96 deletions
+22 -6
View File
@@ -6,6 +6,10 @@ export interface CalendarDayProps {
isCurrentMonth?: boolean | undefined
onClick?: (() => void) | undefined
isToday?: boolean | undefined
isSelected?: boolean | undefined
onPointerDown?: ((date: string) => void) | undefined
onPointerEnter?: ((date: string) => void) | undefined
onActivate?: (() => void) | undefined
}
export function CalendarDay({
@@ -14,9 +18,23 @@ export function CalendarDay({
isCurrentMonth = true,
onClick,
isToday = false,
isSelected = false,
onPointerDown,
onPointerEnter,
onActivate,
}: CalendarDayProps): ReactNode {
const dayNum = new Date(date + 'T00:00:00Z').getUTCDate()
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
if (onActivate) {
onActivate()
} else {
onClick?.()
}
}
}
return (
<div
data-testid="calendar-day"
@@ -24,12 +42,10 @@ export function CalendarDay({
role="button"
tabIndex={0}
aria-label={`${date}${events.length > 0 ? ` with ${events.length} event(s)` : ''}`}
className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''} ${isToday ? 'calendar-day--today' : ''}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.()
}
}}
className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''} ${isToday ? 'calendar-day--today' : ''} ${isSelected ? 'calendar-day--selected' : ''}`}
onKeyDown={handleKeyDown}
onPointerDown={() => onPointerDown?.(date)}
onPointerEnter={() => onPointerEnter?.(date)}
>
<div className="calendar-day__number">{dayNum}</div>
{events.length > 0 && (
@@ -5,6 +5,8 @@ export interface CalendarEventProps {
color: string
title?: string
onDismiss?: () => void
isDubbeldag?: boolean | undefined
parentLabel?: string | undefined
}
export function CalendarEvent({
@@ -12,7 +14,11 @@ export function CalendarEvent({
color,
title,
onDismiss,
isDubbeldag = false,
parentLabel,
}: CalendarEventProps): ReactNode {
const displayLabel = parentLabel ? `${parentLabel} · ${leaveType}` : leaveType
return (
<div
data-testid="calendar-event"
@@ -20,8 +26,9 @@ export function CalendarEvent({
style={{ backgroundColor: color }}
>
<span className="calendar-event__label" title={title ?? leaveType}>
{leaveType}
{displayLabel}
</span>
{isDubbeldag && <span className="calendar-event__badge">2×</span>}
{onDismiss && (
<button
className="calendar-event__dismiss"
@@ -0,0 +1,277 @@
import { ReactNode, useMemo } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
import { getISOWeekNumber } from '@/utils/dates'
import { CalendarDay } from './CalendarDay'
import { CalendarEvent } from './CalendarEvent'
export interface CalendarWeekProps {
weekDates: string[]
plannedDays: PlannedLeaveDay[]
selectedDates: Set<string>
parentName: string
personalDataUuid: string
isCurrentMonth: boolean
showWeekNumbers: boolean
onDayClick: (date: string) => void
onPointerDown: (date: string) => void
onPointerEnter: (date: string) => void
}
interface BarData {
startCol: number
endCol: number
parentUuid: string
dayType: string
isDubbeldag: boolean
lane: number
}
function buildBarsForWeek(
weekDates: string[],
plannedDays: PlannedLeaveDay[]
): BarData[] {
const dayToEntries = new Map<string, PlannedLeaveDay[]>()
for (const date of weekDates) {
dayToEntries.set(date, plannedDays.filter((pd) => pd.date === date))
}
const series = new Map<
string,
{ bars: Array<{ startIdx: number; endIdx: number }> }
>()
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 result: Array<{
startCol: number
endCol: number
parentUuid: string
dayType: string
isDubbeldag: boolean
}> = []
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'
let startIdx = -1
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
)
if (hasEntry) {
if (startIdx === -1) {
startIdx = i
}
} else {
if (startIdx !== -1) {
result.push({
startCol: startIdx,
endCol: i - 1,
parentUuid,
dayType,
isDubbeldag: isDoubleStr,
})
startIdx = -1
}
}
}
if (startIdx !== -1) {
result.push({
startCol: startIdx,
endCol: weekDates.length - 1,
parentUuid,
dayType,
isDubbeldag: isDoubleStr,
})
}
}
const barLanes = assignLanes(result)
return barLanes
}
function assignLanes(
bars: Array<{ startCol: number; endCol: number; parentUuid: string; dayType: string; isDubbeldag: boolean }>
): BarData[] {
const sorted = [...bars].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[][] = []
for (const bar 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))) {
assignedLane = laneIdx
break
}
}
if (assignedLane === -1) {
assignedLane = lanes.length
lanes.push([])
}
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,
})
}
}
return lanes.flat()
}
export function CalendarWeek({
weekDates,
plannedDays,
selectedDates,
parentName,
personalDataUuid,
isCurrentMonth,
showWeekNumbers,
onDayClick,
onPointerDown,
onPointerEnter,
}: CalendarWeekProps): ReactNode {
const bars = useMemo(() => buildBarsForWeek(weekDates, plannedDays), [weekDates, plannedDays])
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 firstDate = weekDates[0]
const weekNumber = getISOWeekNumber(new Date(firstDate + 'T00:00:00Z'))
return (
<div
className="calendar-week"
style={{
display: 'contents',
minHeight: `${weekMinHeight}px`,
}}
>
{showWeekNumbers && (
<div className="calendar__week-number">
<span className="calendar__week-number-text">v.{weekNumber}</span>
</div>
)}
{weekDates.map((date) => {
const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z')
const dateObj = new Date(date + 'T00:00:00Z')
const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth()
return (
<div key={date} className="calendar-week__day-cell" style={{ position: 'relative' }}>
<CalendarDay
date={date}
isCurrentMonth={isCurrentMonth && dayIsCurrentMonth}
isToday={date === getTodayString()}
isSelected={selectedDates.has(date)}
onClick={() => {
if (isCurrentMonth && dayIsCurrentMonth) {
onDayClick(date)
}
}}
onPointerDown={(d) => {
if (isCurrentMonth && dayIsCurrentMonth) {
onPointerDown(d)
}
}}
onPointerEnter={(d) => {
if (isCurrentMonth && dayIsCurrentMonth) {
onPointerEnter(d)
}
}}
/>
<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 : 'Partner'
const typeLabel = getLabelForType(bar.dayType)
const color = getColorForType(bar.dayType)
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>
)
})}
</div>
</div>
)
})}
</div>
)
}
function getTodayString(): string {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}