Files
F-r-ldraledigplannerare/frontend/src/pages/Calendar.tsx
T
jacobh2 31a60a296f Let the calendar jump to any month or year, in both directions
Clicking the month or year label only ever stepped forward
(`month + 1`, `year + 1`), so the only way back was the prev-month arrow,
one month at a time. Jumping back a year was impossible.

Both labels are now selects, so any month or year is one click away in
either direction. Selects rather than +/- steppers because the app
already uses them elsewhere, and because stepping in a second direction
would still not give the "quickly jump" part of the original intent.

Year options are bounded to 2026-2055, the calendar cache range: outside
it the backend raises UncachedYearError and returns 422, so offering
those years would hand the user an error instead of a calendar.

The prev/next month arrows are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-15 03:31:33 +02:00

455 lines
16 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, 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<Set<string>>(new Set())
const [dragAnchor, setDragAnchor] = useState<string | null>(null)
const [dragCurrent, setDragCurrent] = useState<string | null>(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<string | null>(null)
const [isOverrideMode, setIsOverrideMode] = useState(false)
const [overrideError, setOverrideError] = useState<string | null>(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<string, CalendarDayEntry>()
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 <LoadingSpinner message={t('calendar.loading')} />
}
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<HTMLSelectElement>) => {
const newMonth = Number(e.target.value)
setCurrentMonth(new Date(year, newMonth, 1))
}
const handleYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
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 (
<Layout>
<div data-testid="calendar" className="calendar">
<div className="calendar__header">
<button onClick={handlePrevMonth} aria-label={t('calendar.prevMonthAria')}>
←
</button>
<h2 className="calendar__title">
<select
value={month}
onChange={handleMonthChange}
className="calendar__month-select"
aria-label={t('calendar.selectMonthAria')}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => {
const date = new Date(year, m, 1)
const monthName = date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' })
return (
<option key={m} value={m}>
{monthName}
</option>
)
})}
</select>
<select
value={year}
onChange={handleYearChange}
className="calendar__year-select"
aria-label={t('calendar.selectYearAria')}
>
{Array.from({ length: 2055 - 2026 + 1 }, (_, i) => 2026 + i).map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</h2>
<button onClick={handleNextMonth} aria-label={t('calendar.nextMonthAria')}>
→
</button>
</div>
<div className="calendar__toolbar">
<button
type="button"
onClick={() => setIsOverrideMode((prev) => !prev)}
aria-pressed={isOverrideMode}
className={`calendar__override-toggle${isOverrideMode ? ' calendar__override-toggle--active' : ''}`}
data-testid="calendar-override-toggle"
>
{isOverrideMode ? t('calendar.overrideModeActiveLabel') : t('calendar.overrideModeLabel')}
</button>
{isOverrideMode && (
<p className="calendar__toolbar-hint" data-testid="calendar-override-hint">
{t('calendar.overrideModeHint')}
</p>
)}
{overrideError && (
<p className="calendar__toolbar-error" role="alert" data-testid="calendar-override-error">
{overrideError}
</p>
)}
</div>
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
{showWeekNumbers && <div className="calendar__week-number-header" />}
{WEEKDAY_KEYS.map((key) => (
<div key={key} className="calendar__weekday">
{t(key)}
</div>
))}
{weeks.map((week, weekIdx) => {
const firstDate = week[0]
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
return (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDays ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
isCurrentMonth={weekMonth === month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
/>
)
})}
</div>
<div className="calendar__legend">
<h3>{t('calendar.legendTitle')}</h3>
<div className="calendar__legend-items">
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('vacation') }} />
<span>{t('calendar.legendVacation')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
<span>{t('calendar.legendIncomeBased')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
<span>{t('calendar.legendMinimumRate')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--red-day" />
<span>{t('calendar.legendRedDay')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--klamdag" />
<span>{t('calendar.legendKlamdag')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--override" />
<span>{t('calendar.legendOverride')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--dubbeldag">2×</span>
<span>{t('calendar.legendDoubleDay')}</span>
</div>
</div>
</div>
{firstSelectedDate && (
<PlannedDayModal
date={firstSelectedDate}
isOpen={isModalOpen}
existingDay={isRangeMode ? null : getExistingDay(firstSelectedDate) ?? null}
parents={[{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]}
selectedParentId={personalDataUuid ?? ''}
onSave={handleSavePlannedDay}
onDelete={!isRangeMode && getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())
}}
isSaving={isSaving}
error={modalError}
isRangeMode={isRangeMode}
dayCount={selectedDates.size}
defaultDayType={defaultLeaveType}
/>
)}
</div>
</Layout>
)
}
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}`
}