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 && ( + <> +
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + className="period-popover__input" + disabled={isSaving} + data-testid="period-name-input" + aria-label={t('calendar.periodNameAria')} + /> +
+ +
+ + setForm((prev) => ({ ...prev, startDate: e.target.value }))} + className="period-popover__input" + disabled={isSaving} + data-testid="period-start-input" + /> +
+ +
+ + setForm((prev) => ({ ...prev, endDate: e.target.value }))} + className="period-popover__input" + disabled={isSaving} + data-testid="period-end-input" + /> +
+ +
+ + +
+ +
+ {t('calendar.periodRepeatLabel')} + + +
+ + {form.recurrence === 'yearly' && ( +
+ + + {form.lastYear !== null && ( + + )} +
+ )} + + )} +
+ +
+ + + {wish && ( + + )} +
+
+
+ ) +} 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 ( + + ) +} 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')} +
+ + +
+ {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 ?? ''} + > + + {props.onDelete && ( + + )} + {props.onToggleOverride && ( + + )} + +
+ ), +})) + +vi.mock('@/components/calendar/PeriodPopover', () => ({ + PeriodPopover: (props: PeriodPopoverProps) => ( +
+ + + +
+ ), +})) + +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) => ( +
+ +
), })) @@ -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 {
-
- - {isOverrideMode && ( -

- {t('calendar.overrideModeHint')} -

- )} - {overrideError && ( -

- {overrideError} -

- )} -
+ {(syncHook.isSyncing || isCreatingPeriod) && ( +

+ {t('calendar.syncingLabel')} +

+ )} -
- {showWeekNumbers &&
} - {WEEKDAY_KEYS.map((key) => ( -
- {t(key)} -
- ))} -
+ {toast && ( +
+ {toast.message} + {toast.undo && ( + + )} + +
+ )} - {/* 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 {