diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx
new file mode 100644
index 0000000..2f8cbeb
--- /dev/null
+++ b/frontend/src/components/calendar/PeriodPopover.test.tsx
@@ -0,0 +1,300 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { PeriodPopover } from './PeriodPopover'
+import { makeLeaveWish } from '../../../tests/fixtures'
+
+describe('PeriodPopover', () => {
+ const mockOnClose = vi.fn()
+ const mockOnSave = vi.fn()
+ const mockOnDelete = vi.fn()
+
+ const defaultProps = {
+ isOpen: true,
+ wish: null,
+ startDate: '2028-06-15',
+ endDate: '2028-06-20',
+ parents: [{ id: 'p1', name: 'Parent 1' }],
+ onSave: mockOnSave,
+ onDelete: mockOnDelete,
+ onClose: mockOnClose,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns null when not open', () => {
+ const { container } = render( )
+ expect(container.firstChild).toBeNull()
+ })
+
+ describe('ad-hoc band (no wish)', () => {
+ it('shows the ad-hoc notice and date range instead of editable fields', () => {
+ render( )
+ expect(screen.getByTestId('period-adhoc-notice')).toBeInTheDocument()
+ expect(screen.getByText('2028-06-15 – 2028-06-20')).toBeInTheDocument()
+ expect(screen.queryByTestId('period-name-input')).not.toBeInTheDocument()
+ })
+
+ it('does not render a save button', () => {
+ render( )
+ expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument()
+ })
+
+ it('shows the ad-hoc delete label', () => {
+ render( )
+ expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar')
+ })
+
+ it('calls onDelete after confirmation', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true)
+ mockOnDelete.mockResolvedValue(undefined)
+ render( )
+ fireEvent.click(screen.getByTestId('period-delete-button'))
+ await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
+ })
+
+ it('does not call onDelete when confirmation is cancelled', () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(false)
+ render( )
+ fireEvent.click(screen.getByTestId('period-delete-button'))
+ expect(mockOnDelete).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('wish-linked band', () => {
+ const wish = makeLeaveWish({
+ id: 'lw-1',
+ name: 'Sommar',
+ windowStartMonth: 6,
+ windowStartDay: 15,
+ windowEndMonth: 7,
+ windowEndDay: 26,
+ firstYear: 2028,
+ lastYear: null,
+ recurrence: 'yearly',
+ parentMode: 'either',
+ })
+
+ it('prefills name/dates/parent/repeat from the wish', () => {
+ render( )
+ expect(screen.getByTestId('period-name-input')).toHaveValue('Sommar')
+ expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15')
+ expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26')
+ expect(screen.getByTestId('period-parent-select')).toHaveValue('either')
+ expect(screen.getByTestId('period-repeat-yearly')).toBeChecked()
+ })
+
+ it('shows the wrapping window correctly for a Jul-style wish (end year = firstYear + 1)', () => {
+ const jul = makeLeaveWish({
+ windowStartMonth: 12,
+ windowStartDay: 20,
+ windowEndMonth: 1,
+ windowEndDay: 16,
+ firstYear: 2028,
+ })
+ render( )
+ expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-20')
+ expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-16')
+ })
+
+ it('shows "until age 4" checked when lastYear is null', () => {
+ render( )
+ expect(screen.getByTestId('period-until-age4')).toBeChecked()
+ expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
+ })
+
+ it('shows an explicit last-year input when lastYear is set', () => {
+ render( )
+ expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
+ expect(screen.getByTestId('period-last-year-input')).toHaveValue(2030)
+ })
+
+ it('unchecking "until age 4" reveals a last-year input seeded from the start date\'s year', () => {
+ render( )
+ expect(screen.getByTestId('period-until-age4')).toBeChecked()
+ fireEvent.click(screen.getByTestId('period-until-age4'))
+ expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
+ expect(screen.getByTestId('period-last-year-input')).toHaveValue(2028)
+ })
+
+ it('re-checking "until age 4" hides the last-year input and clears it on save', async () => {
+ mockOnSave.mockResolvedValue(undefined)
+ render( )
+ fireEvent.click(screen.getByTestId('period-until-age4'))
+ expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByTestId('period-save-button'))
+ await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: null })))
+ })
+
+ it('editing the last-year input updates the value sent on save', async () => {
+ mockOnSave.mockResolvedValue(undefined)
+ render( )
+ fireEvent.change(screen.getByTestId('period-last-year-input'), { target: { value: '2032' } })
+ expect(screen.getByTestId('period-last-year-input')).toHaveValue(2032)
+ fireEvent.click(screen.getByTestId('period-save-button'))
+ await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: 2032 })))
+ })
+
+ it('toggles to "Never" repeat and hides the yearly options', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('period-repeat-never'))
+ expect(screen.queryByTestId('period-yearly-options')).not.toBeInTheDocument()
+ })
+
+ it('calls onSave with the edited name, dates and recurrence', async () => {
+ mockOnSave.mockResolvedValue(undefined)
+ render( )
+
+ fireEvent.change(screen.getByTestId('period-name-input'), { target: { value: 'Sommarledighet' } })
+ fireEvent.change(screen.getByTestId('period-end-input'), { target: { value: '2028-08-02' } })
+ fireEvent.click(screen.getByTestId('period-save-button'))
+
+ await waitFor(() => {
+ expect(mockOnSave).toHaveBeenCalledWith({
+ name: 'Sommarledighet',
+ startDate: '2028-06-15',
+ endDate: '2028-08-02',
+ parentMode: 'either',
+ parentPersonalDataUuid: null,
+ recurrence: 'yearly',
+ lastYear: null,
+ })
+ })
+ })
+
+ it('sends lastYear: null when switched to Never even if a last year was set', async () => {
+ mockOnSave.mockResolvedValue(undefined)
+ render( )
+ fireEvent.click(screen.getByTestId('period-repeat-never'))
+ fireEvent.click(screen.getByTestId('period-save-button'))
+ await waitFor(() => {
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ recurrence: 'once', lastYear: null }))
+ })
+ })
+
+ it('switches parentMode to specific when a member is selected', async () => {
+ mockOnSave.mockResolvedValue(undefined)
+ render(
+ ,
+ )
+ fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
+ fireEvent.click(screen.getByTestId('period-save-button'))
+ await waitFor(() => {
+ expect(mockOnSave).toHaveBeenCalledWith(
+ expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'p2' }),
+ )
+ })
+ })
+
+ it('switches parentMode back to "both" after picking a specific member', async () => {
+ mockOnSave.mockResolvedValue(undefined)
+ render(
+ ,
+ )
+ fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
+ fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'both' } })
+ fireEvent.click(screen.getByTestId('period-save-button'))
+ await waitFor(() => {
+ expect(mockOnSave).toHaveBeenCalledWith(
+ expect.objectContaining({ parentMode: 'both', parentPersonalDataUuid: null }),
+ )
+ })
+ })
+
+ it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true)
+ mockOnDelete.mockRejectedValue('boom')
+ render( )
+ fireEvent.click(screen.getByTestId('period-delete-button'))
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
+ })
+ })
+
+ it('calls onDelete for a wish-linked band after confirmation', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true)
+ mockOnDelete.mockResolvedValue(undefined)
+ render( )
+ fireEvent.click(screen.getByTestId('period-delete-button'))
+ await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
+ })
+
+ it('shows the wish-linked delete label', () => {
+ render( )
+ expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period')
+ })
+
+ it('displays a local error when onSave rejects', async () => {
+ mockOnSave.mockRejectedValue(new Error('save exploded'))
+ render( )
+ fireEvent.click(screen.getByTestId('period-save-button'))
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('save exploded')
+ })
+ })
+
+ it('displays a generic error when onSave rejects with a non-Error value', async () => {
+ mockOnSave.mockRejectedValue('boom')
+ render( )
+ fireEvent.click(screen.getByTestId('period-save-button'))
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).not.toHaveTextContent('boom')
+ expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
+ })
+ })
+
+ it('displays the error prop directly', () => {
+ render( )
+ expect(screen.getByRole('alert')).toHaveTextContent('server said no')
+ })
+
+ it('shows the loading state while saving', () => {
+ render( )
+ expect(screen.getByTestId('period-save-button')).toBeDisabled()
+ expect(screen.getByTestId('period-save-button')).toHaveTextContent('Sparar…')
+ })
+
+ it('resolves a preset-created title through its message key, not the stale stored name', () => {
+ const presetWish = makeLeaveWish({ name: 'Stale Cached Name', nameMessageKey: 'wishEditor.seasonSommar' })
+ render( )
+ expect(screen.getByTestId('period-popover-title')).toHaveTextContent('Sommar')
+ })
+ })
+
+ describe('close behaviour', () => {
+ it('calls onClose on the close button', () => {
+ render( )
+ fireEvent.click(screen.getByLabelText('Stäng'))
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('calls onClose on overlay click but not on content click', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('period-popover__content'))
+ expect(mockOnClose).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByTestId('period-popover__overlay'))
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('calls onClose on the cancel button', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('period-cancel-button'))
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx
new file mode 100644
index 0000000..f7dd306
--- /dev/null
+++ b/frontend/src/components/calendar/PeriodPopover.tsx
@@ -0,0 +1,376 @@
+import { ReactNode, useEffect, useState } from 'react'
+import type { LeaveWish } from '@/types'
+import { useTranslation, type TranslationKey } from '@/i18n'
+import { resolveWishName } from '@/utils/wishName'
+import './period-popover.css'
+
+export interface PeriodPopoverSavePayload {
+ name: string
+ // ISO-8601 dates for the period's first (or only) occurrence. Month/day
+ // become the wish's recurring anchor; the start date's year becomes
+ // `firstYear`. See `LeaveWish.window_for_year()` (backend) for how a
+ // wrapping window (end sorts before start) is inferred from month/day
+ // alone, not from the actual year gap between these two dates.
+ startDate: string
+ endDate: string
+ parentMode: 'either' | 'both' | 'specific'
+ parentPersonalDataUuid: string | null
+ recurrence: 'yearly' | 'once'
+ // Meaningful only when `recurrence === 'yearly'`. `null` = "until the
+ // child turns 4" (no explicit end year).
+ lastYear: number | null
+}
+
+export interface PeriodPopoverProps {
+ isOpen: boolean
+ /** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band
+ * (planned days with no `wishId`) — those can only be deleted, not edited,
+ * since there is no wish to persist name/parent/repeat changes against. */
+ wish: LeaveWish | null
+ /** The clicked band's actual placed dates — always shown, and the only
+ * source of truth for an ad-hoc band. */
+ startDate: string
+ endDate: string
+ parents: Array<{ id: string; name: string }>
+ isSaving?: boolean | undefined
+ error?: string | null | undefined
+ onSave: (payload: PeriodPopoverSavePayload) => Promise
+ onDelete: () => Promise
+ onClose: () => void
+}
+
+interface FormState {
+ name: string
+ startDate: string
+ endDate: string
+ parentMode: 'either' | 'both' | 'specific'
+ parentPersonalDataUuid: string | null
+ recurrence: 'yearly' | 'once'
+ lastYear: number | null
+}
+
+/** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py):
+ * whether (end_month, end_day) sorts before (start_month, start_day). */
+function windowWraps(startMonth: number, startDay: number, endMonth: number, endDay: number): boolean {
+ return endMonth < startMonth || (endMonth === startMonth && endDay < startDay)
+}
+
+function formatISO(year: number, month: number, day: number): string {
+ return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
+}
+
+function parseISO(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 }
+}
+
+function formStateFromWish(wish: LeaveWish, t: (key: TranslationKey) => string): FormState {
+ const wraps = windowWraps(wish.windowStartMonth, wish.windowStartDay, wish.windowEndMonth, wish.windowEndDay)
+ const endYear = wraps ? wish.firstYear + 1 : wish.firstYear
+ return {
+ name: resolveWishName(wish, t),
+ startDate: formatISO(wish.firstYear, wish.windowStartMonth, wish.windowStartDay),
+ endDate: formatISO(endYear, wish.windowEndMonth, wish.windowEndDay),
+ parentMode: wish.parentMode,
+ parentPersonalDataUuid: wish.parentPersonalDataUuid,
+ recurrence: wish.recurrence,
+ lastYear: wish.lastYear,
+ }
+}
+
+export function PeriodPopover({
+ isOpen,
+ wish,
+ startDate,
+ endDate,
+ parents,
+ isSaving = false,
+ error = null,
+ onSave,
+ onDelete,
+ onClose,
+}: PeriodPopoverProps): ReactNode {
+ const { t } = useTranslation()
+ const [form, setForm] = useState({
+ name: '',
+ startDate,
+ endDate,
+ parentMode: 'either',
+ parentPersonalDataUuid: null,
+ recurrence: 'once',
+ lastYear: null,
+ })
+ const [localError, setLocalError] = useState(null)
+
+ useEffect(() => {
+ if (wish) {
+ setForm(formStateFromWish(wish, t))
+ } else {
+ setForm({
+ name: '',
+ startDate,
+ endDate,
+ parentMode: 'either',
+ parentPersonalDataUuid: null,
+ recurrence: 'once',
+ lastYear: null,
+ })
+ }
+ setLocalError(null)
+ }, [isOpen, wish, startDate, endDate, t])
+
+ if (!isOpen) return null
+
+ const parentSelectValue = form.parentMode === 'specific' ? (form.parentPersonalDataUuid ?? '') : form.parentMode
+
+ const handleSave = async () => {
+ if (!wish) return
+ try {
+ setLocalError(null)
+ await onSave({
+ name: form.name,
+ startDate: form.startDate,
+ endDate: form.endDate,
+ parentMode: form.parentMode,
+ parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null,
+ recurrence: form.recurrence,
+ lastYear: form.recurrence === 'yearly' ? form.lastYear : null,
+ })
+ } catch (err) {
+ setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
+ }
+ }
+
+ const handleDelete = async () => {
+ if (confirm(t('calendar.periodDeleteConfirm'))) {
+ try {
+ setLocalError(null)
+ await onDelete()
+ } catch (err) {
+ setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
+ }
+ }
+ }
+
+ const title = wish ? resolveWishName(wish, t) : t('calendar.periodAdHocTitle')
+
+ return (
+
+
e.stopPropagation()}
+ data-testid="period-popover__content"
+ >
+
+
{title}
+
+ ×
+
+
+
+
+ {(error || localError) && (
+
+ {error || localError}
+
+ )}
+
+ {!wish && (
+ <>
+
+ {t('calendar.periodAdHocNotice')}
+
+
+ {startDate} – {endDate}
+
+ >
+ )}
+
+ {wish && (
+ <>
+
+
+ {t('calendar.periodNameLabel')}
+
+ setForm((prev) => ({ ...prev, name: e.target.value }))}
+ className="period-popover__input"
+ disabled={isSaving}
+ data-testid="period-name-input"
+ aria-label={t('calendar.periodNameAria')}
+ />
+
+
+
+
+ {t('calendar.periodStartLabel')}
+
+ setForm((prev) => ({ ...prev, startDate: e.target.value }))}
+ className="period-popover__input"
+ disabled={isSaving}
+ data-testid="period-start-input"
+ />
+
+
+
+
+ {t('calendar.periodEndLabel')}
+
+ setForm((prev) => ({ ...prev, endDate: e.target.value }))}
+ className="period-popover__input"
+ disabled={isSaving}
+ data-testid="period-end-input"
+ />
+
+
+
+
+ {t('wishEditor.parentLabel')}
+
+ {
+ const value = e.target.value
+ if (value === 'either' || value === 'both') {
+ setForm((prev) => ({ ...prev, parentMode: value, parentPersonalDataUuid: null }))
+ } else {
+ setForm((prev) => ({ ...prev, parentMode: 'specific', parentPersonalDataUuid: value }))
+ }
+ }}
+ className="period-popover__select"
+ disabled={isSaving}
+ data-testid="period-parent-select"
+ >
+ {t('wishEditor.eitherParent')}
+ {t('wishEditor.bothParents')}
+ {parents.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+
+ {t('calendar.periodRepeatLabel')}
+
+ setForm((prev) => ({ ...prev, recurrence: 'once' }))}
+ disabled={isSaving}
+ data-testid="period-repeat-never"
+ />
+ {' '}{t('calendar.periodRepeatNever')}
+
+
+ setForm((prev) => ({ ...prev, recurrence: 'yearly' }))}
+ disabled={isSaving}
+ data-testid="period-repeat-yearly"
+ />
+ {' '}{t('calendar.periodRepeatYearly')}
+
+
+
+ {form.recurrence === 'yearly' && (
+
+
+
+ setForm((prev) => ({
+ ...prev,
+ lastYear: e.target.checked ? null : parseISO(prev.startDate).year,
+ }))
+ }
+ disabled={isSaving}
+ data-testid="period-until-age4"
+ />
+ {' '}{t('wishEditor.untilAge4')}
+
+
+ {form.lastYear !== null && (
+
+ {t('calendar.periodLastYearLabel')}
+
+ setForm((prev) => ({ ...prev, lastYear: Number(e.target.value) }))
+ }
+ disabled={isSaving}
+ data-testid="period-last-year-input"
+ />
+
+ )}
+
+ )}
+ >
+ )}
+
+
+
+ void handleDelete()}
+ disabled={isSaving}
+ data-testid="period-delete-button"
+ >
+ {isSaving
+ ? t('calendar.periodDeletingButton')
+ : wish
+ ? t('calendar.periodDeleteButton')
+ : t('calendar.periodDeleteAdHocButton')}
+
+
+ {t('calendar.periodCancelButton')}
+
+ {wish && (
+ void handleSave()}
+ disabled={isSaving}
+ data-testid="period-save-button"
+ >
+ {isSaving ? t('calendar.periodSavingButton') : t('calendar.periodSaveButton')}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/components/calendar/PresetRail.test.tsx b/frontend/src/components/calendar/PresetRail.test.tsx
new file mode 100644
index 0000000..7ba507b
--- /dev/null
+++ b/frontend/src/components/calendar/PresetRail.test.tsx
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { PresetRail, SEASON_PRESETS } from './PresetRail'
+
+describe('PresetRail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders one button per season preset', () => {
+ render( )
+ for (const preset of SEASON_PRESETS) {
+ expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeInTheDocument()
+ }
+ })
+
+ it('renders the heading and hint', () => {
+ render( )
+ expect(screen.getByTestId('preset-rail')).toBeInTheDocument()
+ })
+
+ it('calls onSelectPreset with the clicked preset', () => {
+ const onSelectPreset = vi.fn()
+ render( )
+ fireEvent.click(screen.getByTestId('preset-rail-season-sommar'))
+ expect(onSelectPreset).toHaveBeenCalledWith(
+ expect.objectContaining({ key: 'season-sommar', defaultWeeks: 6 }),
+ )
+ })
+
+ it('every preset is flexible (window search, not a fixed placement)', () => {
+ // Season presets seed a "när det passar bäst" wish — the allocator
+ // resolves the placement. A fixed period is what dragging produces.
+ for (const preset of SEASON_PRESETS) {
+ expect(preset.startMonth).toBeGreaterThanOrEqual(1)
+ expect(preset.endMonth).toBeGreaterThanOrEqual(1)
+ }
+ expect(SEASON_PRESETS).toHaveLength(4)
+ })
+
+ it('disables every button when isBusy is true', () => {
+ render( )
+ for (const preset of SEASON_PRESETS) {
+ expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeDisabled()
+ }
+ })
+
+ it('does not call onSelectPreset for a disabled button click', () => {
+ const onSelectPreset = vi.fn()
+ render( )
+ fireEvent.click(screen.getByTestId('preset-rail-season-var'))
+ expect(onSelectPreset).not.toHaveBeenCalled()
+ })
+
+ it('shows the week count and the flexible badge for each preset', () => {
+ render( )
+ const summerButton = screen.getByTestId('preset-rail-season-sommar')
+ expect(summerButton).toHaveTextContent('6')
+ expect(summerButton).toHaveTextContent('när det passar bäst')
+ })
+})
diff --git a/frontend/src/components/calendar/PresetRail.tsx b/frontend/src/components/calendar/PresetRail.tsx
new file mode 100644
index 0000000..aed53e5
--- /dev/null
+++ b/frontend/src/components/calendar/PresetRail.tsx
@@ -0,0 +1,73 @@
+import { ReactNode } from 'react'
+import { useTranslation, type TranslationKey } from '@/i18n'
+import './preset-rail.css'
+
+export interface SeasonPreset {
+ key: string
+ labelKey: TranslationKey
+ startMonth: number
+ startDay: number
+ endMonth: number
+ endDay: number
+ defaultWeeks: number
+}
+
+/**
+ * The four season quick-picks, copied from `SEASON_PRESETS` in
+ * `WishEditor.tsx` (PLANNING_ENGINE_SPEC.md §10.7) — deliberately NOT
+ * imported from there since that component is owned by another task.
+ * Every one of these is a `flexible` wish: the dates below are the bounding
+ * range the allocator searches within for the best-costing placement, not
+ * the final dates. A fixed (exact-dates) period is what dragging on the
+ * calendar produces instead — see Calendar.tsx's drag-select handler.
+ */
+export const SEASON_PRESETS: SeasonPreset[] = [
+ { key: 'season-var', labelKey: 'wishEditor.seasonVar', startMonth: 3, startDay: 1, endMonth: 5, endDay: 31, defaultWeeks: 1 },
+ { key: 'season-sommar', labelKey: 'wishEditor.seasonSommar', startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, defaultWeeks: 6 },
+ { key: 'season-host', labelKey: 'wishEditor.seasonHost', startMonth: 9, startDay: 1, endMonth: 10, endDay: 31, defaultWeeks: 1 },
+ { key: 'season-jul', labelKey: 'wishEditor.seasonJul', startMonth: 12, startDay: 1, endMonth: 1, endDay: 31, defaultWeeks: 4 },
+]
+
+export interface PresetRailProps {
+ /** Disables every button while a preset's create+sync is in flight. */
+ isBusy?: boolean | undefined
+ onSelectPreset: (preset: SeasonPreset) => void | Promise
+}
+
+/**
+ * A side rail of one-click season presets. Clicking one creates a flexible
+ * `LeaveWish` and syncs the plan — the allocator picks the best placement,
+ * and the calendar scrolls to it and flashes the resulting band (see
+ * Calendar.tsx's `handleSelectPreset`). This rail only renders the choices
+ * and reports which one was picked; it owns no request/scroll logic itself.
+ */
+export function PresetRail({ isBusy = false, onSelectPreset }: PresetRailProps): ReactNode {
+ const { t } = useTranslation()
+
+ return (
+
+ {t('calendar.railHeading')}
+ {t('calendar.railHint')}
+
+ {SEASON_PRESETS.map((preset) => (
+ void onSelectPreset(preset)}
+ disabled={isBusy}
+ data-testid={`preset-rail-${preset.key}`}
+ aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
+ >
+ {t(preset.labelKey)}
+
+ {preset.defaultWeeks} {t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}
+ {' · '}
+ {t('wishEditor.flexBadge')}
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/calendar/period-popover.css b/frontend/src/components/calendar/period-popover.css
new file mode 100644
index 0000000..0fced05
--- /dev/null
+++ b/frontend/src/components/calendar/period-popover.css
@@ -0,0 +1,177 @@
+.period-popover__overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.period-popover__content {
+ background-color: #fff;
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
+ max-width: 480px;
+ width: 90%;
+ max-height: 80vh;
+ overflow-y: auto;
+}
+
+.period-popover__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.period-popover__header h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.period-popover__close {
+ background: none;
+ border: none;
+ font-size: 28px;
+ cursor: pointer;
+ padding: 0;
+ color: #666;
+ transition: color 0.2s;
+}
+
+.period-popover__close:hover {
+ color: #000;
+}
+
+.period-popover__body {
+ padding: 20px;
+}
+
+.period-popover__error {
+ background-color: #ffebee;
+ color: #c62828;
+ padding: 12px;
+ border-radius: 4px;
+ margin-bottom: 16px;
+ font-size: 14px;
+}
+
+.period-popover__adhoc-notice {
+ font-size: 13px;
+ color: #666;
+ margin: 0 0 12px 0;
+}
+
+.period-popover__date-range {
+ font-size: 15px;
+ font-weight: 500;
+ color: #333;
+}
+
+.period-popover__field {
+ margin-bottom: 16px;
+}
+
+.period-popover__label {
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 6px;
+ color: #333;
+}
+
+.period-popover__input,
+.period-popover__select {
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid #d0d0d0;
+ border-radius: 4px;
+ font-size: 14px;
+ background-color: #fff;
+}
+
+.period-popover__input:disabled,
+.period-popover__select:disabled {
+ background-color: #f5f5f5;
+ cursor: not-allowed;
+}
+
+.period-popover__fieldset {
+ border: none;
+ padding: 0;
+ margin: 0 0 16px 0;
+}
+
+.period-popover__radio-label,
+.period-popover__checkbox-label {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ cursor: pointer;
+ user-select: none;
+ margin-bottom: 8px;
+}
+
+.period-popover__radio-label input,
+.period-popover__checkbox-label input {
+ margin-right: 8px;
+ cursor: pointer;
+}
+
+.period-popover__footer {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ padding: 16px 20px;
+ border-top: 1px solid #e0e0e0;
+ background-color: #fafafa;
+}
+
+.period-popover__button {
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ border: none;
+ transition: background-color 0.2s;
+}
+
+.period-popover__button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.period-popover__button--primary {
+ background-color: #0066cc;
+ color: #fff;
+}
+
+.period-popover__button--primary:hover:not(:disabled) {
+ background-color: #0052a3;
+}
+
+.period-popover__button--secondary {
+ background-color: #e0e0e0;
+ color: #333;
+}
+
+.period-popover__button--secondary:hover:not(:disabled) {
+ background-color: #d0d0d0;
+}
+
+.period-popover__button--danger {
+ background-color: #d32f2f;
+ color: #fff;
+ margin-right: auto;
+}
+
+.period-popover__button--danger:hover:not(:disabled) {
+ background-color: #b71c1c;
+}
diff --git a/frontend/src/components/calendar/preset-rail.css b/frontend/src/components/calendar/preset-rail.css
new file mode 100644
index 0000000..8c1e29f
--- /dev/null
+++ b/frontend/src/components/calendar/preset-rail.css
@@ -0,0 +1,63 @@
+.preset-rail {
+ width: 200px;
+ flex-shrink: 0;
+ padding: 16px;
+ background-color: #f9f9f9;
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ align-self: flex-start;
+}
+
+.preset-rail__heading {
+ margin: 0 0 4px 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: #333;
+}
+
+.preset-rail__hint {
+ margin: 0 0 12px 0;
+ font-size: 12px;
+ color: #666;
+}
+
+.preset-rail__buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.preset-rail__button {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 10px 12px;
+ border: 1px solid #d0d0d0;
+ border-radius: 4px;
+ background-color: #fff;
+ cursor: pointer;
+ text-align: left;
+ transition: background-color 0.2s, border-color 0.2s;
+}
+
+.preset-rail__button:hover:not(:disabled) {
+ background-color: #f0f8ff;
+ border-color: #0066cc;
+}
+
+.preset-rail__button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.preset-rail__button-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: #333;
+}
+
+.preset-rail__button-detail {
+ font-size: 11px;
+ color: #666;
+}
diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx
index b8500c7..64a5657 100644
--- a/frontend/src/components/common/CalendarWeek.test.tsx
+++ b/frontend/src/components/common/CalendarWeek.test.tsx
@@ -456,6 +456,26 @@ describe('CalendarWeek', () => {
})
})
+ describe('§Task7 flashWishId', () => {
+ it('adds the flash class to the band matching flashWishId', () => {
+ const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
+ render( )
+ expect(screen.getAllByTestId(/^calendar-band-/)[0]!).toHaveClass('calendar-band--flash')
+ })
+
+ it('does not flash a band for a different wishId', () => {
+ const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
+ render( )
+ expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
+ })
+
+ it('never flashes an ad-hoc (wishId null) band even if flashWishId is set oddly', () => {
+ const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })]
+ render( )
+ expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
+ })
+ })
+
describe('lanes: overlapping bands for two parents do not collide', () => {
it('assigns overlapping bands to different lanes (both render, neither is dropped)', () => {
const plannedDays = [
diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx
index 7c5acf5..e08c634 100644
--- a/frontend/src/components/common/CalendarWeek.tsx
+++ b/frontend/src/components/common/CalendarWeek.tsx
@@ -29,8 +29,12 @@ export interface CalendarWeekProps {
onPointerEnter: (date: string) => void
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
calendarDaysByDate?: Map | undefined
- /** Optional for now — a later task wires this to a period editor. */
+ /** Opens the period editor for the clicked band's underlying period. */
onBandClick?: ((info: BandClickInfo) => void) | undefined
+ /** §Task7 — the wishId of a just-created/just-edited period to briefly
+ * highlight (see Calendar.tsx's preset rail: "scrolls to the resolved
+ * placement and flashes the band"). `null`/omitted highlights nothing. */
+ flashWishId?: string | null | undefined
}
/** One day's slot inside a band segment: either a real planned_leave_day
@@ -241,6 +245,7 @@ export function CalendarWeek({
onPointerEnter,
calendarDaysByDate,
onBandClick,
+ flashWishId,
}: CalendarWeekProps): ReactNode {
const { t } = useTranslation()
const bandSegments = useMemo(
@@ -323,6 +328,7 @@ export function CalendarWeek({
'calendar-band',
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
+ segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
]
.filter(Boolean)
.join(' ')
diff --git a/frontend/src/components/modals/PlannedDayModal.test.tsx b/frontend/src/components/modals/PlannedDayModal.test.tsx
index 9458c9d..ef80570 100644
--- a/frontend/src/components/modals/PlannedDayModal.test.tsx
+++ b/frontend/src/components/modals/PlannedDayModal.test.tsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlannedDayModal } from './PlannedDayModal'
-import { makePlannedLeaveDay } from '@/../tests/fixtures'
+import { makePlannedLeaveDay, makeCalendarDayEntry } from '@/../tests/fixtures'
describe('PlannedDayModal', () => {
const mockOnClose = vi.fn()
@@ -827,4 +827,136 @@ describe('PlannedDayModal', () => {
expect(screen.getByTestId('delete-button')).toHaveTextContent('Tar bort…')
})
})
+
+ describe('§Task7 pinned notice', () => {
+ it('shows a pinned notice when the existing day is pinned', () => {
+ const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true })
+ render( )
+ expect(screen.getByTestId('pinned-notice')).toBeInTheDocument()
+ })
+
+ it('does not show a pinned notice for an unpinned day', () => {
+ const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: false })
+ render( )
+ expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
+ })
+
+ it('does not show a pinned notice for a brand-new day', () => {
+ render( )
+ expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('§Task7 day status switch (arbetsdag/ledig)', () => {
+ const onToggleOverride = vi.fn()
+
+ beforeEach(() => {
+ onToggleOverride.mockReset()
+ })
+
+ it('is hidden when dayInfo is not provided', () => {
+ render( )
+ expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
+ })
+
+ it('is hidden when onToggleOverride is not provided', () => {
+ render( )
+ expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
+ })
+
+ it('is hidden in range mode even with dayInfo and onToggleOverride provided', () => {
+ render(
+
+ )
+ expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
+ })
+
+ it('selects "Ledig" when the day is currently free', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('day-status-off')).toBeChecked()
+ expect(screen.getByTestId('day-status-workday')).not.toBeChecked()
+ })
+
+ it('selects "Arbetsdag" when the day is currently a working day', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('day-status-workday')).toBeChecked()
+ expect(screen.getByTestId('day-status-off')).not.toBeChecked()
+ })
+
+ it('calls onToggleOverride when picking the option that differs from the current status', async () => {
+ onToggleOverride.mockResolvedValue(undefined)
+ render(
+
+ )
+ await userEvent.click(screen.getByTestId('day-status-off'))
+ expect(onToggleOverride).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not call onToggleOverride when clicking the already-selected option', async () => {
+ onToggleOverride.mockResolvedValue(undefined)
+ render(
+
+ )
+ await userEvent.click(screen.getByTestId('day-status-workday'))
+ expect(onToggleOverride).not.toHaveBeenCalled()
+ })
+
+ it('shows the override error when provided', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('day-status-error')).toHaveTextContent('Det gick inte att uppdatera undantaget')
+ })
+
+ it('disables the switch while saving', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('day-status-workday')).toBeDisabled()
+ expect(screen.getByTestId('day-status-off')).toBeDisabled()
+ })
+ })
})
diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx
index 856a0f1..0b7abd8 100644
--- a/frontend/src/components/modals/PlannedDayModal.tsx
+++ b/frontend/src/components/modals/PlannedDayModal.tsx
@@ -1,5 +1,5 @@
import { ReactNode, useState, useEffect } from 'react'
-import type { PlannedLeaveDay } from '@/types'
+import type { CalendarDayEntry, PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import './planned-day-modal.css'
@@ -21,6 +21,14 @@ export interface PlannedDayModalProps {
isRangeMode?: boolean | undefined
dayCount?: number | undefined
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
+ // §Task7 day popover — this date's calendar metadata (röda dagar/weekend/
+ // overrides), driving the "arbetsdag/ledig" switch below. `undefined`
+ // while the calendar window hasn't loaded metadata for this date yet;
+ // `null`/absent `onToggleOverride` hides the switch entirely (e.g. in
+ // range mode, where there is no single day to toggle).
+ dayInfo?: CalendarDayEntry | null | undefined
+ onToggleOverride?: (() => Promise) | undefined
+ overrideError?: string | null | undefined
}
export function PlannedDayModal({
@@ -37,12 +45,16 @@ export function PlannedDayModal({
isRangeMode = false,
dayCount = 1,
defaultDayType,
+ dayInfo,
+ onToggleOverride,
+ overrideError = null,
}: PlannedDayModalProps): ReactNode {
const { t, locale } = useTranslation()
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
const [parentId, setParentId] = useState(selectedParentId)
const [isDubbeldag, setIsDubbeldag] = useState(false)
const [localError, setLocalError] = useState(null)
+ const [isTogglingOverride, setIsTogglingOverride] = useState(false)
useEffect(() => {
if (existingDay) {
@@ -88,6 +100,22 @@ export function PlannedDayModal({
}
}
+ // §Task7 — the "Den här dagen är: arbetsdag / ledig" switch. There are
+ // only two effective states, so flipping to whichever option ISN'T
+ // currently selected is exactly the toggle `onToggleOverride` performs —
+ // no separate "set to X" call is needed. A no-op click on the
+ // already-selected option is ignored.
+ const handleSelectDayStatus = async (wantFree: boolean) => {
+ if (!onToggleOverride || !dayInfo) return
+ if (dayInfo.isFree === wantFree) return
+ try {
+ setIsTogglingOverride(true)
+ await onToggleOverride()
+ } finally {
+ setIsTogglingOverride(false)
+ }
+ }
+
if (!isOpen) return null
const displayDate = new Date(date + 'T00:00:00Z').toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
@@ -179,6 +207,52 @@ export function PlannedDayModal({
)}
+
+ {existingDay?.isPinned && (
+
+ 📌 {t('plannedDayModal.pinnedNotice')}
+
+ )}
+
+ {!isRangeMode && dayInfo && onToggleOverride && (
+
+
{t('plannedDayModal.dayStatusLabel')}
+
+
+ void handleSelectDayStatus(false)}
+ disabled={isSaving || isTogglingOverride}
+ data-testid="day-status-workday"
+ />
+ {' '}{t('plannedDayModal.workdayOption')}
+
+
+ void handleSelectDayStatus(true)}
+ disabled={isSaving || isTogglingOverride}
+ data-testid="day-status-off"
+ />
+ {' '}{t('plannedDayModal.dayOffOption')}
+
+
+ {overrideError && (
+
+ {overrideError}
+
+ )}
+
+ )}
diff --git a/frontend/src/components/modals/planned-day-modal.css b/frontend/src/components/modals/planned-day-modal.css
index 1814909..3be985f 100644
--- a/frontend/src/components/modals/planned-day-modal.css
+++ b/frontend/src/components/modals/planned-day-modal.css
@@ -121,6 +121,22 @@
font-style: italic;
}
+.planned-day-modal__pinned-notice {
+ font-size: 12px;
+ color: #666;
+ margin: -8px 0 16px 0;
+}
+
+.planned-day-modal__override-switch {
+ display: flex;
+ gap: 16px;
+ margin-top: 6px;
+}
+
+.planned-day-modal__override-switch .planned-day-modal__checkbox-label {
+ margin-bottom: 0;
+}
+
.planned-day-modal__footer {
display: flex;
gap: 8px;
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index e851b4d..56544af 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -2,6 +2,7 @@ export { useAnalytics } from './useAnalytics'
export { useLeaveWishes } from './useLeaveWishes'
export { usePlanProposal } from './usePlanProposal'
export { usePlanApply } from './usePlanApply'
+export { usePlanSync } from './usePlanSync'
export { useWorkspaceMembers } from './useWorkspaceMembers'
export { useShowWeekNumbers, getLastLeaveType, setLastLeaveType } from './useCalendarPreferences'
export { useChildSettings } from './useChildSettings'
diff --git a/frontend/src/hooks/usePlanSync.test.ts b/frontend/src/hooks/usePlanSync.test.ts
new file mode 100644
index 0000000..f204c25
--- /dev/null
+++ b/frontend/src/hooks/usePlanSync.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import { usePlanSync } from './usePlanSync'
+import * as api from '@/services/api'
+import { makePlanSyncResponse, makePlanChange } from '../../tests/fixtures'
+
+vi.mock('@/services/api', () => ({
+ syncPlan: vi.fn(),
+}))
+
+describe('usePlanSync', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('starts idle with no error and no changes', () => {
+ const { result } = renderHook(() => usePlanSync())
+ expect(result.current.isSyncing).toBe(false)
+ expect(result.current.error).toBeNull()
+ expect(result.current.lastChanges).toBeNull()
+ })
+
+ it('calls api.syncPlan with the workspace id and stores the changes', async () => {
+ const response = makePlanSyncResponse({ changes: [makePlanChange({ kind: 'retyped' })] })
+ vi.mocked(api.syncPlan).mockResolvedValue(response)
+
+ const { result } = renderHook(() => usePlanSync())
+
+ let returned: Awaited
> | undefined
+ await act(async () => {
+ returned = await result.current.sync('ws-1')
+ })
+
+ expect(api.syncPlan).toHaveBeenCalledWith('ws-1')
+ expect(returned).toEqual(response)
+ expect(result.current.lastChanges).toEqual(response.changes)
+ expect(result.current.isSyncing).toBe(false)
+ expect(result.current.error).toBeNull()
+ })
+
+ it('flips isSyncing while the call is in flight', async () => {
+ let resolvePromise: (value: ReturnType) => void = () => {}
+ vi.mocked(api.syncPlan).mockReturnValue(
+ new Promise((resolve) => {
+ resolvePromise = resolve
+ }),
+ )
+
+ const { result } = renderHook(() => usePlanSync())
+
+ let syncPromise: Promise = Promise.resolve()
+ act(() => {
+ syncPromise = result.current.sync('ws-1')
+ })
+
+ await waitFor(() => expect(result.current.isSyncing).toBe(true))
+
+ resolvePromise(makePlanSyncResponse())
+ await act(async () => {
+ await syncPromise
+ })
+
+ expect(result.current.isSyncing).toBe(false)
+ })
+
+ it('clears a previous changes list at the start of a new call', async () => {
+ vi.mocked(api.syncPlan).mockResolvedValueOnce(
+ makePlanSyncResponse({ changes: [makePlanChange()] }),
+ )
+ const { result } = renderHook(() => usePlanSync())
+
+ await act(async () => {
+ await result.current.sync('ws-1')
+ })
+ expect(result.current.lastChanges).toHaveLength(1)
+
+ let resolveSecond: (value: ReturnType) => void = () => {}
+ vi.mocked(api.syncPlan).mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSecond = resolve
+ }),
+ )
+
+ act(() => {
+ void result.current.sync('ws-1')
+ })
+
+ await waitFor(() => expect(result.current.lastChanges).toBeNull())
+ resolveSecond(makePlanSyncResponse())
+ })
+
+ it('sets a readable error and rethrows when the request rejects with an Error', async () => {
+ vi.mocked(api.syncPlan).mockRejectedValue(new Error('workspace not found'))
+ const { result } = renderHook(() => usePlanSync())
+
+ await act(async () => {
+ await expect(result.current.sync('ws-1')).rejects.toThrow('workspace not found')
+ })
+
+ expect(result.current.error).toBe('workspace not found')
+ expect(result.current.isSyncing).toBe(false)
+ })
+
+ it('falls back to a generic error message for a non-Error rejection', async () => {
+ vi.mocked(api.syncPlan).mockRejectedValue('network exploded')
+ const { result } = renderHook(() => usePlanSync())
+
+ await act(async () => {
+ await expect(result.current.sync('ws-1')).rejects.toBe('network exploded')
+ })
+
+ expect(result.current.error).toBe('Failed to sync the plan')
+ })
+})
diff --git a/frontend/src/hooks/usePlanSync.ts b/frontend/src/hooks/usePlanSync.ts
new file mode 100644
index 0000000..e0e57fe
--- /dev/null
+++ b/frontend/src/hooks/usePlanSync.ts
@@ -0,0 +1,44 @@
+import { useCallback, useState } from 'react'
+import * as api from '@/services/api'
+import type { PlanSyncResponse } from '@/types'
+
+interface UsePlanSyncResult {
+ /** True while a sync call is in flight. */
+ isSyncing: boolean
+ error: string | null
+ /** The most recent sync's `changes` — cleared to `null` at the start of
+ * every new call so a stale diff from a previous sync never lingers. */
+ lastChanges: PlanSyncResponse['changes'] | null
+ sync: (workspaceId: string) => Promise
+}
+
+/**
+ * `POST /api/plan/sync` — the direct-manipulation calendar's one call:
+ * recompute the plan from the workspace's `LeaveWish`es and persist it,
+ * never retyping/overwriting a pinned day, and reporting exactly what
+ * changed. Callers (Calendar.tsx) drive their own toast/undo UI off the
+ * returned `changes`; this hook only owns the request's loading/error state.
+ */
+export function usePlanSync(): UsePlanSyncResult {
+ const [isSyncing, setIsSyncing] = useState(false)
+ const [error, setError] = useState(null)
+ const [lastChanges, setLastChanges] = useState(null)
+
+ const sync = useCallback(async (workspaceId: string): Promise => {
+ setIsSyncing(true)
+ setError(null)
+ setLastChanges(null)
+ try {
+ const result = await api.syncPlan(workspaceId)
+ setLastChanges(result.changes)
+ return result
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to sync the plan')
+ throw err
+ } finally {
+ setIsSyncing(false)
+ }
+ }, [])
+
+ return { isSyncing, error, lastChanges, sync }
+}
diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts
index 434c084..52298de 100644
--- a/frontend/src/i18n/en/calendar.ts
+++ b/frontend/src/i18n/en/calendar.ts
@@ -49,6 +49,10 @@ export const calendar = {
'plannedDayModal.leaveTypeLabel': 'Leave Type',
'plannedDayModal.newTitle': 'Plan a Day',
'plannedDayModal.parentLabel': 'Parent',
+ 'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
+ 'plannedDayModal.dayStatusLabel': 'This day is:',
+ 'plannedDayModal.workdayOption': 'Working day',
+ 'plannedDayModal.dayOffOption': 'Day off',
'plannedDayModal.rangeTitle': 'Plan {count} days',
'plannedDayModal.saveButton': 'Save',
'plannedDayModal.saveError': 'Failed to save planned day',
@@ -63,4 +67,44 @@ export const calendar = {
'redDaysList.overrideFree': 'Exception: day off',
'redDaysList.overrideWorking': 'Exception: working day',
'redDaysList.prevWindow': 'Previous 4 years',
+
+ // §Task7 — the calendar becomes the plan: drag-select, period popover,
+ // preset rail, and the sync toast/undo.
+ 'calendar.newPeriodName': 'New period',
+ 'calendar.periodCreateError': 'Failed to create the period',
+ 'calendar.syncError': 'Failed to sync the plan',
+ 'calendar.syncingLabel': 'Syncing…',
+ 'calendar.toastChangedDays': '{name}: {count} day(s) changed',
+ 'calendar.toastGenericPeriod': 'The period',
+ 'calendar.toastShortfallNone':
+ '{name} ({start}–{end}): none of the {weeksWished} week(s) requested could be funded — {shortfallDays} day(s) short',
+ 'calendar.toastShortfallPartial':
+ '{name} ({start}–{end}): only {weeksFunded} of {weeksWished} week(s) funded — {shortfallDays} day(s) short',
+ 'calendar.undoButton': 'Undo',
+ 'calendar.undoError': 'Failed to undo the change',
+ 'calendar.dismissToastAria': 'Dismiss the message',
+
+ 'calendar.railHeading': 'Add leave',
+ 'calendar.railHint': 'Click to add — the calendar jumps to wherever it fits best.',
+ 'calendar.presetAddAria': 'Add {name}',
+
+ 'calendar.periodPopoverTitle': 'Edit period',
+ 'calendar.periodAdHocTitle': 'Period',
+ 'calendar.periodAdHocNotice': 'This period is not linked to a saved wish.',
+ 'calendar.periodNameLabel': 'Name',
+ 'calendar.periodNameAria': "The period's name",
+ 'calendar.periodStartLabel': 'Start date',
+ 'calendar.periodEndLabel': 'End date',
+ 'calendar.periodRepeatLabel': 'Repeat',
+ 'calendar.periodRepeatNever': 'Never',
+ 'calendar.periodRepeatYearly': 'Yearly',
+ 'calendar.periodLastYearLabel': 'Last year',
+ 'calendar.periodSaveButton': 'Save period',
+ 'calendar.periodSavingButton': 'Saving…',
+ 'calendar.periodDeleteButton': 'Delete period',
+ 'calendar.periodDeletingButton': 'Deleting…',
+ 'calendar.periodDeleteConfirm': 'Are you sure you want to delete this period?',
+ 'calendar.periodDeleteAdHocButton': 'Delete these days',
+ 'calendar.periodCancelButton': 'Cancel',
+ 'calendar.periodCloseAria': 'Close',
} as const
diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts
index d92331a..0c2e9fb 100644
--- a/frontend/src/i18n/sv/calendar.ts
+++ b/frontend/src/i18n/sv/calendar.ts
@@ -49,6 +49,10 @@ export const calendar = {
'plannedDayModal.leaveTypeLabel': 'Typ av ledighet',
'plannedDayModal.newTitle': 'Planera en dag',
'plannedDayModal.parentLabel': 'Förälder',
+ 'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
+ 'plannedDayModal.dayStatusLabel': 'Den här dagen är:',
+ 'plannedDayModal.workdayOption': 'Arbetsdag',
+ 'plannedDayModal.dayOffOption': 'Ledig',
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
'plannedDayModal.saveButton': 'Spara',
'plannedDayModal.saveError': 'Det gick inte att spara den planerade dagen',
@@ -63,4 +67,44 @@ export const calendar = {
'redDaysList.overrideFree': 'Undantag: ledig dag',
'redDaysList.overrideWorking': 'Undantag: arbetsdag',
'redDaysList.prevWindow': 'Föregående 4 år',
+
+ // §Task7 — the calendar becomes the plan: drag-select, period popover,
+ // preset rail, and the sync toast/undo.
+ 'calendar.newPeriodName': 'Ny period',
+ 'calendar.periodCreateError': 'Det gick inte att skapa perioden',
+ 'calendar.syncError': 'Det gick inte att synka planen',
+ 'calendar.syncingLabel': 'Synkroniserar…',
+ 'calendar.toastChangedDays': '{name}: {count} dag(ar) ändrades',
+ 'calendar.toastGenericPeriod': 'Perioden',
+ 'calendar.toastShortfallNone':
+ '{name} ({start}–{end}): inget av de {weeksWished} önskade veckorna kunde finansieras — {shortfallDays} dag(ar) saknas',
+ 'calendar.toastShortfallPartial':
+ '{name} ({start}–{end}): endast {weeksFunded} av {weeksWished} veckor finansierades — {shortfallDays} dag(ar) saknas',
+ 'calendar.undoButton': 'Ångra',
+ 'calendar.undoError': 'Det gick inte att ångra ändringen',
+ 'calendar.dismissToastAria': 'Stäng meddelandet',
+
+ 'calendar.railHeading': 'Lägg till ledighet',
+ 'calendar.railHint': 'Klicka för att lägga till — kalendern hoppar till där den passar bäst.',
+ 'calendar.presetAddAria': 'Lägg till {name}',
+
+ 'calendar.periodPopoverTitle': 'Redigera period',
+ 'calendar.periodAdHocTitle': 'Period',
+ 'calendar.periodAdHocNotice': 'Den här perioden är inte kopplad till en sparad önskan.',
+ 'calendar.periodNameLabel': 'Namn',
+ 'calendar.periodNameAria': 'Periodens namn',
+ 'calendar.periodStartLabel': 'Startdatum',
+ 'calendar.periodEndLabel': 'Slutdatum',
+ 'calendar.periodRepeatLabel': 'Upprepning',
+ 'calendar.periodRepeatNever': 'Aldrig',
+ 'calendar.periodRepeatYearly': 'Varje år',
+ 'calendar.periodLastYearLabel': 'Sista året',
+ 'calendar.periodSaveButton': 'Spara period',
+ 'calendar.periodSavingButton': 'Sparar…',
+ 'calendar.periodDeleteButton': 'Ta bort period',
+ 'calendar.periodDeletingButton': 'Tar bort…',
+ 'calendar.periodDeleteConfirm': 'Är du säker på att du vill ta bort den här perioden?',
+ 'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar',
+ 'calendar.periodCancelButton': 'Avbryt',
+ 'calendar.periodCloseAria': 'Stäng',
} as const
\ No newline at end of file
diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx
index 15d79d8..eed8720 100644
--- a/frontend/src/pages/Calendar.test.tsx
+++ b/frontend/src/pages/Calendar.test.tsx
@@ -2,8 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Calendar } from './Calendar'
import { useWorkspace } from '@/context'
-import { usePlannedDays, useCalendarDays, useDayOverrides, useShowWeekNumbers, getLastLeaveType } from '@/hooks'
+import {
+ usePlannedDays,
+ useCalendarDays,
+ useDayOverrides,
+ useShowWeekNumbers,
+ getLastLeaveType,
+ useLeaveWishes,
+ useWorkspaceMembers,
+ usePlanSync,
+} from '@/hooks'
import { LanguageProvider } from '@/i18n'
+import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal'
+import type { PeriodPopoverProps, PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
+import type { PresetRailProps, SeasonPreset } from '@/components/calendar/PresetRail'
import {
makeWorkspaceContextValue,
makePlannedDaysResult,
@@ -11,6 +23,17 @@ import {
makeDayOverridesResult,
makeShowWeekNumbersTuple,
makePlannedLeaveDay,
+ makeLeaveWishesResult,
+ makeWorkspaceMembersResult,
+ makePlanSyncResult,
+ makeLeaveWish,
+ makePersonalData,
+ makePlanSyncResponse,
+ makePlanChange,
+ makeWindowOutcome,
+ makeRejection,
+ makePlanProposal,
+ makePlanApplyResponse,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
@@ -25,6 +48,9 @@ vi.mock('@/hooks', () => ({
setLastLeaveType: vi.fn(),
useCalendarDays: vi.fn(),
useDayOverrides: vi.fn(),
+ useLeaveWishes: vi.fn(),
+ useWorkspaceMembers: vi.fn(),
+ usePlanSync: vi.fn(),
}))
vi.mock('@/components/layout/Layout', () => ({
@@ -36,14 +62,93 @@ vi.mock('@/components/common/LoadingSpinner', () => ({
}))
vi.mock('@/components/modals/PlannedDayModal', () => ({
- // Exposes the props this test suite cares about as data attributes so
- // tests can assert on them without rendering the real modal.
- PlannedDayModal: (props: { defaultDayType?: string; existingDay?: unknown }) => (
+ // Exposes the props this test suite cares about as data attributes/buttons
+ // so tests can assert on Calendar.tsx's wiring without rendering the real
+ // modal (that component has its own exhaustive test suite).
+ PlannedDayModal: (props: PlannedDayModalProps) => (
+ data-parents-count={props.parents.length}
+ data-day-info={props.dayInfo ? 'yes' : 'no'}
+ data-override-error={props.overrideError ?? ''}
+ >
+ void props.onSave({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
+ >
+ save
+
+ {props.onDelete && (
+ void props.onDelete?.()}>
+ delete
+
+ )}
+ {props.onToggleOverride && (
+ void props.onToggleOverride?.()}>
+ toggle
+
+ )}
+
+ close
+
+
+ ),
+}))
+
+vi.mock('@/components/calendar/PeriodPopover', () => ({
+ PeriodPopover: (props: PeriodPopoverProps) => (
+
+
+ void props.onSave({
+ name: 'Edited name',
+ startDate: props.startDate,
+ endDate: props.endDate,
+ parentMode: 'either',
+ parentPersonalDataUuid: null,
+ recurrence: 'yearly',
+ lastYear: null,
+ } satisfies PeriodPopoverSavePayload)
+ }
+ >
+ save
+
+ void props.onDelete()}>
+ delete
+
+
+ close
+
+
+ ),
+}))
+
+const SOMMAR_PRESET: SeasonPreset = {
+ key: 'season-sommar',
+ labelKey: 'wishEditor.seasonSommar',
+ startMonth: 6,
+ startDay: 1,
+ endMonth: 8,
+ endDay: 31,
+ defaultWeeks: 6,
+}
+
+vi.mock('@/components/calendar/PresetRail', () => ({
+ PresetRail: (props: PresetRailProps) => (
+
+ void props.onSelectPreset(SOMMAR_PRESET)}>
+ preset
+
+
),
}))
@@ -58,28 +163,38 @@ describe('Calendar', () => {
})
const mockPlannedDays = makePlannedDaysResult()
-
const mockCalendarWindow = makeCalendarDaysResult()
-
const mockDayOverrides = makeDayOverridesResult()
+ const mockLeaveWishes = makeLeaveWishesResult()
+ const mockMembers = makeWorkspaceMembersResult()
+ const mockSync = makePlanSyncResult()
beforeEach(() => {
vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow)
vi.mocked(useDayOverrides).mockReturnValue(mockDayOverrides)
+ vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes)
+ vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
+ vi.mocked(usePlanSync).mockReturnValue(mockSync)
+ vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
+ vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
})
+ function todayDateString(): string {
+ const today = new Date()
+ const year = today.getFullYear()
+ const month = String(today.getMonth() + 1).padStart(2, '0')
+ const day = String(today.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+ }
+
it('renders loading state while data is loading', () => {
vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspace, isLoading: true })
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
render( )
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('renders 7 weekday headers in Monday-first Swedish order', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
render( )
const weekdays = screen.getAllByText(/^(Mån|Tis|Ons|Tor|Fre|Lör|Sön)$/)
expect(weekdays).toHaveLength(7)
@@ -91,34 +206,14 @@ describe('Calendar', () => {
})
it('renders day cells for all weeks in a month', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
render( )
-
const calendarDays = screen.getAllByTestId('calendar-day')
expect(calendarDays.length).toBeGreaterThan(28)
expect(calendarDays.length % 7).toBe(0)
})
- it('marks Sunday-starting month (March 2026) correctly with 7-column grid', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
- const { rerender } = render( )
- expect(screen.getByTestId('calendar')).toBeInTheDocument()
- rerender( )
-
- const calendarDays = screen.getAllByTestId('calendar-day')
- expect(calendarDays.length % 7).toBe(0)
- })
-
- it('opens modal when clicking a current-month day', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
+ it('opens the day popover when clicking a current-month day', () => {
render( )
-
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
@@ -128,12 +223,8 @@ describe('Calendar', () => {
}
})
- it('does not open modal when clicking disabled (non-current-month) day', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
+ it('does not open the day popover when clicking disabled (non-current-month) day', () => {
render( )
-
const calendarDays = screen.getAllByTestId('calendar-day')
const disabledDay = calendarDays.find((el) => el.classList.contains('calendar-day--disabled'))
@@ -144,402 +235,880 @@ describe('Calendar', () => {
})
it('displays current month title', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
render( )
-
const title = screen.getByRole('heading', { level: 2 })
expect(title.textContent).toMatch(/\d{4}/)
})
it('handles next/prev month navigation', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
render( )
-
- const nextButton = screen.getByLabelText('Nästa månad')
- fireEvent.click(nextButton)
-
- const prevButton = screen.getByLabelText('Föregående månad')
- fireEvent.click(prevButton)
-
+ fireEvent.click(screen.getByLabelText('Nästa månad'))
+ fireEvent.click(screen.getByLabelText('Föregående månad'))
expect(screen.getByTestId('calendar')).toBeInTheDocument()
})
it('highlights date range live while dragging, before pointerup', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
render( )
-
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
-
expect(currentMonthDays.length).toBeGreaterThanOrEqual(2)
const startDay = currentMonthDays[0] as HTMLElement
const endDay = currentMonthDays[3] as HTMLElement
- // Fire pointerdown on first day
fireEvent.pointerDown(startDay)
-
- // Fire pointerenter on later day WITHOUT firing pointerup
fireEvent.pointerEnter(endDay)
- // The range should now have the --selected class before pointerup fires
- const selectedDaysInRange = Array.from(calendarDays).filter((el) =>
- el.classList.contains('calendar-day--selected')
- )
-
+ const selectedDaysInRange = Array.from(calendarDays).filter((el) => el.classList.contains('calendar-day--selected'))
expect(selectedDaysInRange.length).toBeGreaterThan(0)
})
- it('jumps forward to any month via month dropdown', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
+ it('jumps forward/backward via month and year dropdowns', () => {
render( )
-
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
-
- expect(monthSelect).toBeInTheDocument()
-
- // Change to a different month (e.g., month 5 = June, 0-indexed)
- fireEvent.change(monthSelect, { target: { value: '5' } })
-
- // Verify the select value changed
- expect(monthSelect.value).toBe('5')
- })
-
- it('jumps backward to any month via month dropdown', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
- render( )
-
- const title = screen.getByRole('heading', { level: 2 })
- const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
-
- expect(monthSelect).toBeInTheDocument()
-
- // Jump backward to January (month 0)
- fireEvent.change(monthSelect, { target: { value: '0' } })
-
- expect(monthSelect.value).toBe('0')
- })
-
- it('jumps forward to any year via year dropdown', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
- render( )
-
- const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
- expect(yearSelect).toBeInTheDocument()
+ fireEvent.change(monthSelect, { target: { value: '5' } })
+ expect(monthSelect.value).toBe('5')
- // Jump to year 2035
fireEvent.change(yearSelect, { target: { value: '2035' } })
-
expect(yearSelect.value).toBe('2035')
})
- it('jumps backward to any year via year dropdown', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
- render( )
-
- const title = screen.getByRole('heading', { level: 2 })
- const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
-
- expect(yearSelect).toBeInTheDocument()
-
- // Jump backward to year 2026 (the minimum supported year)
- fireEvent.change(yearSelect, { target: { value: '2026' } })
-
- expect(yearSelect.value).toBe('2026')
- })
-
- it('year dropdown is derived from the calendar window\'s minYear/maxYear, not a hardcoded literal', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
+ it('year dropdown is derived from the calendar window\'s minYear/maxYear', () => {
vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({
- data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }),
- }),
+ makeCalendarDaysResult({ data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }) }),
)
-
render( )
-
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
-
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
-
- // Verify the range comes from the API's reported bounds
expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2055)
- expect(options).toHaveLength(30) // 2026 to 2055 inclusive is 30 years
})
- it('year dropdown reflects a narrower calendar window when the API reports one', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- // Default fixture window: minYear 2026, maxYear 2035 (see makeCalendarWindow).
-
+ it('always includes the current year even before the calendar window has loaded', () => {
+ vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true }))
render( )
-
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
-
- expect(Math.min(...options)).toBe(2026)
- expect(Math.max(...options)).toBe(2035)
- expect(options).toHaveLength(10)
- })
-
- it('always includes the current year in the dropdown even before the calendar window has loaded', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({ data: null, isLoading: true }),
- )
-
- render( )
-
- const title = screen.getByRole('heading', { level: 2 })
- const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
- const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
-
expect(options).toContain(new Date().getFullYear())
})
- it('month and year selects have proper accessibility labels', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
- render( )
-
- const title = screen.getByRole('heading', { level: 2 })
- const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
- const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
-
- expect(monthSelect).toHaveAttribute('aria-label', 'Välj månad')
- expect(yearSelect).toHaveAttribute('aria-label', 'Välj år')
- })
-
- // §3.4 shading + §3.6 click-to-override
- function todayDateString(): string {
- const today = new Date()
- const year = today.getFullYear()
- const month = String(today.getMonth() + 1).padStart(2, '0')
- const day = String(today.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
- }
-
describe('§3.4 red day / klämdag shading', () => {
it('shades a statutory red day and shows its name', () => {
const today = todayDateString()
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
- days: [
- makeCalendarDayEntry({
- date: today,
- name: 'Påskdagen; Söndag',
- dayType: 'red_day',
- isOverride: false,
- overrideIsWorkingDay: null,
- isFree: true,
- }),
- ],
+ days: [makeCalendarDayEntry({ date: today, name: 'Påskdagen; Söndag', dayType: 'red_day', isFree: true })],
}),
- })
+ }),
)
-
render( )
-
const redDay = document.querySelector('.calendar-day--red-day')
expect(redDay).toBeInTheDocument()
- // The combined "X; Söndag" form is not dumped raw — the weekday suffix is stripped.
expect(redDay).toHaveAttribute('title', 'Påskdagen')
- expect(redDay).toHaveAttribute('aria-label', expect.stringContaining('Påskdagen'))
- })
-
- it('marks an overridden day distinctly from a plain statutory day', () => {
- const today = todayDateString()
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({
- data: makeCalendarWindow({
- days: [
- makeCalendarDayEntry({
- date: today,
- name: null,
- dayType: 'working_day',
- isOverride: true,
- overrideIsWorkingDay: false,
- isFree: true,
- }),
- ],
- }),
- })
- )
-
- render( )
-
- const overridden = document.querySelector('.calendar-day--override')
- expect(overridden).toBeInTheDocument()
- expect(screen.getByTestId('calendar-day-override-badge')).toBeInTheDocument()
})
})
- describe('§3.6 click-to-override', () => {
- it('shows an override-mode toggle button', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
+ describe('§Task7 day popover replaces the override toolbar', () => {
+ it('does not render an override-mode toggle in the toolbar', () => {
render( )
-
- expect(screen.getByTestId('calendar-override-toggle')).toBeInTheDocument()
+ expect(screen.queryByTestId('calendar-override-toggle')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('calendar-override-hint')).not.toBeInTheDocument()
})
- it('clicking a day while override mode is active does not open the planning modal', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
+ it('passes the clicked date\'s calendar metadata as dayInfo to the day popover', () => {
+ const today = todayDateString()
+ vi.mocked(useCalendarDays).mockReturnValue(
+ makeCalendarDaysResult({
+ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, isFree: true })] }),
+ }),
+ )
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-day-info', 'yes')
+ })
+
+ it('toggling the day popover\'s override switch marks a free day as a working day', async () => {
+ const today = todayDateString()
+ const upsert = vi.fn().mockResolvedValue(undefined)
+ const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined)
+ vi.mocked(useCalendarDays).mockReturnValue(
+ makeCalendarDaysResult({
+ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
+ refetch: refetchCalendarWindow,
+ }),
+ )
+ vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-toggle-override'))
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
+ await waitFor(() =>
+ expect(upsert).toHaveBeenCalledWith({ workspaceId: 'ws-1', date: today, isWorkingDay: true, note: null }),
+ )
+ await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalled())
+ })
+ it('removes an existing override when toggled again', async () => {
+ const today = todayDateString()
+ const removeOverride = vi.fn().mockResolvedValue(undefined)
+ vi.mocked(useCalendarDays).mockReturnValue(
+ makeCalendarDaysResult({
+ data: makeCalendarWindow({
+ days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })],
+ }),
+ }),
+ )
+ vi.mocked(useDayOverrides).mockReturnValue(
+ makeDayOverridesResult({
+ data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null, createdAt: null, updatedAt: null }],
+ upsert: vi.fn(),
+ remove: removeOverride,
+ }),
+ )
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-toggle-override'))
+
+ await waitFor(() => expect(removeOverride).toHaveBeenCalledWith('ov-1'))
+ })
+
+ it('surfaces an override failure through the modal\'s overrideError prop', async () => {
+ const today = todayDateString()
+ const upsert = vi.fn().mockRejectedValue(new Error('quota exceeded'))
+ vi.mocked(useCalendarDays).mockReturnValue(
+ makeCalendarDaysResult({
+ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
+ }),
+ )
+ vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-toggle-override'))
+
+ await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', 'quota exceeded'))
+ })
+ })
+
+ describe('§Task7 saving a day pins it', () => {
+ it('pins a brand-new day created through the day popover', async () => {
+ const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
+ vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
+
+ render( )
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
- expect(currentMonthDay).toBeDefined()
fireEvent.click(currentMonthDay!)
+ fireEvent.click(screen.getByTestId('modal-save'))
+ await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true })))
+ })
+
+ it('pins an existing day edited through the day popover', async () => {
+ const today = todayDateString()
+ const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
+ update,
+ }),
+ )
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-save'))
+
+ await waitFor(() =>
+ expect(update).toHaveBeenCalledWith('pd-1', expect.objectContaining({ is_pinned: true })),
+ )
+ })
+
+ it('shows a generic error when saving a day rejects with a non-Error value', async () => {
+ const add = vi.fn().mockRejectedValue('boom')
+ vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.click(currentMonthDay!)
+ fireEvent.click(screen.getByTestId('modal-save'))
+
+ // The mocked modal doesn't surface `error`, but the save must not
+ // crash and must leave the modal open with the day still selected.
+ await waitFor(() => expect(add).toHaveBeenCalled())
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('shows a generic error when deleting a day rejects with a non-Error value', async () => {
+ const today = todayDateString()
+ const remove = vi.fn().mockRejectedValue('boom')
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
+ remove,
+ }),
+ )
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-delete'))
+
+ await waitFor(() => expect(remove).toHaveBeenCalled())
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('§Task7 fixing the hardcoded single parent', () => {
+ it('feeds every workspace member into the day popover\'s parents list', () => {
+ vi.mocked(useWorkspaceMembers).mockReturnValue(
+ makeWorkspaceMembersResult({
+ data: [makePersonalData({ uuid: 'pd-a', name: 'Alex' }), makePersonalData({ uuid: 'pd-b', name: 'Sam' })],
+ }),
+ )
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.click(currentMonthDay!)
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-parents-count', '2')
+ })
+
+ it('falls back to the logged-in parent alone while members have not loaded', () => {
+ vi.mocked(useWorkspaceMembers).mockReturnValue(makeWorkspaceMembersResult({ data: [] }))
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.click(currentMonthDay!)
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-parents-count', '1')
+ })
+ })
+
+ describe('§Task7 drag-select creates a period', () => {
+ it('creates a fixed, once wish covering the dragged range and syncs', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ const startDay = currentMonthDays[0] as HTMLElement
+ const endDay = currentMonthDays[3] as HTMLElement
+
+ fireEvent.pointerDown(startDay)
+ fireEvent.pointerEnter(endDay)
+ fireEvent.pointerUp(window)
+
+ await waitFor(() =>
+ expect(add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ recurrence: 'once',
+ windowMode: 'fixed',
+ parentMode: 'specific',
+ parentPersonalDataUuid: 'person-1',
+ }),
+ ),
+ )
+ // No day popover opens for a multi-day drag — the period is created directly.
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
- it('toggling override mode off restores normal click-to-plan behaviour', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
+ it('does not open the day popover for a single-day drag (falls back to the normal click path)', () => {
render( )
-
- const toggle = screen.getByTestId('calendar-override-toggle')
- fireEvent.click(toggle) // on
- fireEvent.click(toggle) // off
-
const calendarDays = screen.getAllByTestId('calendar-day')
- const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
- fireEvent.click(currentMonthDay!)
+ const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
+
+ fireEvent.pointerDown(currentMonthDay)
+ fireEvent.pointerUp(window)
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
- it('clicking a currently-free day in override mode marks it as a working day', async () => {
- const today = todayDateString()
- const upsert = vi.fn().mockResolvedValue(undefined)
- const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined)
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({
- data: makeCalendarWindow({
- days: [
- makeCalendarDayEntry({
- date: today,
- name: null,
- dayType: 'weekend',
- isOverride: false,
- overrideIsWorkingDay: null,
- isFree: true,
- }),
- ],
- }),
- refetch: refetchCalendarWindow,
- })
- )
- vi.mocked(useDayOverrides).mockReturnValue(
- makeDayOverridesResult({ data: [], upsert, remove: vi.fn() })
- )
+ it('shows an error toast when creating the period fails', async () => {
+ const add = vi.fn().mockRejectedValue(new Error('validation failed'))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
render( )
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
-
const calendarDays = screen.getAllByTestId('calendar-day')
- const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
- expect(todayCell).toBeDefined()
- fireEvent.click(todayCell!)
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
- await screen.findByTestId('calendar-override-toggle')
- expect(upsert).toHaveBeenCalledWith({
- workspaceId: 'ws-1',
- date: today,
- isWorkingDay: true,
- note: null,
- })
- // Regression: the day cell's shading comes from a separate fetch
- // (GET /api/calendar/days) than the override list, so a successful
- // mutation must explicitly refetch it — otherwise the cell keeps
- // showing its pre-mutation state until a full page reload.
- await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalledTimes(1))
- })
-
- it('clicking an already-overridden day in override mode removes the override', async () => {
- const today = todayDateString()
- const removeOverride = vi.fn().mockResolvedValue(undefined)
- const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined)
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({
- data: makeCalendarWindow({
- days: [
- makeCalendarDayEntry({
- date: today,
- name: null,
- dayType: 'working_day',
- isOverride: true,
- overrideIsWorkingDay: false,
- isFree: true,
- }),
- ],
- }),
- refetch: refetchCalendarWindow,
- })
- )
- vi.mocked(useDayOverrides).mockReturnValue(
- makeDayOverridesResult({
- data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }],
- upsert: vi.fn(),
- remove: removeOverride,
- })
- )
-
- render( )
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
-
- const calendarDays = screen.getAllByTestId('calendar-day')
- const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
- expect(todayCell).toBeDefined()
- fireEvent.click(todayCell!)
-
- await screen.findByTestId('calendar-override-toggle')
- expect(removeOverride).toHaveBeenCalledWith('ov-1')
- await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalledTimes(1))
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('validation failed'))
})
})
- describe('defensive fallbacks and additional branches', () => {
+ describe('§Task7 band click opens the period popover', () => {
+ it('opens the period popover with the resolved wish when a wish-linked band is clicked', () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1' })
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ const band = screen.getAllByTestId(/^calendar-band-/)[0]!
+ fireEvent.click(band)
+
+ expect(screen.getByTestId('period-popover')).toHaveAttribute('data-wish-id', 'lw-1')
+ })
+
+ it('opens the period popover with a null wish for an ad-hoc band', () => {
+ const today = todayDateString()
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ const band = screen.getAllByTestId(/^calendar-band-/)[0]!
+ fireEvent.click(band)
+
+ expect(screen.getByTestId('period-popover')).toHaveAttribute('data-wish-id', 'null')
+ })
+
+ it('saving a period edit updates the wish and re-syncs', async () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1' })
+ const update = vi.fn().mockResolvedValue(wish)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-save'))
+
+ await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ name: 'Edited name' })))
+ await waitFor(() => expect(sync).toHaveBeenCalled())
+ expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
+ })
+
+ it('deleting a wish-linked period removes the wish and re-syncs', async () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1' })
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-delete'))
+
+ await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
+ await waitFor(() => expect(sync).toHaveBeenCalled())
+ })
+
+ it('undo after deleting a wish-linked period recreates it and re-syncs', async () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' })
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const add = vi.fn().mockResolvedValue(wish)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-delete'))
+
+ const undoButton = await screen.findByTestId('calendar-toast-undo')
+ fireEvent.click(undoButton)
+
+ await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sommar' })))
+ })
+
+ it('deleting a wish-linked period no longer present locally shows a toast with no undo', async () => {
+ const today = todayDateString()
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }),
+ )
+ // The wish is gone from the local list by the time delete runs (e.g. a
+ // concurrent refresh) — there is nothing to offer as an undo, even
+ // though the sync still reports (and toasts) a real change.
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [], remove }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-delete'))
+
+ await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toBeInTheDocument())
+ expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument()
+ })
+
+ it('deleting an ad-hoc period removes the underlying planned days directly (no sync)', async () => {
+ const today = todayDateString()
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
+ remove,
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-delete'))
+
+ await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
+ // Sync was still called once, from the initial auto-sync on mount —
+ // but not a second time for this ad-hoc delete (it isn't wish-driven).
+ expect(sync).toHaveBeenCalledTimes(1)
+ })
+
+ it('closes the popover on close/cancel', () => {
+ const today = todayDateString()
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ expect(screen.getByTestId('period-popover')).toBeInTheDocument()
+ fireEvent.click(screen.getByTestId('period-close'))
+ expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('§Task7 preset rail', () => {
+ it('creates the selected preset as a flexible, yearly wish', async () => {
+ const created = makeLeaveWish({ id: 'lw-preset' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ fireEvent.click(screen.getByTestId('preset-click'))
+
+ await waitFor(() =>
+ expect(add).toHaveBeenCalledWith(expect.objectContaining({ windowMode: 'flexible', recurrence: 'yearly' })),
+ )
+ await waitFor(() => expect(sync).toHaveBeenCalled())
+ })
+
+ it('navigates the month view to the resolved placement and flashes the band', async () => {
+ const created = makeLeaveWish({ id: 'lw-preset' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({
+ changes: [makePlanChange({ newWishId: 'lw-preset', date: '2030-07-10' })],
+ }),
+ )
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ fireEvent.click(screen.getByTestId('preset-click'))
+
+ await waitFor(() => {
+ const title = screen.getByRole('heading', { level: 2 })
+ const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
+ expect(yearSelect.value).toBe('2030')
+ })
+ })
+
+ it('shows an error toast when creating the preset fails', async () => {
+ const add = vi.fn().mockRejectedValue(new Error('no priority left'))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+
+ render( )
+ fireEvent.click(screen.getByTestId('preset-click'))
+
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('no priority left'))
+ })
+ })
+
+ describe('§Task7 sync toast + undo', () => {
+ it('shows a toast summarizing what changed after a sync with changes', async () => {
+ const created = makeLeaveWish({ id: 'lw-new', name: 'Sommar' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' }), makePlanChange({ newWishId: 'lw-new' })] }),
+ )
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Sommar'))
+ expect(screen.getByTestId('calendar-toast')).toHaveTextContent('2')
+ })
+
+ it('shows no toast when the sync produces no changes', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ await waitFor(() => expect(add).toHaveBeenCalled())
+ expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
+ })
+
+ it('explains a fully-starved sync (no changes, nothing funded) instead of showing nothing', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({
+ changes: [],
+ proposal: makePlanProposal({
+ outcomes: [
+ makeWindowOutcome({
+ wishId: 'lw-new',
+ startDate: '2026-06-01',
+ endDate: '2026-06-14',
+ weeksWished: 2,
+ weeksFunded: 0,
+ shortfallDays: 10,
+ data: { wish_name: 'Sommar', wish_name_message_key: null },
+ }),
+ ],
+ }),
+ }),
+ )
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ const toast = await screen.findByTestId('calendar-toast')
+ expect(toast).toHaveTextContent('Sommar')
+ expect(toast).toHaveTextContent('10')
+ })
+
+ it('shows no toast when a sync with no changes also has no outcome for the touched wish (genuine no-op)', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({
+ changes: [],
+ proposal: makePlanProposal({ outcomes: [] }),
+ }),
+ )
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ await waitFor(() => expect(add).toHaveBeenCalled())
+ expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
+ })
+
+ it('reports both what changed and what fell short on a partially-funded sync', async () => {
+ const created = makeLeaveWish({ id: 'lw-new', name: 'Sommar' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({
+ changes: [makePlanChange({ newWishId: 'lw-new' })],
+ proposal: makePlanProposal({
+ outcomes: [
+ makeWindowOutcome({
+ wishId: 'lw-new',
+ startDate: '2026-06-01',
+ endDate: '2026-06-14',
+ weeksWished: 2,
+ weeksFunded: 1,
+ shortfallDays: 5,
+ data: { wish_name: 'Sommar', wish_name_message_key: null },
+ }),
+ ],
+ }),
+ }),
+ )
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ const toast = await screen.findByTestId('calendar-toast')
+ // What changed (existing "N day(s) changed" summary) ...
+ expect(toast).toHaveTextContent('ändrades')
+ // ... and what fell short.
+ expect(toast).toHaveTextContent('5')
+ expect(toast).toHaveTextContent('saknas')
+ })
+
+ it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({
+ changes: [],
+ applyResult: makePlanApplyResponse({
+ accepted: false,
+ rejections: [
+ makeRejection({
+ messageKey: 'planApply.rejection.insufficientVacationBalance',
+ data: { parent_personal_data_uuid: 'pd-a', year: 2026, needed: 5, remaining: 2 },
+ }),
+ ],
+ }),
+ }),
+ )
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(useWorkspaceMembers).mockReturnValue(
+ makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }),
+ )
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ const toast = await screen.findByTestId('calendar-toast')
+ expect(toast).toHaveTextContent('Förälder A')
+ expect(toast.textContent).not.toContain('pd-a')
+ })
+
+ it('undo re-syncs after reverting a period edit to its previous state', async () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' })
+ const update = vi.fn().mockResolvedValue(wish)
+ // Every call (including the initial auto-sync on mount, which is not
+ // under test here) reports the same non-empty diff — the point of
+ // this test is the UNDO's re-sync, not what any particular sync call
+ // returns.
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ // Let the initial auto-sync (unrelated to this test) settle first.
+ await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
+
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-save'))
+
+ const undoButton = await screen.findByTestId('calendar-toast-undo')
+ const syncCallsBeforeUndo = sync.mock.calls.length
+ fireEvent.click(undoButton)
+
+ await waitFor(() =>
+ expect(update).toHaveBeenLastCalledWith('lw-1', expect.objectContaining({ name: 'Sommar' })),
+ )
+ // Undo re-syncs (days are derived — reverting the wish and re-syncing
+ // is the correct undo, not touching planned days directly).
+ await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
+ })
+
+ it('dismisses the toast on demand', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ const dismissButton = await screen.findByTestId('calendar-toast-dismiss')
+ fireEvent.click(dismissButton)
+ expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
+ })
+
+ it('undo after creating a period from a drag removes the wish and re-syncs', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ const undoButton = await screen.findByTestId('calendar-toast-undo')
+ const syncCallsBeforeUndo = sync.mock.calls.length
+ fireEvent.click(undoButton)
+
+ await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-new'))
+ await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
+ })
+
+ it('undo after selecting a preset removes the wish and re-syncs', async () => {
+ const created = makeLeaveWish({ id: 'lw-preset' })
+ const add = vi.fn().mockResolvedValue(created)
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ fireEvent.click(screen.getByTestId('preset-click'))
+
+ const undoButton = await screen.findByTestId('calendar-toast-undo')
+ const syncCallsBeforeUndo = sync.mock.calls.length
+ fireEvent.click(undoButton)
+
+ await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset'))
+ await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
+ })
+
+ it('undo after deleting an ad-hoc period recreates the deleted days', async () => {
+ const today = todayDateString()
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [
+ makePlannedLeaveDay({
+ id: 'pd-1',
+ date: today,
+ wishId: null,
+ parentPersonalDataUuid: 'person-1',
+ dayType: 'vacation',
+ isDubbeldag: false,
+ isPinned: true,
+ }),
+ ],
+ remove,
+ add,
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-delete'))
+
+ const undoButton = await screen.findByTestId('calendar-toast-undo')
+ fireEvent.click(undoButton)
+
+ await waitFor(() =>
+ expect(add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspace_id: 'ws-1',
+ child_id: 'child-1',
+ parent_personal_data_uuid: 'person-1',
+ date: today,
+ day_type: 'vacation',
+ is_dubbeldag: false,
+ is_pinned: true,
+ }),
+ ),
+ )
+ })
+ })
+
+ describe('§Task7 initial auto-sync', () => {
+ it('syncs once per workspace on mount without user interaction', async () => {
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ const { rerender } = render( )
+ await waitFor(() => expect(sync).toHaveBeenCalledWith('ws-1'))
+
+ rerender( )
+ // No extra auto-sync from a plain re-render of the same workspace.
+ expect(sync).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not crash when the workspace id is not yet available', () => {
+ vi.mocked(useWorkspace).mockReturnValue(
+ makeWorkspaceContextValue({ workspace: null, selectedChildId: null, personalDataUuid: null }),
+ )
+ expect(() => render( )).not.toThrow()
+ expect(mockSync.sync).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('defensive fallbacks', () => {
it('falls back to safe defaults when workspace/child/personalData are unavailable', () => {
const noWorkspace = makeWorkspaceContextValue({
selectedChildId: null,
@@ -547,68 +1116,31 @@ describe('Calendar', () => {
personalDataUuid: null,
})
vi.mocked(useWorkspace).mockReturnValue(noWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- const upsert = vi.fn()
- const removeOverride = vi.fn()
- vi.mocked(useDayOverrides).mockReturnValue(
- makeDayOverridesResult({ data: [], upsert: vi.fn(), remove: removeOverride })
- )
render( )
- // selectedChildId ?? undefined
expect(usePlannedDays).toHaveBeenCalledWith(undefined)
- // workspace?.id ?? '' (used for both useCalendarDays and useDayOverrides)
expect(useCalendarDays).toHaveBeenCalledWith('', expect.any(Number), expect.any(Number))
expect(useDayOverrides).toHaveBeenCalledWith('')
-
- // Attempting an override toggle without a workspace id must be a no-op
- // (the `if (!workspace?.id) return` guard fires before any mutation).
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
- const calendarDays = screen.getAllByTestId('calendar-day')
- const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
- fireEvent.click(currentMonthDay!)
-
- expect(upsert).not.toHaveBeenCalled()
- expect(removeOverride).not.toHaveBeenCalled()
})
it('treats a missing calendar window as having no days yet', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({
- data: null,
- isLoading: true,
- })
- )
-
+ vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true }))
render( )
-
- // calendarWindow?.days ?? [] — must render normally with no shaded days.
expect(document.querySelector('.calendar-day--red-day')).not.toBeInTheDocument()
expect(screen.getByTestId('calendar')).toBeInTheDocument()
})
it('treats missing planned days as an empty list for lookups and bar rendering', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue({ ...mockPlannedDays, data: null })
-
render( )
-
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
-
- // getExistingDay's (plannedDays ?? []).find(...) must not throw, and
- // there is (correctly) no existing day to edit.
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
})
it('renders the month/year header in English when the locale is English', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
vi.stubGlobal('localStorage', {
getItem: () => null,
@@ -624,7 +1156,6 @@ describe('Calendar', () => {
)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
- // Month select has a simple aria label
expect(monthSelect.getAttribute('aria-label')).toBe('Select month')
} finally {
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
@@ -633,241 +1164,336 @@ describe('Calendar', () => {
})
it('shows the week-number column when the preference is enabled', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useShowWeekNumbers).mockReturnValueOnce(makeShowWeekNumbersTuple(true))
-
render( )
-
expect(document.querySelector('.calendar__grid--with-week-numbers')).toBeInTheDocument()
- expect(document.querySelector('.calendar__week-number-header')).toBeInTheDocument()
})
- it('defaults the new-day modal to "vacation" when there is no last-used leave type', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
+ it('defaults the new-day popover to "vacation" when there is no last-used leave type', () => {
vi.mocked(getLastLeaveType).mockReturnValueOnce(null)
-
render( )
-
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
-
expect(screen.getByTestId('modal')).toHaveAttribute('data-default-day-type', 'vacation')
})
- it('ignores pointer-drag gestures entirely while override mode is active', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
- render( )
-
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
-
- const calendarDays = screen.getAllByTestId('calendar-day')
- const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
- const startDay = currentMonthDays[0] as HTMLElement
- const endDay = currentMonthDays[3] as HTMLElement
-
- fireEvent.pointerDown(startDay)
- fireEvent.pointerEnter(endDay)
-
- // Both handlers' `if (isOverrideMode) return` guards must fire, so no
- // drag-selection highlighting should appear.
- const selected = Array.from(calendarDays).filter((el) => el.classList.contains('calendar-day--selected'))
- expect(selected).toHaveLength(0)
- })
-
- it('treats a missing override-list fetch as empty when clearing an override', async () => {
+ it('lets the popover offer deletion when the clicked date already has a planned day', () => {
const today = todayDateString()
- const removeOverride = vi.fn()
- const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined)
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({
- data: makeCalendarWindow({
- days: [
- makeCalendarDayEntry({
- date: today,
- name: null,
- dayType: 'working_day',
- isOverride: true,
- overrideIsWorkingDay: false,
- isFree: true,
- }),
- ],
- }),
- refetch: refetchCalendarWindow,
- })
- )
- // data is undefined here (e.g. before the overrides fetch resolves) —
- // the (dayOverrides ?? []).find(...) fallback must kick in rather than
- // throwing, and since nothing is found there is nothing to remove.
- vi.mocked(useDayOverrides).mockReturnValue(
- {
- ...makeDayOverridesResult({
- isLoading: true,
- upsert: vi.fn(),
- remove: removeOverride,
- }),
- data: undefined,
- } as unknown as ReturnType
- )
-
- render( )
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
-
- const calendarDays = screen.getAllByTestId('calendar-day')
- const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
- expect(todayCell).toBeDefined()
- fireEvent.click(todayCell!)
-
- await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalledTimes(1))
- expect(removeOverride).not.toHaveBeenCalled()
- })
-
- it('shows the Error message directly when the override mutation rejects with an Error', async () => {
- const today = todayDateString()
- const upsert = vi.fn().mockRejectedValue(new Error('quota exceeded'))
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
- vi.mocked(useCalendarDays).mockReturnValue(
- makeCalendarDaysResult({
- data: makeCalendarWindow({
- days: [
- makeCalendarDayEntry({
- date: today,
- name: null,
- dayType: 'weekend',
- isOverride: false,
- overrideIsWorkingDay: null,
- isFree: true,
- }),
- ],
- }),
- })
- )
- vi.mocked(useDayOverrides).mockReturnValue(
- makeDayOverridesResult({ data: [], upsert, remove: vi.fn() })
- )
-
- render( )
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
-
- const calendarDays = screen.getAllByTestId('calendar-day')
- const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
- fireEvent.click(todayCell!)
-
- await waitFor(() => {
- expect(screen.getByTestId('calendar-override-error')).toHaveTextContent('quota exceeded')
- })
- })
-
- it('passes an empty personalDataUuid through to the planning modal when it is unavailable', () => {
- const noPersonalData = { ...mockWorkspace, personalDataUuid: null }
- vi.mocked(useWorkspace).mockReturnValue(noPersonalData)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
- render( )
-
- // Normal (non-override) click opens the planning modal, which must not
- // crash even though personalDataUuid is missing.
- const calendarDays = screen.getAllByTestId('calendar-day')
- const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
- fireEvent.click(currentMonthDay!)
-
- expect(screen.getByTestId('modal')).toBeInTheDocument()
- })
-
- it('lets the modal offer deletion when the clicked date already has a planned day', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- render( )
-
- const calendarDays = screen.getAllByTestId('calendar-day')
- const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
- const clickedDate = currentMonthDay!.getAttribute('aria-label')!
-
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
- data: [
- makePlannedLeaveDay({
- id: 'pd-1',
- parentPersonalDataUuid: 'person-1',
- date: clickedDate,
- }),
- ],
- })
+ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
+ }),
)
-
- const { rerender } = render( )
- rerender( )
-
- const daysAfterRerender = screen.getAllByTestId('calendar-day')
- const sameDay = daysAfterRerender.find((el) => el.getAttribute('aria-label') === clickedDate)
- fireEvent.click(sameDay!)
-
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes')
})
- it('forces existingDay to null while a multi-day range is being planned', () => {
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
-
+ it('deleting an existing day through the popover removes it', async () => {
+ const today = todayDateString()
+ const remove = vi.fn().mockResolvedValue(undefined)
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
+ remove,
+ }),
+ )
render( )
-
const calendarDays = screen.getAllByTestId('calendar-day')
- const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
- const startDay = currentMonthDays[0] as HTMLElement
- const endDay = currentMonthDays[3] as HTMLElement
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-delete'))
+ await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
+ })
- fireEvent.pointerDown(startDay)
- fireEvent.pointerEnter(endDay)
- fireEvent.pointerUp(window)
-
- // isRangeMode ? null : ... — a completed multi-day drag must force
- // existingDay to null even if one of the dates happened to have a plan.
- expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
+ it('closes the day popover via its close button', () => {
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.click(currentMonthDay!)
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ fireEvent.click(screen.getByTestId('modal-close'))
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('shows a generic override error when the mutation rejects with a non-Error value', async () => {
const today = todayDateString()
const upsert = vi.fn().mockRejectedValue('network exploded')
- vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
- vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
- data: makeCalendarWindow({
- days: [
- makeCalendarDayEntry({
- date: today,
- name: null,
- dayType: 'weekend',
- isOverride: false,
- overrideIsWorkingDay: null,
- isFree: true,
- }),
- ],
- }),
- })
- )
- vi.mocked(useDayOverrides).mockReturnValue(
- makeDayOverridesResult({ data: [], upsert, remove: vi.fn() })
+ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
+ }),
)
+ vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render( )
- fireEvent.click(screen.getByTestId('calendar-override-toggle'))
-
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => {
- expect(screen.getByTestId('calendar-override-error')).toBeInTheDocument()
+ const attr = screen.getByTestId('modal').getAttribute('data-override-error')
+ expect(attr).not.toBe('')
+ expect(attr).not.toBe('network exploded')
})
- // The fallback message is used, not the raw non-Error rejection value.
- expect(screen.getByTestId('calendar-override-error')).not.toHaveTextContent('network exploded')
+ })
+
+ it('treats a missing day-overrides list as empty when clearing an override', async () => {
+ const today = todayDateString()
+ const removeOverride = vi.fn()
+ vi.mocked(useCalendarDays).mockReturnValue(
+ makeCalendarDaysResult({
+ data: makeCalendarWindow({
+ days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })],
+ }),
+ }),
+ )
+ // `data` is undefined here (e.g. before the overrides fetch resolves) —
+ // the `(dayOverridesHook.data ?? []).find(...)` fallback must kick in.
+ vi.mocked(useDayOverrides).mockReturnValue({
+ ...makeDayOverridesResult({ isLoading: true, upsert: vi.fn(), remove: removeOverride }),
+ data: undefined,
+ } as unknown as ReturnType)
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
+ fireEvent.click(todayCell!)
+ fireEvent.click(screen.getByTestId('modal-toggle-override'))
+
+ await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', ''))
+ expect(removeOverride).not.toHaveBeenCalled()
+ })
+
+ it('does not attempt an override toggle when the workspace id is missing', () => {
+ const upsert = vi.fn()
+ vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: 'person-1' }))
+ vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.click(currentMonthDay!)
+ fireEvent.click(screen.getByTestId('modal-toggle-override'))
+
+ expect(upsert).not.toHaveBeenCalled()
+ })
+
+ it('shows a generic error toast for a non-Error rejection while syncing', async () => {
+ const sync = vi.fn().mockRejectedValue('boom')
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+
+ await waitFor(() => {
+ const toastEl = screen.getByTestId('calendar-toast')
+ expect(toastEl).toHaveTextContent('Det gick inte att synka planen')
+ })
+ })
+
+ it('shows a generic error toast for a non-Error rejection when creating a period from a drag', async () => {
+ const add = vi.fn().mockRejectedValue('boom')
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ await waitFor(() =>
+ expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att skapa perioden'),
+ )
+ })
+
+ it('does not create a period from a drag when the workspace id is missing', () => {
+ const add = vi.fn()
+ vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: null }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ expect(add).not.toHaveBeenCalled()
+ })
+
+ it('shows a generic error toast for a non-Error rejection on undo', async () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1' })
+ const update = vi.fn().mockResolvedValueOnce(wish).mockRejectedValueOnce('boom')
+ const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-save'))
+
+ const undoButton = await screen.findByTestId('calendar-toast-undo')
+ fireEvent.click(undoButton)
+
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att ångra ändringen'))
+ })
+
+ it('does not save a period edit for an ad-hoc band (no wishId)', async () => {
+ const today = todayDateString()
+ const update = vi.fn()
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ update, data: [] }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-save'))
+
+ expect(update).not.toHaveBeenCalled()
+ })
+
+ it('shows a generic error when saving a period edit rejects with a non-Error value', async () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1' })
+ const update = vi.fn().mockRejectedValue('boom')
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-save'))
+
+ await waitFor(() => {
+ const attr = screen.getByTestId('period-popover').getAttribute('data-error')
+ expect(attr).not.toBe('')
+ expect(attr).not.toBe('boom')
+ })
+ })
+
+ it('shows an error toast when deleting a period fails', async () => {
+ const today = todayDateString()
+ const wish = makeLeaveWish({ id: 'lw-1' })
+ const remove = vi.fn().mockRejectedValue(new Error('cannot delete'))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ fireEvent.click(screen.getByTestId('period-delete'))
+
+ await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
+ })
+
+ it('shows the Error message directly when the initial sync rejects with an Error', async () => {
+ const sync = vi.fn().mockRejectedValue(new Error('workspace not found'))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('workspace not found'))
+ })
+
+ it('does not navigate or flash a band when the preset is created but the follow-up sync fails', async () => {
+ const created = makeLeaveWish({ id: 'lw-preset' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValueOnce(makePlanSyncResponse()).mockRejectedValueOnce(new Error('sync failed'))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
+
+ const yearBefore = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
+
+ fireEvent.click(screen.getByTestId('preset-click'))
+
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed'))
+ const yearAfter = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
+ expect(yearAfter).toBe(yearBefore)
+ })
+
+ it('actually scrolls the calendar into view when the placement lands after a preset sync', async () => {
+ const scrollIntoView = vi.fn()
+ Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
+ value: scrollIntoView,
+ configurable: true,
+ })
+ const created = makeLeaveWish({ id: 'lw-preset' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi
+ .fn()
+ .mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset', date: '2031-03-05' })] }))
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ fireEvent.click(screen.getByTestId('preset-click'))
+
+ await waitFor(() => expect(scrollIntoView).toHaveBeenCalled())
+ })
+
+ it('deleting an ad-hoc band whose days are no longer present shows no toast', async () => {
+ const today = todayDateString()
+ // The band's dates exist in the grid, but the underlying planned days
+ // have already been removed by the time delete runs (a defensive
+ // fallback path — should not throw or show a toast for zero days).
+ vi.mocked(usePlannedDays).mockReturnValue(
+ makePlannedDaysResult({
+ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
+ }),
+ )
+
+ const { rerender } = render( )
+ fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ // Swap the data out from under the handler right before deleting —
+ // requires a re-render for the new hook return value to take effect.
+ vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
+ rerender( )
+ fireEvent.click(screen.getByTestId('period-delete'))
+
+ expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
+ })
+
+ it('summarizes a change for a wish not present in the local wish list as a generic period', async () => {
+ const created = makeLeaveWish({ id: 'lw-new' })
+ const add = vi.fn().mockResolvedValue(created)
+ const sync = vi.fn().mockResolvedValue(
+ makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'some-other-wish-id' })] }),
+ )
+ // The wish list doesn't (yet) include the wish the change refers to.
+ vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
+ vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
+
+ render( )
+ const calendarDays = screen.getAllByTestId('calendar-day')
+ const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
+ fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
+ fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
+ fireEvent.pointerUp(window)
+
+ await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Perioden'))
})
})
})
diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx
index 72e6cd6..e3bb20d 100644
--- a/frontend/src/pages/Calendar.tsx
+++ b/frontend/src/pages/Calendar.tsx
@@ -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 } | 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()
+ 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>(new Set())
@@ -39,13 +183,20 @@ export function Calendar(): ReactNode {
const [dragCurrent, setDragCurrent] = useState(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(null)
- const [isOverrideMode, setIsOverrideMode] = useState(false)
const [overrideError, setOverrideError] = useState(null)
- const isLoading = wsLoading || pdLoading
+ const [activeBand, setActiveBand] = useState(null)
+ const [isPeriodOpen, setIsPeriodOpen] = useState(false)
+ const [isPeriodSaving, setIsPeriodSaving] = useState(false)
+ const [periodError, setPeriodError] = useState(null)
+ const [isCreatingPeriod, setIsCreatingPeriod] = useState(false)
+ const [flashWishId, setFlashWishId] = useState(null)
+ const [toast, setToast] = useState(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()
- 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 => {
+ 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(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
@@ -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 (
@@ -353,117 +801,150 @@ export function Calendar(): ReactNode {
-
-
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')}
-
- {isOverrideMode && (
-
- {t('calendar.overrideModeHint')}
-
- )}
- {overrideError && (
-
- {overrideError}
-
- )}
-
+ {(syncHook.isSyncing || isCreatingPeriod) && (
+
+ {t('calendar.syncingLabel')}
+
+ )}
-
- {showWeekNumbers &&
}
- {WEEKDAY_KEYS.map((key) => (
-
- {t(key)}
-
- ))}
-
+ {toast && (
+
+ {toast.message}
+ {toast.undo && (
+ void handleUndoClick()}
+ disabled={isUndoing}
+ data-testid="calendar-toast-undo"
+ >
+ {toast.undo.label}
+
+ )}
+ setToast(null)}
+ data-testid="calendar-toast-dismiss"
+ >
+ ×
+
+
+ )}
- {/* 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. */}
-
- {weeks.map((week, weekIdx) => {
- const firstDate = week[0]
- const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
- return (
-
- )
- })}
-
+
+
+
+ {showWeekNumbers &&
}
+ {WEEKDAY_KEYS.map((key) => (
+
+ {t(key)}
+
+ ))}
+
-
-
{t('calendar.legendTitle')}
-
-
-
-
{t('calendar.legendVacation')}
+ {/* 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. */}
+
+ {weeks.map((week, weekIdx) => {
+ const firstDate = week[0]
+ const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
+ return (
+
+ )
+ })}
-
-
- {t('calendar.legendIncomeBased')}
-
-
-
- {t('calendar.legendMinimumRate')}
-
-
-
- {t('calendar.legendRedDay')}
-
-
-
- {t('calendar.legendKlamdag')}
-
-
-
- {t('calendar.legendOverride')}
-
-
-
2×
-
{t('calendar.legendDoubleDay')}
+
+
+
{t('calendar.legendTitle')}
+
+
+
+ {t('calendar.legendVacation')}
+
+
+
+ {t('calendar.legendIncomeBased')}
+
+
+
+ {t('calendar.legendMinimumRate')}
+
+
+
+ {t('calendar.legendRedDay')}
+
+
+
+ {t('calendar.legendKlamdag')}
+
+
+
+ {t('calendar.legendOverride')}
+
+
+ 2×
+ {t('calendar.legendDoubleDay')}
+
+
+
+
{firstSelectedDate && (
{
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 && (
+
)}
diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css
index e4cd33c..aaa69b5 100644
--- a/frontend/src/pages/calendar.css
+++ b/frontend/src/pages/calendar.css
@@ -319,6 +319,21 @@
outline-offset: 1px;
}
+/* §Task7 — briefly highlights the band the preset rail just placed, after
+ the calendar has scrolled/navigated to it. */
+.calendar-band--flash {
+ animation: calendar-band-flash 1.4s ease-in-out;
+}
+
+@keyframes calendar-band-flash {
+ 0%, 100% {
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+ }
+ 30%, 70% {
+ box-shadow: 0 0 0 3px #ffd54f, 0 1px 2px rgba(0, 0, 0, 0.1);
+ }
+}
+
.calendar-band__label {
position: absolute;
inset: 0;
@@ -462,46 +477,74 @@
border: 2px dashed #666;
}
-/* §3.6 — override-mode toolbar */
-.calendar__toolbar {
+/* §Task7 — the calendar becomes the plan: a side rail of presets next to
+ the month grid, a sync-in-progress indicator, and a "what changed" toast
+ with optional undo. The global "mark working/day off" toolbar mode this
+ used to hold was deleted — the same capability now lives in the day
+ popover (see PlannedDayModal's "Den här dagen är" switch). */
+.calendar__layout {
+ display: flex;
+ gap: 20px;
+ align-items: flex-start;
+}
+
+.calendar__main {
+ flex: 1;
+ min-width: 0;
+}
+
+.calendar__sync-indicator {
+ font-size: 12px;
+ color: #666;
+ margin: 0 0 12px 0;
+}
+
+.calendar__toast {
display: flex;
align-items: center;
gap: 12px;
+ padding: 10px 14px;
margin-bottom: 16px;
- flex-wrap: wrap;
+ background-color: #eef6ff;
+ border: 1px solid #b8d9ff;
+ border-radius: 4px;
+ font-size: 13px;
+ color: #003a70;
}
-.calendar__override-toggle {
- padding: 8px 14px;
- border: 1px solid #d0d0d0;
+.calendar__toast-message {
+ flex: 1;
+}
+
+.calendar__toast-undo {
+ padding: 4px 10px;
+ border: 1px solid #0066cc;
border-radius: 4px;
background: #fff;
- color: var(--c-text);
+ color: #0066cc;
cursor: pointer;
- font-size: 13px;
- transition: background-color 0.2s, border-color 0.2s, color 0.2s;
+ font-size: 12px;
+ font-weight: 600;
}
-.calendar__override-toggle:hover {
- background-color: #f0f0f0;
-}
-
-.calendar__override-toggle--active {
+.calendar__toast-undo:hover:not(:disabled) {
background-color: #0066cc;
- border-color: #0066cc;
color: #fff;
}
-.calendar__toolbar-hint {
- font-size: 12px;
- color: #666;
- margin: 0;
+.calendar__toast-undo:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
}
-.calendar__toolbar-error {
- font-size: 12px;
- color: #b3261e;
- margin: 0;
+.calendar__toast-dismiss {
+ background: none;
+ border: none;
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ color: #003a70;
+ padding: 0 2px;
}
.calendar__grid--with-week-numbers {