d14f86478a
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>
1295 lines
50 KiB
TypeScript
1295 lines
50 KiB
TypeScript
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>
|
||
)
|
||
}
|