Files
F-r-ldraledigplannerare/frontend/src/pages/Calendar.tsx
T
jacobh2 f2b3920cbc Share one date formatter, and stop describing files that are gone
Two components each carried the same four lines turning a date into an ISO
string from local parts. They now share one helper, deliberately separate
from the one that formats in UTC: substituting one for the other shifts the
day by one anywhere east of Greenwich, Sweden included, so a test pins the
difference rather than a comment asking nicely.

Creating a period ran its own copy of the sync-and-report sequence purely so
it could name a wish the lookup could not yet see; it now passes that wish to
the ordinary path instead.

Two comments still explained their relationship to components deleted some
time ago, which would send the next reader looking for files that are not
there, and some styling outlived the markup it described.
2026-09-18 01:12:58 +02:00

1016 lines
37 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, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useWorkspace } from '@/context'
import {
usePlannedDays,
useShowWeekNumbers,
getLastLeaveType,
setLastLeaveType,
useCalendarDays,
useDayOverrides,
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
} from '@/hooks'
import type {
PlannedLeaveDay,
CalendarDayEntry,
LeaveWish,
LeaveWishCreateRequest,
LeaveWishUpdateRequest,
PersonalData,
PlanChange,
PlanSyncResponse,
WindowOutcome,
} from '@/types'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { CalendarWeek, type BandClickInfo } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
import { getColorForType } from '@/utils/leaveTypes'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay, formatDateLocalISO } from '@/utils/dates'
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
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
/** Small "what changed" toast + optional undo — see Calendar.tsx's usage. */
interface CalendarToast {
message: string
undo: { label: string; run: () => Promise<void> } | null
}
function splitISODate(date: string): { year: number; month: number; day: number } {
const [year, month, day] = date.split('-').map(Number)
return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 }
}
/** Builds the full `LeaveWishCreateRequest` a wish's current fields would
* recreate — used both to "undo" a period delete (recreate the wish) and,
* minus `workspaceId`/`childId`, as the patch that "undoes" a period edit
* (restore every field to its pre-edit value; §Task7 "undo reverts the
* wish edit, not the days"). */
function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
return {
workspaceId: w.workspaceId,
childId: w.childId,
name: w.name,
nameMessageKey: w.nameMessageKey,
windowStartMonth: w.windowStartMonth,
windowStartDay: w.windowStartDay,
windowEndMonth: w.windowEndMonth,
windowEndDay: w.windowEndDay,
targetWeeks: w.targetWeeks,
targetDays: w.targetDays ?? null,
recurrence: w.recurrence,
firstYear: w.firstYear,
lastYear: w.lastYear,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
}
}
function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
return {
name: w.name,
nameMessageKey: w.nameMessageKey,
windowStartMonth: w.windowStartMonth,
windowStartDay: w.windowStartDay,
windowEndMonth: w.windowEndMonth,
windowEndDay: w.windowEndDay,
targetWeeks: w.targetWeeks,
targetDays: w.targetDays ?? null,
recurrence: w.recurrence,
firstYear: w.firstYear,
lastYear: w.lastYear,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
}
}
/** Groups a sync's `changes` by the wish (or "adhoc") they belong to and
* renders one "{name}: N dagar ändrades" fragment per group — house style
* is structured data in, composed copy out; the backend never sends prose. */
function summarizeChanges(changes: PlanChange[], wishes: LeaveWish[], t: Translate): string | null {
if (changes.length === 0) return null
const counts = new Map<string, number>()
for (const change of changes) {
const key = change.newWishId ?? change.oldWishId ?? 'adhoc'
counts.set(key, (counts.get(key) ?? 0) + 1)
}
const parts: string[] = []
for (const [wishId, count] of counts) {
const wish = wishes.find((w) => w.id === wishId)
const name = wish ? resolveWishName(wish, t) : t('calendar.toastGenericPeriod')
parts.push(t('calendar.toastChangedDays', { name, count }))
}
return parts.join(' · ')
}
/** Renders a `WindowOutcome`'s shortfall for the touched wish, from
* `outcome.messageKey`/`data` (today always `planOutcomes.windowExplanation`,
* carrying `wish_name`/`wish_name_message_key`) — falling back to the plain
* `shortfallDays` count when there's nothing richer to say. `funded`
* distinguishes "nothing could be placed" from "only part of it fit", the
* two phrasings the "sync that changes nothing must say why" fix needs.
*
* `wish` is the touched wish itself (not just the outcome), because the
* outcome only ever carries `weeksWished`/`weeksFunded` — the backend's
* week-rounded view. A calendar-drawn period sets `targetDays` (an exact
* day count); `targetWeeks` on that same wish is just `Math.ceil(days / 7)`,
* a leftover the API still requires, never what the user actually asked
* for. Reporting `weeksWished` for those wishes says "3 week(s)" for a
* 7-day drag. When `targetDays` is set, report days instead — the figure
* the user actually asked for — and only fall back to weeks for wishes that
* never had a day-granular target (e.g. the preset rail, which only ever
* sends `targetWeeks`). */
function summarizeOutcomeShortfall(
outcome: WindowOutcome,
funded: boolean,
wish: LeaveWish | undefined,
t: Translate,
): string {
const name = resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod')
const base = {
name,
start: formatDateForDisplay(outcome.startDate),
end: formatDateForDisplay(outcome.endDate),
shortfallDays: outcome.shortfallDays,
}
if (wish?.targetDays != null) {
const params = {
...base,
daysWished: wish.targetDays,
daysFunded: wish.targetDays - outcome.shortfallDays,
}
return t(funded ? 'calendar.toastShortfallPartialDays' : 'calendar.toastShortfallNoneDays', params)
}
const params = {
...base,
weeksWished: outcome.weeksWished,
weeksFunded: outcome.weeksFunded,
}
return t(funded ? 'calendar.toastShortfallPartial' : 'calendar.toastShortfallNone', params)
}
/** Builds the full sync toast message: what changed (existing summary), why
* the wish just touched (if any) fell short of what was wished, and any
* rejections the apply step returned — a genuine no-op sync (nothing to
* report on any of the three) still renders `null`, so it stays silent.
*
* `watchWish`, when given, is used in place of looking `watchWishId` up in
* `wishes` — needed right after creating a wish, where `wishes` is whatever
* `wishesHook.data` was as of this closure's own render (captured before
* the create's background refetch lands) and so never actually contains a
* wish created moments ago. Passing the just-created wish object directly
* sidesteps that; every other caller (editing/re-syncing an *existing*
* wish) can leave it out and rely on the `wishes` lookup as before. */
function buildSyncToastMessage(
result: PlanSyncResponse,
wishes: LeaveWish[],
members: PersonalData[],
watchWishId: string | undefined,
t: Translate,
watchWish?: LeaveWish,
): string | null {
const parts: string[] = []
const changesMessage = summarizeChanges(result.changes, wishes, t)
if (changesMessage) parts.push(changesMessage)
if (watchWishId) {
const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId)
if (outcome && outcome.shortfallDays > 0) {
const funded = result.changes.some((c) => c.newWishId === watchWishId || c.oldWishId === watchWishId)
const wish = watchWish ?? wishes.find((w) => w.id === watchWishId)
parts.push(summarizeOutcomeShortfall(outcome, funded, wish, t))
}
}
for (const rejection of result.applyResult.rejections) {
parts.push(renderRejection(rejection, t, members))
}
return parts.length > 0 ? parts.join(' · ') : null
}
export function Calendar(): ReactNode {
const { t, locale } = useTranslation()
const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace()
const plannedDaysHook = 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 [isSaving, setIsSaving] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [overrideError, setOverrideError] = useState<string | null>(null)
const [activeBand, setActiveBand] = useState<BandClickInfo | null>(null)
const [isPeriodOpen, setIsPeriodOpen] = useState(false)
const [isPeriodSaving, setIsPeriodSaving] = useState(false)
const [periodError, setPeriodError] = useState<string | null>(null)
const [isCreatingPeriod, setIsCreatingPeriod] = useState(false)
const [flashWishId, setFlashWishId] = useState<string | null>(null)
const [toast, setToast] = useState<CalendarToast | null>(null)
const [isUndoing, setIsUndoing] = useState(false)
const isLoading = wsLoading || plannedDaysHook.isLoading
// §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 calendarHook = useCalendarDays(workspace?.id ?? '', calendarMinYear, calendarYearsSpan)
const dayOverridesHook = useDayOverrides(workspace?.id ?? '')
const wishesHook = useLeaveWishes(workspace?.id ?? null)
const membersHook = useWorkspaceMembers(workspace?.id ?? null)
const syncHook = usePlanSync()
const calendarDaysByDate = useMemo(() => {
const map = new Map<string, CalendarDayEntry>()
for (const day of calendarHook.data?.days ?? []) {
map.set(day.date, day)
}
return map
}, [calendarHook.data])
const showToast = useCallback((message: string, undo: CalendarToast['undo']) => {
setToast({ message, undo })
}, [])
// Auto-dismiss the toast after a while so it never lingers indefinitely.
useEffect(() => {
if (!toast) return
const timer = setTimeout(() => setToast(null), 8000)
return () => clearTimeout(timer)
}, [toast])
// Clear a flashed band's highlight shortly after it's applied.
useEffect(() => {
if (!flashWishId) return
const timer = setTimeout(() => setFlashWishId(null), 1600)
return () => clearTimeout(timer)
}, [flashWishId])
// §Task7 — the calendar IS the plan: recompute + persist from the
// workspace's LeaveWishes in one call, then refresh the two views that
// depend on the result (planned days for the bands, calendar metadata for
// shading). `silent` suppresses the "what changed" toast (used for the
// initial load and for undo's re-sync) without suppressing error
// reporting — a broken sync should always be visible.
const runSync = useCallback(
async (
workspaceId: string,
options?: {
silent?: boolean
undo?: CalendarToast['undo']
watchWishId?: string
// Used right after creating a wish, where `wishesHook.data` is
// whatever it was as of this closure's own render (captured before
// the create's background refetch lands) and so never actually
// contains a wish created moments ago. Passing the just-created
// wish object directly sidesteps that — see `buildSyncToastMessage`.
watchWish?: LeaveWish
},
): Promise<PlanSyncResponse | undefined> => {
try {
const result = await syncHook.sync(workspaceId)
await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch()])
if (!options?.silent) {
const message = buildSyncToastMessage(
result,
wishesHook.data,
membersHook.data,
options?.watchWishId,
t,
options?.watchWish,
)
if (message) showToast(message, options?.undo ?? null)
}
return result
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.syncError'), null)
return undefined
}
},
[syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast],
)
// Sync once per workspace as soon as it's known, so wishes seeded/created
// elsewhere show up as bands without requiring a manual edit first.
const didAutoSyncRef = useRef<string | null>(null)
useEffect(() => {
if (!workspace?.id) return
if (didAutoSyncRef.current === workspace.id) return
didAutoSyncRef.current = workspace.id
void runSync(workspace.id, { silent: true })
}, [workspace?.id, runSync])
const handleCreatePeriodFromDrag = useCallback(
async (dates: string[]) => {
setSelectedDates(new Set())
if (!workspace?.id || !selectedChildId || !personalDataUuid) return
const sorted = [...dates].sort()
const start = sorted[0]
const end = sorted[sorted.length - 1]
if (!start || !end) return
const { year: startYear, month: startMonth, day: startDay } = splitISODate(start)
const { month: endMonth, day: endDay } = splitISODate(end)
const dayCount = sorted.length
const targetWeeks = Math.min(52, Math.max(1, Math.ceil(dayCount / 7)))
try {
setIsCreatingPeriod(true)
const created = await wishesHook.add({
workspaceId: workspace.id,
childId: selectedChildId,
name: t('calendar.newPeriodName'),
nameMessageKey: 'calendar.newPeriodName',
windowStartMonth: startMonth,
windowStartDay: startDay,
windowEndMonth: endMonth,
windowEndDay: endDay,
targetWeeks,
targetDays: dayCount,
recurrence: 'once',
firstYear: startYear,
priority: wishesHook.data.length + 1,
parentMode: 'specific',
parentPersonalDataUuid: personalDataUuid,
windowMode: 'fixed',
})
// Undo is just "delete the wish, re-sync": the backend deletes every
// `planned_leave_day` row the wish produced as part of deleting the
// wish (`LeaveWishService.delete()`), so there is nothing left here
// for the frontend to individually track or clean up.
await runSync(workspace.id, {
watchWishId: created.id,
watchWish: created,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.remove(created.id)
await runSync(workspace.id, { silent: true })
},
},
})
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null)
} finally {
setIsCreatingPeriod(false)
}
},
[
workspace?.id,
selectedChildId,
personalDataUuid,
wishesHook,
t,
runSync,
showToast,
],
)
useEffect(() => {
const handlePointerUp = () => {
if (isDragging && dragAnchor && dragCurrent) {
const dates = datesInRange(dragAnchor, dragCurrent)
if (dates.length === 1) {
setSelectedDates(new Set(dates))
setIsModalOpen(true)
} else if (dates.length > 1) {
void handleCreatePeriodFromDrag(dates)
}
}
setIsDragging(false)
setDragAnchor(null)
setDragCurrent(null)
}
if (isDragging) {
window.addEventListener('pointerup', handlePointerUp)
return () => window.removeEventListener('pointerup', handlePointerUp)
}
}, [isDragging, dragAnchor, dragCurrent, handleCreatePeriodFromDrag])
if (isLoading) {
return <LoadingSpinner message={t('calendar.loading')} />
}
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
// The year <select> must cover the API's real supported range (from
// `CalendarWindow.minYear`/`maxYear`, not a hardcoded literal), while
// always including the currently-selected year even before the window has
// loaded (or if it somehow falls outside the reported bounds).
const yearRangeMin = Math.min(calendarHook.data?.minYear ?? year, year)
const yearRangeMax = Math.max(calendarHook.data?.maxYear ?? year, year)
const yearOptions = Array.from(
{ length: yearRangeMax - yearRangeMin + 1 },
(_, i) => yearRangeMin + i,
)
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(formatDateLocalISO(prevDate))
}
for (let day = 1; day <= daysInMonth; day++) {
currentWeek.push(formatDateLocalISO(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(formatDateLocalISO(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))
}
const handlePointerDown = (date: string) => {
setDragAnchor(date)
setDragCurrent(date)
setIsDragging(true)
}
const handlePointerEnter = (date: string) => {
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 = (dayOverridesHook.data ?? []).find((o) => o.date === date)
if (existing) {
await dayOverridesHook.remove(existing.id)
}
} else {
// Toggle the effective status: a currently-free day becomes an
// override marking it a working day, and vice versa.
await dayOverridesHook.upsert({
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 calendarHook.refetch()
} catch (err) {
setOverrideError(err instanceof Error ? err.message : t('calendar.overrideError'))
}
}
const handleDayClick = (date: string) => {
setSelectedDates(new Set([date]))
setIsModalOpen(true)
}
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
return (plannedDaysHook.data ?? []).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 = (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
)
if (existingDay) {
// §Task7 — a hand-edit through the day popover pins the day so a
// later `POST /api/plan/sync` never silently retypes it.
await plannedDaysHook.update(existingDay.id, {
day_type: payload.dayType,
is_dubbeldag: payload.isDubbeldag,
is_pinned: true,
})
} else {
await plannedDaysHook.add({
workspace_id: workspace.id,
child_id: selectedChildId,
parent_personal_data_uuid: payload.parentId,
date,
day_type: payload.dayType,
is_dubbeldag: payload.isDubbeldag,
is_pinned: true,
})
}
}
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 plannedDaysHook.remove(existingDay.id)
}
setIsModalOpen(false)
setSelectedDates(new Set())
} catch (err) {
setModalError(err instanceof Error ? err.message : t('calendar.deleteError'))
} finally {
setIsSaving(false)
}
}
const handleBandClick = (info: BandClickInfo): void => {
setActiveBand(info)
setIsPeriodOpen(true)
setPeriodError(null)
}
const closePeriodPopover = () => {
setIsPeriodOpen(false)
setActiveBand(null)
setPeriodError(null)
}
const handleSavePeriod = async (payload: PeriodPopoverSavePayload) => {
if (!activeBand?.wishId || !workspace?.id) return
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
if (!previousWish) return
const start = splitISODate(payload.startDate)
const end = splitISODate(payload.endDate)
try {
setIsPeriodSaving(true)
setPeriodError(null)
await wishesHook.update(previousWish.id, {
name: payload.name,
nameMessageKey: null,
windowStartMonth: start.month,
windowStartDay: start.day,
windowEndMonth: end.month,
windowEndDay: end.day,
firstYear: start.year,
recurrence: payload.recurrence,
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
})
closePeriodPopover()
await runSync(workspace.id, {
watchWishId: previousWish.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.update(previousWish.id, patchFromWish(previousWish))
await runSync(workspace.id, { silent: true })
},
},
})
} catch (err) {
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
} finally {
setIsPeriodSaving(false)
}
}
const handleDeletePeriod = async () => {
if (!workspace?.id || !activeBand) return
try {
setIsPeriodSaving(true)
setPeriodError(null)
if (activeBand.wishId) {
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
await wishesHook.remove(activeBand.wishId)
closePeriodPopover()
await runSync(workspace.id, {
undo: previousWish
? {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.add(createRequestFromWish(previousWish))
await runSync(workspace.id, { silent: true })
},
}
: null,
})
} else {
// Ad-hoc band (no wishId): delete the underlying planned days
// directly — there is no wish to resync from.
const bandDates = new Set(datesInRange(activeBand.startDate, activeBand.endDate))
const toDelete = (plannedDaysHook.data ?? []).filter(
(pd) =>
pd.parentPersonalDataUuid === activeBand.parentPersonalDataUuid &&
pd.wishId === null &&
bandDates.has(pd.date),
)
const snapshot = toDelete.map((pd) => ({ ...pd }))
for (const pd of toDelete) {
await plannedDaysHook.remove(pd.id)
}
closePeriodPopover()
if (snapshot.length > 0) {
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
label: t('calendar.undoButton'),
run: async () => {
for (const pd of snapshot) {
await plannedDaysHook.add({
workspace_id: pd.workspaceId,
child_id: pd.childId,
parent_personal_data_uuid: pd.parentPersonalDataUuid,
date: pd.date,
day_type: pd.dayType,
is_dubbeldag: pd.isDubbeldag,
is_pinned: pd.isPinned,
})
}
},
})
}
}
} catch (err) {
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
} finally {
setIsPeriodSaving(false)
}
}
const handleSelectPreset = async (preset: SeasonPreset) => {
if (!workspace?.id || !selectedChildId) return
try {
setIsCreatingPeriod(true)
const created = await wishesHook.add({
workspaceId: workspace.id,
childId: selectedChildId,
name: t(preset.labelKey),
nameMessageKey: preset.labelKey,
windowStartMonth: preset.startMonth,
windowStartDay: preset.startDay,
windowEndMonth: preset.endMonth,
windowEndDay: preset.endDay,
targetWeeks: preset.defaultWeeks,
recurrence: 'yearly',
firstYear: new Date().getFullYear(),
priority: wishesHook.data.length + 1,
parentMode: 'either',
windowMode: 'flexible',
})
const result = await runSync(workspace.id, {
watchWishId: created.id,
watchWish: created,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.remove(created.id)
await runSync(workspace.id, { silent: true })
},
},
})
if (result) {
const placedDates = result.changes
.filter((c) => c.newWishId === created.id)
.map((c) => c.date)
.sort()
const earliest = placedDates[0]
if (earliest) {
const { year: placedYear, month: placedMonth } = splitISODate(earliest)
setCurrentMonth(new Date(placedYear, placedMonth - 1, 1))
setFlashWishId(created.id)
const calendarEl = document.querySelector('[data-testid="calendar"]')
if (calendarEl && typeof calendarEl.scrollIntoView === 'function') {
calendarEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
}
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null)
} finally {
setIsCreatingPeriod(false)
}
}
const handleUndoClick = async () => {
const undo = toast?.undo
if (!undo) return
try {
setIsUndoing(true)
await undo.run()
setToast(null)
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.undoError'), null)
} finally {
setIsUndoing(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
// §Task7 — feed every workspace member, not just the logged-in parent, so
// couple mode can plan for either parent from the calendar's popovers.
// Falls back to the logged-in parent alone while members haven't loaded
// yet (or in solo mode, where there is only one).
const parentsForModals =
membersHook.data.length > 0
? membersHook.data.map((m) => ({ id: m.uuid, name: m.name }))
: [{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]
const activeWish = activeBand?.wishId
? wishesHook.data.find((w) => w.id === activeBand.wishId) ?? null
: null
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')}
>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</h2>
<button onClick={handleNextMonth} aria-label={t('calendar.nextMonthAria')}>
→
</button>
</div>
{(syncHook.isSyncing || isCreatingPeriod) && (
<p className="calendar__sync-indicator" data-testid="calendar-sync-indicator">
{t('calendar.syncingLabel')}
</p>
)}
{toast && (
<div className="calendar__toast" role="status" data-testid="calendar-toast">
<span className="calendar__toast-message">{toast.message}</span>
{toast.undo && (
<button
type="button"
className="calendar__toast-undo"
onClick={() => void handleUndoClick()}
disabled={isUndoing}
data-testid="calendar-toast-undo"
>
{toast.undo.label}
</button>
)}
<button
type="button"
className="calendar__toast-dismiss"
aria-label={t('calendar.dismissToastAria')}
onClick={() => setToast(null)}
data-testid="calendar-toast-dismiss"
>
×
</button>
</div>
)}
<div className="calendar__layout">
<div className="calendar__main">
<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>
))}
</div>
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
calendar.css) so a multi-day band can span several day columns as
one element — that only works if each week is a separate grid,
not a flat row of the header grid above. */}
<div className="calendar__weeks">
{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={plannedDaysHook.data ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
isCurrentMonth={weekMonth === month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
flashWishId={flashWishId}
/>
)
})}
</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>
</div>
<PresetRail isBusy={isCreatingPeriod || syncHook.isSyncing} onSelectPreset={handleSelectPreset} />
</div>
{firstSelectedDate && (
<PlannedDayModal
date={firstSelectedDate}
isOpen={isModalOpen}
existingDay={getExistingDay(firstSelectedDate) ?? null}
parents={parentsForModals}
selectedParentId={personalDataUuid ?? ''}
onSave={handleSavePlannedDay}
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())
}}
isSaving={isSaving}
error={modalError}
defaultDayType={defaultLeaveType}
dayInfo={calendarDaysByDate.get(firstSelectedDate) ?? null}
onToggleOverride={() => handleOverrideToggle(firstSelectedDate)}
overrideError={overrideError}
/>
)}
{activeBand && (
<PeriodPopover
isOpen={isPeriodOpen}
wish={activeWish}
startDate={activeBand.startDate}
endDate={activeBand.endDate}
parents={parentsForModals}
isSaving={isPeriodSaving}
error={periodError}
onSave={handleSavePeriod}
onDelete={handleDeletePeriod}
onClose={closePeriodPopover}
/>
)}
</div>
</Layout>
)
}