import { ReactNode, useMemo, useState, useEffect } from 'react' import { useWorkspace } from '@/context' import { usePlannedDays, useShowWeekNumbers, getLastLeaveType, setLastLeaveType, useCalendarDays, useDayOverrides, } from '@/hooks' 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 { PlannedDayModal } from '@/components/modals/PlannedDayModal' import { getColorForType } from '@/utils/leaveTypes' import { datesInRange } from '@/utils/dates' import { useTranslation } from '@/i18n' import './calendar.css' const WEEKDAY_KEYS = [ 'calendar.weekdayMon', 'calendar.weekdayTue', 'calendar.weekdayWed', 'calendar.weekdayThu', 'calendar.weekdayFri', 'calendar.weekdaySat', 'calendar.weekdaySun', ] as const export function Calendar(): ReactNode { const { t, locale } = useTranslation() const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace() const { data: plannedDays, isLoading: pdLoading, add, update, remove } = usePlannedDays(selectedChildId ?? undefined) const [showWeekNumbers] = useShowWeekNumbers() const [currentMonth, setCurrentMonth] = useState(new Date()) const [selectedDates, setSelectedDates] = useState>(new Set()) const [dragAnchor, setDragAnchor] = useState(null) const [dragCurrent, setDragCurrent] = useState(null) const [isDragging, setIsDragging] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) const [isRangeMode, setIsRangeMode] = useState(false) const [isSaving, setIsSaving] = useState(false) const [modalError, setModalError] = useState(null) const [isOverrideMode, setIsOverrideMode] = useState(false) const [overrideError, setOverrideError] = useState(null) const isLoading = wsLoading || pdLoading // §3.4/§3.6 — the visible grid can spill into the previous year (January's // leading days) or the next year (December's trailing days), so fetch // whichever 1-2 calendar years the current month view actually touches. const viewYear = currentMonth.getFullYear() const viewMonth = currentMonth.getMonth() const calendarMinYear = viewMonth === 0 ? viewYear - 1 : viewYear const calendarMaxYear = viewMonth === 11 ? viewYear + 1 : viewYear const calendarYearsSpan = calendarMaxYear - calendarMinYear + 1 const { data: calendarWindow, refetch: refetchCalendarWindow } = useCalendarDays( workspace?.id ?? '', calendarMinYear, calendarYearsSpan, ) const { data: dayOverrides, upsert: upsertDayOverride, remove: removeDayOverride } = useDayOverrides( workspace?.id ?? '', ) const calendarDaysByDate = useMemo(() => { const map = new Map() for (const day of calendarWindow?.days ?? []) { map.set(day.date, day) } return map }, [calendarWindow]) useEffect(() => { const handlePointerUp = () => { if (isDragging && dragAnchor && dragCurrent) { const dates = datesInRange(dragAnchor, dragCurrent) setSelectedDates(new Set(dates)) if (dates.length === 1) { setIsRangeMode(false) } else { setIsRangeMode(true) } setIsModalOpen(true) } setIsDragging(false) } if (isDragging) { window.addEventListener('pointerup', handlePointerUp) return () => window.removeEventListener('pointerup', handlePointerUp) } }, [isDragging, dragAnchor, dragCurrent]) if (isLoading) { return } const year = currentMonth.getFullYear() const month = currentMonth.getMonth() const firstDay = new Date(year, month, 1) const lastDay = new Date(year, month + 1, 0) const daysInMonth = lastDay.getDate() const startOffset = (firstDay.getDay() + 6) % 7 const weeks: string[][] = [] let currentWeek: string[] = [] for (let i = 0; i < startOffset; i++) { const prevDate = new Date(year, month, -startOffset + i + 1) currentWeek.push(formatDate(prevDate)) } for (let day = 1; day <= daysInMonth; day++) { currentWeek.push(formatDate(new Date(year, month, day))) if (currentWeek.length === 7) { weeks.push(currentWeek) currentWeek = [] } } const remaining = 7 - currentWeek.length for (let i = 0; i < remaining; i++) { currentWeek.push(formatDate(new Date(year, month + 1, i + 1))) } weeks.push(currentWeek) const handlePrevMonth = () => { setCurrentMonth(new Date(year, month - 1, 1)) } const handleNextMonth = () => { setCurrentMonth(new Date(year, month + 1, 1)) } const handleMonthChange = (e: React.ChangeEvent) => { const newMonth = Number(e.target.value) setCurrentMonth(new Date(year, newMonth, 1)) } const handleYearChange = (e: React.ChangeEvent) => { const newYear = Number(e.target.value) setCurrentMonth(new Date(newYear, month, 1)) } // §3.6 — an explicit mode toggle (rather than a modifier-click or context // menu) keeps day-override editing unambiguous and fully decoupled from // the pointer-drag multi-select gesture below: modifier keys don't exist // on touch, and a context menu needs its own a11y/keyboard path. While the // mode is active, pointer-drag is disabled entirely so a click always // means "toggle this day's override", never "start a range selection". const handlePointerDown = (date: string) => { if (isOverrideMode) return setDragAnchor(date) setDragCurrent(date) setIsDragging(true) } const handlePointerEnter = (date: string) => { if (isOverrideMode) return if (isDragging) { setDragCurrent(date) } } const handleOverrideToggle = async (date: string) => { if (!workspace?.id) return const info = calendarDaysByDate.get(date) if (!info) return try { setOverrideError(null) if (info.isOverride) { const existing = (dayOverrides ?? []).find((o) => o.date === date) if (existing) { await removeDayOverride(existing.id) } } else { // Toggle the effective status: a currently-free day becomes an // override marking it a working day, and vice versa. await upsertDayOverride({ workspaceId: workspace.id, date, isWorkingDay: info.isFree, note: null, }) } // The grid's shading/badges come from GET /api/calendar/days, a // separate fetch from the override list above — without this // explicit refetch the day-overrides list would be up to date but the // calendar cell would keep showing the pre-mutation state until a // full page reload. await refetchCalendarWindow() } catch (err) { setOverrideError(err instanceof Error ? err.message : t('calendar.overrideError')) } } const handleDayClick = (date: string) => { if (isOverrideMode) { void handleOverrideToggle(date) return } setSelectedDates(new Set([date])) setIsRangeMode(false) setIsModalOpen(true) } const getExistingDay = (date: string): PlannedLeaveDay | undefined => { return (plannedDays ?? []).find((pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid) } const handleSavePlannedDay = async (payload: { dayType: 'sjukpenning' | 'lagsta' | 'vacation' parentId: string isDubbeldag: boolean }) => { if (selectedDates.size === 0 || !selectedChildId || !workspace?.id || !personalDataUuid) return try { setIsSaving(true) setModalError(null) setLastLeaveType(payload.dayType) for (const date of selectedDates) { const existingDay = (plannedDays ?? []).find( (pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid ) if (existingDay) { await update(existingDay.id, { day_type: payload.dayType, is_dubbeldag: payload.isDubbeldag, }) } else { await add({ workspace_id: workspace.id, child_id: selectedChildId, parent_personal_data_uuid: personalDataUuid, date, day_type: payload.dayType, is_dubbeldag: payload.isDubbeldag, }) } } setIsModalOpen(false) setSelectedDates(new Set()) } catch (err) { setModalError(err instanceof Error ? err.message : t('calendar.saveError')) } finally { setIsSaving(false) } } const handleDeletePlannedDay = async () => { if (selectedDates.size !== 1) return try { setIsSaving(true) setModalError(null) const dates = Array.from(selectedDates) const date = dates[0] if (!date) return const existingDay = getExistingDay(date) if (existingDay) { await remove(existingDay.id) } setIsModalOpen(false) setSelectedDates(new Set()) } catch (err) { setModalError(err instanceof Error ? err.message : t('calendar.deleteError')) } finally { setIsSaving(false) } } const defaultLeaveType = getLastLeaveType() ?? 'vacation' const firstSelectedDate = Array.from(selectedDates).sort()[0] // Dates to highlight in the grid. While dragging, show a live preview of the // range under the cursor; otherwise show the committed selection. const highlightedDates = isDragging && dragAnchor && dragCurrent ? new Set(datesInRange(dragAnchor, dragCurrent)) : selectedDates return (

{isOverrideMode && (

{t('calendar.overrideModeHint')}

)} {overrideError && (

{overrideError}

)}
{showWeekNumbers &&
} {WEEKDAY_KEYS.map((key) => (
{t(key)}
))} {weeks.map((week, weekIdx) => { const firstDate = week[0] const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month return ( ) })}

{t('calendar.legendTitle')}

{t('calendar.legendVacation')}
{t('calendar.legendIncomeBased')}
{t('calendar.legendMinimumRate')}
{t('calendar.legendRedDay')}
{t('calendar.legendKlamdag')}
{t('calendar.legendOverride')}
2× {t('calendar.legendDoubleDay')}
{firstSelectedDate && ( { setIsModalOpen(false) setSelectedDates(new Set()) }} isSaving={isSaving} error={modalError} isRangeMode={isRangeMode} dayCount={selectedDates.size} defaultDayType={defaultLeaveType} /> )}
) } function formatDate(date: Date): string { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` }