Files
F-r-ldraledigplannerare/frontend/src/pages/Calendar.tsx
T
jacobh2 d14f86478a Lay the Almanacka design foundation: tokens, type, shell
New token system in global.css (near-white surfaces, one ink-blue
accent, no sub-12px type), self-hosted Schibsted Grotesk, rewritten
primitives (buttons, fields, modal shell, segmented, disclosure,
skeleton), a borderless 56px header with an SV | EN language toggle,
an Icon component replacing glyphs, page skeletons that keep the
layout during loading, and leave-type colours moved out of TS into
CSS variables. Feature blocks keep their structure for later phases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 18:25:04 +02:00

1295 lines
50 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 { useSearchParams } from 'react-router-dom'
import { useWorkspace } from '@/context'
import {
usePlannedDays,
useShowWeekNumbers,
getLastLeaveType,
setLastLeaveType,
useCalendarDays,
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
} from '@/hooks'
import type {
PlannedLeaveDay,
CalendarDayEntry,
LeaveWish,
LeaveWishCreateRequest,
LeaveWishScope,
LeaveWishUpdateRequest,
PersonalData,
PlanChange,
PlanSyncResponse,
WindowOutcome,
} from '@/types'
import { Layout } from '@/components/layout/Layout'
import { PageSkeleton } from '@/components/common/PageSkeleton'
import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
import { getColorForType, getTypeModifier } from '@/utils/leaveTypes'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } 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 }
}
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
/** Reads the `?date=` search param the dashboard's "years ahead" bands
* navigate here with (see Dashboard.tsx's `handleBandClick`) and, when it's
* a valid ISO-8601 date, returns both the month to land on (as a local Date,
* built from the parsed value's UTC components so the calendar view — which
* reads `currentMonth` via local getters throughout — never drifts a day in
* timezones behind UTC) and the ISO string itself, to pre-select the day.
* Returns `null` for a missing or invalid param, so callers fall back to
* today. */
function parseDateParam(searchParams: URLSearchParams): { month: Date; iso: string } | null {
const dateParam = searchParams.get('date')
if (!dateParam || !ISO_DATE_PATTERN.test(dateParam)) return null
const parsed = parseInputDate(dateParam)
if (!isValidDate(parsed)) return null
return {
month: new Date(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()),
iso: dateParam,
}
}
/** 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").
*
* A wish row is ONE year now (see `LeaveWish.seriesId`), so this recreates
* exactly that year as a standalone occurrence. `seriesId`, when passed,
* re-attaches it to the series it came from instead — undoing "remove this
* year" has to put the year back as part of the repeating wish, not as a
* detached one-off a later "every year" edit would miss. */
function createRequestFromWish(w: LeaveWish, seriesId?: string | null): 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: 'once',
firstYear: w.firstYear,
seriesId: seriesId ?? null,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
wantsDubbeldag: w.wantsDubbeldag,
}
}
/** The patch that restores one wish row's pre-edit fields, applied at the same
* scope the edit used.
*
* `recurrence` is restored from whether the row belonged to a series, so
* undoing "make this repeat" (or "stop repeating") really undoes it.
* `lastYear` is series-level and only sent where the backend accepts it: when
* the row was standalone (so the patch is what creates the series) or when the
* undo is series-wide. */
function patchFromWish(w: LeaveWish, scope: LeaveWishScope): LeaveWishUpdateRequest {
const seriesWide = w.seriesId === null || scope === 'series'
return {
...patchFieldsFromWish(w),
recurrence: w.seriesId !== null ? 'yearly' : 'once',
...(seriesWide ? { lastYear: w.lastYear } : {}),
}
}
function patchFieldsFromWish(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,
firstYear: w.firstYear,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
wantsDubbeldag: w.wantsDubbeldag,
}
}
/** 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 [searchParams] = useSearchParams()
// Seeded once from `?date=` on arrival (see `parseDateParam`) — later
// in-page navigation (prev/next month, the month/year selects) only ever
// calls `setCurrentMonth`/`setSelectedDates` directly, so it's never
// fought by the param once mounted.
const [currentMonth, setCurrentMonth] = useState(() => parseDateParam(searchParams)?.month ?? new Date())
const [selectedDates, setSelectedDates] = useState<Set<string>>(() => {
const fromParam = parseDateParam(searchParams)
return fromParam ? new Set([fromParam.iso]) : 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)
// §todo14 — which parent the day popover's "Advanced" (pin exactly) path
// targets. Lifted here (rather than kept as PlannedDayModal-local state)
// so `existingDay`/save/delete all resolve against the SAME parent the
// modal is currently showing — see `getExistingDay`'s doc comment for the
// bug this fixes. Reset to the logged-in parent whenever a fresh
// date/range is selected; updated live via the modal's `onParentChange`
// while it stays open.
const [advancedParentId, setAdvancedParentId] = useState(personalDataUuid ?? '')
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 wishesHook = useLeaveWishes(workspace?.id ?? null)
const membersHook = useWorkspaceMembers(workspace?.id ?? null)
const syncHook = usePlanSync()
// The days the user removed by hand. Read for the calendar's badges, and
// written by the popover's "this day" remove — see `excludeDay`.
const exclusionsHook = usePlannedDayExclusions(workspace?.id ?? null)
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])
// Any parent's removal marks the date: the mark says "someone deliberately
// took a day out here", which is what stops the hole reading as a bug. Whose
// day it was is shown in the day popover, where there is room to say it.
const excludedDates = useMemo(
() => new Set(exclusionsHook.data.map((e) => e.date)),
[exclusionsHook.data],
)
/** The display name for a band's owner. Used wherever the UI has to say
* WHOSE day something is — above all the "this day" remove, which takes one
* parent's day and leaves the partner's. Falls back the same way
* CalendarWeek's band labels do. */
const resolveParentName = (uuid: string): string =>
membersHook.data.find((m) => m.uuid === uuid)?.name ??
(uuid === personalDataUuid ? (parentName ?? t('calendar.you')) : t('calendar.partner'))
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])
useEffect(() => {
const handlePointerUp = () => {
if (isDragging && dragAnchor && dragCurrent) {
const dates = datesInRange(dragAnchor, dragCurrent)
if (dates.length > 0) {
// §Task7 revision — a drag (single- or multi-day) always opens the
// day popover on the exact dragged dates; it never creates a
// LeaveWish/synced period on its own by itself — the default path
// now creates a fixed one-off wish and lets the planner fund it
// (§todo10/11); the "Advanced" disclosure still writes exact
// pinned days, same as before.
setSelectedDates(new Set(dates))
setAdvancedParentId(personalDataUuid ?? '')
setIsModalOpen(true)
}
}
setIsDragging(false)
setDragAnchor(null)
setDragCurrent(null)
}
if (isDragging) {
window.addEventListener('pointerup', handlePointerUp)
return () => window.removeEventListener('pointerup', handlePointerUp)
}
}, [isDragging, dragAnchor, dragCurrent, personalDataUuid])
if (isLoading) {
return (
<Layout>
<PageSkeleton rows={5} />
</Layout>
)
}
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 = []
}
}
// Only pad and push a trailing week if the month's days actually left one
// in progress — a month that ends exactly on a Sunday has `currentWeek`
// already empty here, and pushing anyway would add a phantom row of 7
// disabled next-month cells.
if (currentWeek.length > 0) {
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)
}
// Every week row must be the same height regardless of how many bands
// overlap in it — see `CalendarWeek`'s `monthMaxLaneCount` doc comment.
// Computed once here (the month's tallest week) and passed down
// uniformly to every `CalendarWeek` below, instead of each week reserving
// only its own bottom padding.
const monthMaxLaneCount = weeks.reduce(
(max, week) => Math.max(max, computeWeekLaneCount(week, plannedDaysHook.data ?? [], calendarDaysByDate)),
0,
)
// These four all derive the *other* axis (year when the month changes, and
// vice versa) from live state rather than the `year`/`month` consts closed
// over by this render. Two selects changed in quick succession (as
// `goToCalendarMonth` in the e2e suite does: year, then month) can each
// fire their own native `change` event before React has committed the
// re-render from the first one — the month `<select>`'s onChange handler
// then still closes over the PRE-year-change `year` const, so
// `setCurrentMonth(new Date(year, newMonth, 1))` would silently overwrite
// the just-applied year with the stale one. The functional updater form
// reads the true latest (possibly not-yet-committed but already queued)
// state, which React always threads correctly regardless of how many
// renders separate the two events — see the flaky Easter-2027 red-day e2e
// failure this fixed (the year select's fetch would lose a race against a
// second, stale-closured fetch for the old year, so `calendarDaysByDate`
// never got 2027's data even though the grid itself displayed 2027).
const handlePrevMonth = () => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1))
}
const handleNextMonth = () => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1))
}
const handleMonthChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newMonth = Number(e.target.value)
setCurrentMonth((prev) => new Date(prev.getFullYear(), newMonth, 1))
}
const handleYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newYear = Number(e.target.value)
setCurrentMonth((prev) => new Date(newYear, prev.getMonth(), 1))
}
const handlePointerDown = (date: string) => {
setDragAnchor(date)
setDragCurrent(date)
setIsDragging(true)
}
const handlePointerEnter = (date: string) => {
if (isDragging) {
setDragCurrent(date)
}
}
const handleDayClick = (date: string) => {
setSelectedDates(new Set([date]))
setAdvancedParentId(personalDataUuid ?? '')
setIsModalOpen(true)
}
// §todo14 fix — takes the parent explicitly rather than assuming the
// logged-in one. The old version always resolved against
// `personalDataUuid`, so saving parent B's day on a date where parent A
// already had one found (and silently overwrote) A's row instead of
// creating B's. Every caller below now passes the parent the save/delete
// is actually FOR — the modal-selected one, never the logged-in default.
const getExistingDay = (date: string, parentId: string): PlannedLeaveDay | undefined => {
return (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === parentId,
)
}
// The "Advanced" escape hatch — pins an exact day type for an exact
// parent, writing `planned_leave_day` rows directly (today's pre-existing
// behaviour, unchanged except for the §todo14 lookup fix above).
const handleSavePinnedDay = async (payload: {
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
parentId: string
isDubbeldag: boolean
}) => {
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
try {
setIsSaving(true)
setModalError(null)
setLastLeaveType(payload.dayType)
for (const date of selectedDates) {
const existingDay = getExistingDay(date, payload.parentId)
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)
}
}
// §todo10/11 default path — the planner decides funding. Builds a fixed,
// one-off `LeaveWish` spanning every selected date and lets the existing
// sync machinery place it, exactly like `handleSelectPreset` below (same
// add-then-sync-then-undo shape).
const handleSaveWish = async (payload: {
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
wantsDubbeldag: boolean
}) => {
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
const orderedDates = Array.from(selectedDates).sort()
const first = orderedDates[0]
const last = orderedDates[orderedDates.length - 1]
if (!first || !last) return
const start = splitISODate(first)
const end = splitISODate(last)
const dayCount = orderedDates.length
try {
setIsSaving(true)
setModalError(null)
const created = await wishesHook.add({
workspaceId: workspace.id,
childId: selectedChildId,
name: t('wishEditor.manualPeriodName'),
nameMessageKey: 'wishEditor.manualPeriodName',
windowStartMonth: start.month,
windowStartDay: start.day,
windowEndMonth: end.month,
windowEndDay: end.day,
targetWeeks: Math.max(1, Math.ceil(dayCount / 7)),
targetDays: dayCount,
recurrence: 'once',
firstYear: start.year,
priority: wishesHook.data.length + 1,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
windowMode: 'fixed',
wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false,
})
setIsModalOpen(false)
setSelectedDates(new Set())
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) {
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, advancedParentId)
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)
// One wish row is one year, so the edited dates ARE this row's window —
// year included. The old shape had to keep the wish's anchor year and
// rewrite the shared month/day pattern for every year, because writing
// the clicked year into `firstYear` would have dropped the earlier
// occurrences; there is nothing to preserve now.
const patch: LeaveWishUpdateRequest = {
name: payload.name,
nameMessageKey: null,
windowStartMonth: start.month,
windowStartDay: start.day,
windowEndMonth: end.month,
windowEndDay: end.day,
firstYear: start.year,
recurrence: payload.recurrence,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
wantsDubbeldag: payload.wantsDubbeldag,
}
// `lastYear` belongs to the whole series, so only send it where the
// backend will take it: when this occurrence is becoming a series, or
// when the user explicitly chose "every year".
const isSeries = previousWish.seriesId !== null
if (payload.recurrence === 'yearly' && (!isSeries || payload.scope === 'series')) {
patch.lastYear = payload.lastYear
}
try {
setIsPeriodSaving(true)
setPeriodError(null)
await wishesHook.update(previousWish.id, patch, payload.scope)
closePeriodPopover()
await runSync(workspace.id, {
watchWishId: previousWish.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
// Restores this row's own fields at the same scope the edit used.
// A series-wide edit's undo re-projects the siblings from these
// values, which is as faithful as the shape allows: per-year
// divergence the user had before an "every year" save is gone
// once that save lands.
await wishesHook.update(
previousWish.id,
patchFromWish(previousWish, payload.scope),
payload.scope,
)
await runSync(workspace.id, { silent: true })
},
},
})
} catch (err) {
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
} finally {
setIsPeriodSaving(false)
}
}
// §todo9 "this period, every year" — removes every row of the series (or
// just the one row, when the period does not repeat). Undo recreates the
// repeating wish from the clicked occurrence's fields; the rows get fresh
// ids, exactly as before.
const deleteWishSeries = async (wishId: string, workspaceId: string) => {
const previousWish = wishesHook.data.find((w) => w.id === wishId)
const siblingYears = previousWish?.seriesId
? wishesHook.data.filter((w) => w.seriesId === previousWish.seriesId).map((w) => w.firstYear)
: []
await wishesHook.remove(wishId, 'series')
closePeriodPopover()
await runSync(workspaceId, {
undo: previousWish
? {
label: t('calendar.undoButton'),
run: async () => {
const restore = createRequestFromWish(previousWish)
if (siblingYears.length > 0) {
restore.recurrence = 'yearly'
restore.firstYear = Math.min(...siblingYears)
restore.lastYear = previousWish.lastYear ?? Math.max(...siblingYears)
}
await wishesHook.add(restore)
await runSync(workspaceId, { silent: true })
},
}
: null,
})
}
// §todo9 — the ad-hoc "this period" delete (unchanged behaviour, and also
// what ad-hoc's "this day" falls back to for a single-day band).
const deleteAdhocRange = async (start: string, end: string, parentUuid: string) => {
const bandDates = new Set(datesInRange(start, end))
const toDelete = (plannedDaysHook.data ?? []).filter(
(pd) => pd.parentPersonalDataUuid === parentUuid && 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,
})
}
},
})
}
}
// §todo9 "this period" (this year only). A year of a repeating wish is a
// real row now, so this is one plain delete — no narrowing `firstYear`/
// `lastYear`, and no splitting the wish in two around a middle year.
const deleteWishOccurrence = async (wish: LeaveWish, workspaceId: string) => {
await wishesHook.remove(wish.id, 'occurrence')
closePeriodPopover()
await runSync(workspaceId, {
undo: {
label: t('calendar.undoButton'),
run: async () => {
// `seriesId` puts the year back INTO its repeating wish rather than
// beside it, so a later "every year" edit still reaches it.
await wishesHook.add(createRequestFromWish(wish, wish.seriesId))
await runSync(workspaceId, { silent: true })
},
},
})
}
/** §"removing a single day should stay permanent" — the planner-placed
* counterpart of `deleteAdhocRange`. An auto-placed day cannot simply be
* deleted: the wish behind it still asks for the whole window, so the next
* sync would re-derive it. Recording a `PlannedDayExclusion` is what makes
* the removal outlive the sync.
*
* Keyed on the band's own parent, so a both-parents period loses only the
* band that was clicked and the partner keeps their day — the toast names
* the parent so that choice is never a silent one. Undo deletes the
* exclusion and re-syncs, which puts the day back where it was.
*/
const excludeDay = async (
workspaceId: string,
band: BandClickInfo,
parentLabel: string,
) => {
const childId =
(plannedDaysHook.data ?? []).find(
(pd) => pd.date === band.clickedDate && pd.parentPersonalDataUuid === band.parentPersonalDataUuid,
)?.childId ?? selectedChildId
if (!childId) {
closePeriodPopover()
return
}
const created = await exclusionsHook.exclude({
workspaceId,
childId,
parentPersonalDataUuid: band.parentPersonalDataUuid,
date: band.clickedDate,
})
closePeriodPopover()
await runSync(workspaceId, { silent: true })
const dateLabel = formatDateForDisplay(band.clickedDate, locale)
showToast(t('calendar.toastDayExcluded', { date: dateLabel, parent: parentLabel }), {
label: t('calendar.undoButton'),
run: async () => {
await exclusionsHook.restore(created.id)
await runSync(workspaceId, { silent: true })
showToast(t('calendar.toastDayRestored', { date: dateLabel, parent: parentLabel }), null)
},
})
}
/** The day cell's way back, for when the undo toast is long gone: the day
* popover lists this date's removals (one per parent) with a restore
* button. See `excludeDay` for the other direction. */
const handleRestoreExcluded = async (exclusionId: string) => {
if (!workspace?.id) return
const entry = exclusionsHook.data.find((e) => e.id === exclusionId)
await exclusionsHook.restore(exclusionId)
setIsModalOpen(false)
setSelectedDates(new Set())
await runSync(workspace.id, { silent: true })
if (entry) {
showToast(
t('calendar.toastDayRestored', {
date: formatDateForDisplay(entry.date, locale),
parent: resolveParentName(entry.parentPersonalDataUuid),
}),
null,
)
}
}
const handleDeletePeriod = async (scope: PeriodDeleteScope) => {
if (!workspace?.id || !activeBand) return
try {
setIsPeriodSaving(true)
setPeriodError(null)
if (scope === 'allYears') {
if (activeBand.wishId) await deleteWishSeries(activeBand.wishId, workspace.id)
else closePeriodPopover() // disabled in the UI for ad-hoc bands
return
}
if (scope === 'day') {
if (activeBand.wishId) {
// A planner-placed day: deleting the row would not survive the next
// sync, so record the removal instead.
await excludeDay(
workspace.id,
activeBand,
resolveParentName(activeBand.parentPersonalDataUuid),
)
return
}
await deleteAdhocRange(
activeBand.clickedDate,
activeBand.clickedDate,
activeBand.parentPersonalDataUuid,
)
return
}
// scope === 'year'
if (activeBand.wishId) {
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
if (!previousWish) {
closePeriodPopover()
return
}
await deleteWishOccurrence(previousWish, workspace.id)
} else {
await deleteAdhocRange(activeBand.startDate, activeBand.endDate, activeBand.parentPersonalDataUuid)
}
} 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: membersHook.data.length >= 2 ? 'both' : 'either',
windowMode: 'flexible',
})
const result = await runSync(workspace.id, {
watchWishId: created.id,
watchWish: created,
undo: {
label: t('calendar.undoButton'),
run: async () => {
// The preset is a repeating wish, so `created` is only its first
// year — undo has to take the whole series with it.
await wishesHook.remove(created.id, 'series')
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') }]
// Same members, keyed for CalendarWeek's band labels — a band owned by a
// partner must show their actual name, not the generic "Partner" string.
const parentNamesByUuid = new Map(parentsForModals.map((p) => [p.id, p.name]))
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
type="button"
className="btn btn--icon"
onClick={handlePrevMonth}
aria-label={t('calendar.prevMonthAria')}
>
←
</button>
<h2 className="calendar__title">
<button
type="button"
className="btn btn--ghost calendar__today-button"
onClick={() => setCurrentMonth(new Date())}
>
{t('calendar.today')}
</button>
<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
type="button"
className="btn btn--icon"
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) => (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDaysHook.data ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
parentNames={parentNamesByUuid}
displayedYear={year}
displayedMonth={month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
excludedDates={excludedDates}
onBandClick={handleBandClick}
flashWishId={flashWishId}
monthMaxLaneCount={monthMaxLaneCount}
/>
))}
</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 calendar__legend-swatch--${getTypeModifier('lagsta')}`} 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, advancedParentId) ?? null}
parents={parentsForModals}
selectedParentId={advancedParentId}
onParentChange={setAdvancedParentId}
onSaveWish={handleSaveWish}
onSavePinned={handleSavePinnedDay}
onDelete={getExistingDay(firstSelectedDate, advancedParentId) ? handleDeletePlannedDay : undefined}
excludedEntries={exclusionsHook.data
.filter((e) => e.date === firstSelectedDate)
.map((e) => ({ id: e.id, parentName: resolveParentName(e.parentPersonalDataUuid) }))}
onRestoreExcluded={handleRestoreExcluded}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())
}}
isSaving={isSaving}
error={modalError}
defaultDayType={defaultLeaveType}
isRangeMode={selectedDates.size > 1}
dayCount={selectedDates.size}
/>
)}
{activeBand && (
<PeriodPopover
isOpen={isPeriodOpen}
wish={activeWish}
startDate={activeBand.startDate}
endDate={activeBand.endDate}
removeDayLabel={formatDateForDisplay(activeBand.clickedDate, locale)}
removeDayParentName={resolveParentName(activeBand.parentPersonalDataUuid)}
parents={parentsForModals}
isSaving={isPeriodSaving}
error={periodError}
onSave={handleSavePeriod}
onDelete={handleDeletePeriod}
onClose={closePeriodPopover}
/>
)}
</div>
</Layout>
)
}