Author leave on the calendar itself

Planning used to mean filling in a wish list on one tab, pressing a button to
see a suggestion, then pressing another to accept it — with nothing on screen
telling you whether a plan was active. Dragging a range on the calendar now
creates the period directly, clicking a band opens it for editing, and each
change is recomputed and saved in one step. A period repeats never or yearly,
matching what the planner can actually express.

Clicking a day edits that day: its funding type, which pins it so the planner
leaves it alone, and whether the day counts as worked or off. That switch used
to be a mode covering the whole grid, which is what made the calendar confusing.

The season presets move to a rail beside the grid. Picking one lets the planner
choose the placement, then jumps there and flashes it, rather than asking the
user to guess dates the engine already knows.

A sync that funds nothing now says so, naming the period and how many days it
fell short, instead of leaving the screen unchanged and looking broken.
This commit is contained in:
2026-09-17 21:31:54 +02:00
parent 5e96c352fb
commit c2e64b504d
19 changed files with 3440 additions and 745 deletions
+626 -145
View File
@@ -1,4 +1,4 @@
import { ReactNode, useMemo, useState, useEffect } from 'react'
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useWorkspace } from '@/context'
import {
usePlannedDays,
@@ -7,14 +7,31 @@ import {
setLastLeaveType,
useCalendarDays,
useDayOverrides,
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
} from '@/hooks'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
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 { datesInRange } from '@/utils/dates'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay } from '@/utils/dates'
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
import { useTranslation } from '@/i18n'
import './calendar.css'
@@ -28,10 +45,137 @@ const WEEKDAY_KEYS = [
'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. */
function summarizeOutcomeShortfall(outcome: WindowOutcome, funded: boolean, t: Translate): string {
const name = resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod')
const params = {
name,
start: formatDateForDisplay(outcome.startDate),
end: formatDateForDisplay(outcome.endDate),
weeksWished: outcome.weeksWished,
weeksFunded: outcome.weeksFunded,
shortfallDays: outcome.shortfallDays,
}
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. */
function buildSyncToastMessage(
result: PlanSyncResponse,
wishes: LeaveWish[],
members: PersonalData[],
watchWishId: string | undefined,
t: Translate,
): 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)
parts.push(summarizeOutcomeShortfall(outcome, funded, 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 { data: plannedDays, isLoading: pdLoading, add, update, remove } = usePlannedDays(selectedChildId ?? undefined)
const plannedDaysHook = usePlannedDays(selectedChildId ?? undefined)
const [showWeekNumbers] = useShowWeekNumbers()
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDates, setSelectedDates] = useState<Set<string>>(new Set())
@@ -39,13 +183,20 @@ export function Calendar(): ReactNode {
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
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
@@ -56,44 +207,155 @@ export function Calendar(): ReactNode {
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 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 calendarWindow?.days ?? []) {
for (const day of calendarHook.data?.days ?? []) {
map.set(day.date, day)
}
return map
}, [calendarWindow])
}, [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 },
): 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,
)
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',
})
await runSync(workspace.id, {
watchWishId: created.id,
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)
setSelectedDates(new Set(dates))
if (dates.length === 1) {
setIsRangeMode(false)
} else {
setIsRangeMode(true)
setSelectedDates(new Set(dates))
setIsModalOpen(true)
} else if (dates.length > 1) {
void handleCreatePeriodFromDrag(dates)
}
setIsModalOpen(true)
}
setIsDragging(false)
setDragAnchor(null)
setDragCurrent(null)
}
if (isDragging) {
window.addEventListener('pointerup', handlePointerUp)
return () => window.removeEventListener('pointerup', handlePointerUp)
}
}, [isDragging, dragAnchor, dragCurrent])
}, [isDragging, dragAnchor, dragCurrent, handleCreatePeriodFromDrag])
if (isLoading) {
return <LoadingSpinner message={t('calendar.loading')} />
@@ -106,8 +368,8 @@ export function Calendar(): ReactNode {
// `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(calendarWindow?.minYear ?? year, year)
const yearRangeMax = Math.max(calendarWindow?.maxYear ?? year, year)
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,
@@ -158,21 +420,13 @@ export function Calendar(): ReactNode {
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)
}
@@ -186,14 +440,14 @@ export function Calendar(): ReactNode {
try {
setOverrideError(null)
if (info.isOverride) {
const existing = (dayOverrides ?? []).find((o) => o.date === date)
const existing = (dayOverridesHook.data ?? []).find((o) => o.date === date)
if (existing) {
await removeDayOverride(existing.id)
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 upsertDayOverride({
await dayOverridesHook.upsert({
workspaceId: workspace.id,
date,
isWorkingDay: info.isFree,
@@ -205,24 +459,21 @@ export function Calendar(): ReactNode {
// 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()
await calendarHook.refetch()
} 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)
return (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
)
}
const handleSavePlannedDay = async (payload: {
@@ -239,23 +490,27 @@ export function Calendar(): ReactNode {
setLastLeaveType(payload.dayType)
for (const date of selectedDates) {
const existingDay = (plannedDays ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid
const existingDay = (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
)
if (existingDay) {
await update(existingDay.id, {
// §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 add({
await plannedDaysHook.add({
workspace_id: workspace.id,
child_id: selectedChildId,
parent_personal_data_uuid: personalDataUuid,
parent_personal_data_uuid: payload.parentId,
date,
day_type: payload.dayType,
is_dubbeldag: payload.isDubbeldag,
is_pinned: true,
})
}
}
@@ -282,7 +537,7 @@ export function Calendar(): ReactNode {
const existingDay = getExistingDay(date)
if (existingDay) {
await remove(existingDay.id)
await plannedDaysHook.remove(existingDay.id)
}
setIsModalOpen(false)
@@ -294,11 +549,191 @@ export function Calendar(): ReactNode {
}
}
// Exposed cleanly for a later task: clicking a band (a planned period,
// possibly spanning several weeks) will open a period editor. That editor
// doesn't exist yet, so this is intentionally a no-op for now.
const handleBandClick = (_info: BandClickInfo): void => {
// TODO: open a period editor for this band once it exists.
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,
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'
@@ -311,6 +746,19 @@ export function Calendar(): ReactNode {
? 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">
@@ -353,117 +801,150 @@ export function Calendar(): ReactNode {
</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>
{(syncHook.isSyncing || isCreatingPeriod) && (
<p className="calendar__sync-indicator" data-testid="calendar-sync-indicator">
{t('calendar.syncingLabel')}
</p>
)}
<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>
{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>
)}
{/* 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={plannedDays ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
isCurrentMonth={weekMonth === month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
/>
)
})}
</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>
<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>
{/* 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-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 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={isRangeMode ? null : getExistingDay(firstSelectedDate) ?? null}
parents={[{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]}
existingDay={getExistingDay(firstSelectedDate) ?? null}
parents={parentsForModals}
selectedParentId={personalDataUuid ?? ''}
onSave={handleSavePlannedDay}
onDelete={!isRangeMode && getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())
}}
isSaving={isSaving}
error={modalError}
isRangeMode={isRangeMode}
dayCount={selectedDates.size}
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>