From 35e479a005ab0546d7ebe830380dd8e9849cc694 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 15:14:24 +0200 Subject: [PATCH] Let the planner fund a hand-drawn period, and remove one at a time Three changes to calendar editing, too interleaved in the same files to land separately. Giving the same days to the second parent rewrote the first parent's days instead of adding their own. The save looked the existing day up against whoever was logged in rather than the parent being saved for, so parent A's leave turned into parent B's vacation. The parent choice now lives with the lookup, and two parents on the same dates produce two rows and two bands. Drawing a period by hand meant naming a day type up front. It now asks who is off and lets the planner fund it, as a one-off period the sync places, with double days offered when both parents are off and priced honestly in the hint. Pinning an exact day type is still there, moved behind Advanced, and editing a pinned day goes straight back to it. Removing a period offered only the two extremes. There are now three choices: this day, this year's period, or every year. Taking one year out of a repeating period narrows it, or splits it in two when the year is in the middle; where a scope cannot be expressed it is disabled with a reason rather than doing something surprising. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep --- frontend/e2e/tests/drag-create-delete.spec.ts | 68 +- .../calendar/PeriodPopover.test.tsx | 104 +- .../src/components/calendar/PeriodPopover.tsx | 108 ++- .../components/calendar/period-popover.css | 18 + .../modals/PlannedDayModal.test.tsx | 898 +++++++----------- .../src/components/modals/PlannedDayModal.tsx | 277 ++++-- .../components/modals/planned-day-modal.css | 13 + frontend/src/i18n/en/calendar.ts | 23 + frontend/src/i18n/en/wishEditor.ts | 1 + frontend/src/i18n/sv/calendar.ts | 23 + frontend/src/i18n/sv/wishEditor.ts | 1 + frontend/src/pages/Calendar.test.tsx | 397 +++++++- frontend/src/pages/Calendar.tsx | 316 ++++-- frontend/src/services/api.ts | 4 + frontend/src/types/api.ts | 5 + frontend/src/types/entities.ts | 7 + frontend/tests/fixtures/mockData.ts | 1 + 17 files changed, 1514 insertions(+), 750 deletions(-) diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts index 73beda7..9577f95 100644 --- a/frontend/e2e/tests/drag-create-delete.spec.ts +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -5,10 +5,14 @@ * specs only check rendering (bands, red days, age-4 figures) of state the * API seeded directly. * - * A drag no longer creates a `LeaveWish`/synced period on its own — it opens - * the day popover pre-selected on every dragged date, and saving writes - * exactly those days (ad-hoc `planned_leave_day` rows, no wish). See - * `Calendar.tsx`'s pointerup handler and `handleSavePlannedDay`. + * A drag opens the day popover pre-selected on every dragged date, same as + * before. What that popover does on save changed (todo.md #10/#11): by + * default it now creates a fixed, one-off `LeaveWish` and lets the planner + * fund it (a `calendar-band-wish:` band, not `adhoc:`) — see + * `Calendar.tsx`'s `handleSaveWish`. The old "write exact ad-hoc + * `planned_leave_day` rows" behaviour still exists, behind the modal's + * "Advanced" disclosure (`handleSavePinnedDay`) — the first test below + * opens it explicitly to keep exercising that path. * * The drag deliberately crosses a month boundary (the last two days of one * month into the first day of the next) — a regression guard for the @@ -53,7 +57,7 @@ function targetRange(): { month: Date; dates: [string, string, string] } { return { month, dates } } -test('dragging a range across a month boundary plans every day, and deleting it removes the days for good', async ({ +test('dragging a range and pinning it through Advanced plans every day exactly, and deleting it removes the days for good', async ({ page, }) => { await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson') @@ -75,6 +79,10 @@ test('dragging a range across a month boundary plans every day, and deleting it // A multi-day drag opens the day popover pre-selected on every dragged // date — it does not create a period/wish by itself. await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar') + // The default save path would create a fixed wish instead (see the next + // test) — opening "Advanced" keeps this test on the exact-pinned-days + // path todo.md #10/#11 kept as an escape hatch. + await page.getByTestId('advanced-toggle').click() await page.getByTestId('save-button').click() await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() @@ -104,7 +112,11 @@ test('dragging a range across a month boundary plans every day, and deleting it await expect(page.getByTestId('period-adhoc-notice')).toBeVisible() await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`) - await page.getByTestId('period-delete-button').click() + // todo.md #9 — an ad-hoc band has no recurring plan, so "every year" is + // disabled; "this day"/"this period" are the only meaningful choices, and + // "this period" removes the whole ad-hoc range (today's only behaviour). + await expect(page.getByTestId('period-delete-all-years-button')).toBeDisabled() + await page.getByTestId('period-delete-year-button').click() await expect(page.getByTestId('period-delete-confirm')).toBeVisible() // Regression guard for the "Yes, delete" footer bug: the confirm button's @@ -137,3 +149,47 @@ test('dragging a range across a month boundary plans every day, and deleting it await calendarDayCell(page, dates[1]).click() await expect(page.getByTestId('modal-title')).toHaveText('Planera en dag') }) + +test('a drag saved without opening Advanced creates a planner-funded wish, and the three-way remove deletes it', async ({ + page, +}) => { + // todo.md #10/#11 — the default path no longer writes pinned days + // directly: it creates a fixed, one-off `LeaveWish` and lets the sync + // fund it, so the resulting band is wish-linked (`calendar-band-wish:`), + // not `adhoc:`. + await registerAndReachOnboarding(page, uniqueEmail('drag-wish'), 'Robin Testsson') + await fillChildStep(page, 'Nova') + await continueSoloAndSkipBalances(page) + + const { month, dates } = targetRange() + + await goToCalendarMonth(page, month) + + const wishBand = page.locator('[data-testid^="calendar-band-wish:"]') + await expect(wishBand).toHaveCount(0) + + await dragSelectDates(page, dates) + await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar') + // A solo account has no parent-mode selector to touch — save directly. + await page.getByTestId('save-button').click() + await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() + + await expect(wishBand.first()).toBeVisible() + + // Open the wish-linked band. Its own three-way remove: "this day" cannot + // be expressed for a wish (it would just reappear on the next sync) and + // is disabled; "this period" on a `recurrence: 'once'` wish (there is + // only one occurrence) removes the whole thing, same as "every year". + await wishBand.first().click() + await expect(page.getByTestId('period-popover__content')).toBeVisible() + await expect(page.getByTestId('period-delete-day-button')).toBeDisabled() + + await page.getByTestId('period-delete-year-button').click() + await expect(page.getByTestId('period-delete-confirm')).toBeVisible() + await page.getByTestId('period-delete-confirm-button').click() + await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible() + + await page.reload() + await goToCalendarMonth(page, month) + await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0) +}) diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index 1d6e630..91426e5 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -41,33 +41,47 @@ describe('PeriodPopover', () => { expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument() }) - it('shows the ad-hoc delete label', () => { + it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => { render() - expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar') + expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar') + expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled() + expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled() + expect(screen.getByTestId('period-delete-all-years-button')).toHaveAttribute( + 'title', + 'Den här perioden är inte kopplad till en återkommande plan.', + ) }) it('shows an inline confirmation instead of deleting immediately', () => { render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) expect(mockOnDelete).not.toHaveBeenCalled() expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument() }) - it('calls onDelete after confirming inline', async () => { + it('calls onDelete with the "year" scope after confirming inline', async () => { mockOnDelete.mockResolvedValue(undefined) render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) fireEvent.click(screen.getByTestId('period-delete-confirm-button')) - await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year')) + }) + + it('calls onDelete with the "day" scope for the day button', async () => { + mockOnDelete.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-delete-day-button')) + fireEvent.click(screen.getByTestId('period-delete-confirm-button')) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day')) }) it('does not call onDelete when the inline confirmation is cancelled', () => { render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) fireEvent.click(screen.getByTestId('period-delete-cancel-button')) expect(mockOnDelete).not.toHaveBeenCalled() expect(screen.queryByTestId('period-delete-confirm')).not.toBeInTheDocument() - expect(screen.getByTestId('period-delete-button')).toBeInTheDocument() + expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument() }) }) @@ -223,6 +237,7 @@ describe('PeriodPopover', () => { parentPersonalDataUuid: null, recurrence: 'yearly', lastYear: null, + wantsDubbeldag: false, }) }) }) @@ -280,27 +295,88 @@ describe('PeriodPopover', () => { }) }) + describe('dubbeldagar checkbox', () => { + it('is enabled only when parentMode is "both"', () => { + render() + expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).not.toBeDisabled() + }) + + it('is disabled for a non-"both" parentMode', () => { + render() + expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).toBeDisabled() + }) + + it('sends wantsDubbeldag: true only when checked in "both" mode', async () => { + mockOnSave.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-wants-dubbeldag-checkbox')) + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true }))) + }) + + it('never sends wantsDubbeldag: true outside "both" mode, even if it was checked before switching', async () => { + mockOnSave.mockResolvedValue(undefined) + render() + fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'either' } }) + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: false }))) + }) + }) + + it('disables "this day" and shows why, for a wish-linked band (would just reappear on the next sync)', () => { + render() + expect(screen.getByTestId('period-delete-day-button')).toBeDisabled() + expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute( + 'title', + "Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.", + ) + expect(screen.getByTestId('period-delete-year-button')).not.toBeDisabled() + expect(screen.getByTestId('period-delete-all-years-button')).not.toBeDisabled() + }) + it('displays a generic local error when onDelete rejects with a non-Error value', async () => { mockOnDelete.mockRejectedValue('boom') render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-year-button')) fireEvent.click(screen.getByTestId('period-delete-confirm-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 () => { + it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => { mockOnDelete.mockResolvedValue(undefined) render() - fireEvent.click(screen.getByTestId('period-delete-button')) + fireEvent.click(screen.getByTestId('period-delete-all-years-button')) fireEvent.click(screen.getByTestId('period-delete-confirm-button')) - await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears')) }) - it('shows the wish-linked delete label', () => { + it('calls onDelete with the "year" scope for a wish-linked band', async () => { + mockOnDelete.mockResolvedValue(undefined) render() - expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period') + fireEvent.click(screen.getByTestId('period-delete-year-button')) + fireEvent.click(screen.getByTestId('period-delete-confirm-button')) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year')) + }) + + it('shows the wish-linked "this period" and "every year" labels', () => { + render() + expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden') + expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år') + }) + + it('shows a scope-specific confirmation message for each delete button', () => { + render() + fireEvent.click(screen.getByTestId('period-delete-year-button')) + expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent( + 'Är du säker på att du vill ta bort den här perioden (bara det här året)?', + ) + fireEvent.click(screen.getByTestId('period-delete-cancel-button')) + fireEvent.click(screen.getByTestId('period-delete-all-years-button')) + expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent( + 'Är du säker på att du vill ta bort perioden — alla år?', + ) }) it('displays a local error when onSave rejects', async () => { diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index f11b450..1730dac 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -19,8 +19,20 @@ export interface PeriodPopoverSavePayload { // Meaningful only when `recurrence === 'yearly'`. `null` = "until the // child turns 4" (no explicit end year). lastYear: number | null + // §Decision A (co-leave, opt-in dubbeldagar) — only meaningful/sent when + // `parentMode === 'both'`. See `LeaveWish.wantsDubbeldag` and + // dubbeldagar.md at the repo root. + wantsDubbeldag: boolean } +/** todo.md #9 — the three-way remove. `'day'` removes just the clicked + * occurrence's first date (ad-hoc bands only — see Calendar.tsx's + * `handleDeletePeriod`); `'year'` removes this occurrence/year only (for a + * yearly wish, narrows or splits `firstYear`/`lastYear`; for an ad-hoc band, + * removes the whole contiguous range, same as today); `'allYears'` removes + * every occurrence (today's only wish-branch behaviour, unchanged). */ +export type PeriodDeleteScope = 'day' | 'year' | 'allYears' + export interface PeriodPopoverProps { isOpen: boolean /** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band @@ -35,7 +47,7 @@ export interface PeriodPopoverProps { isSaving?: boolean | undefined error?: string | null | undefined onSave: (payload: PeriodPopoverSavePayload) => Promise - onDelete: () => Promise + onDelete: (scope: PeriodDeleteScope) => Promise onClose: () => void } @@ -47,6 +59,7 @@ interface FormState { parentPersonalDataUuid: string | null recurrence: 'yearly' | 'once' lastYear: number | null + wantsDubbeldag: boolean } /** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py): @@ -89,6 +102,7 @@ function formStateFromWish( parentPersonalDataUuid: wish.parentPersonalDataUuid, recurrence: wish.recurrence, lastYear: wish.lastYear, + wantsDubbeldag: wish.wantsDubbeldag, } } @@ -113,9 +127,10 @@ export function PeriodPopover({ parentPersonalDataUuid: null, recurrence: 'once', lastYear: null, + wantsDubbeldag: false, }) const [localError, setLocalError] = useState(null) - const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) + const [deleteScope, setDeleteScope] = useState(null) useEffect(() => { if (wish) { @@ -129,10 +144,11 @@ export function PeriodPopover({ parentPersonalDataUuid: null, recurrence: 'once', lastYear: null, + wantsDubbeldag: false, }) } setLocalError(null) - setIsConfirmingDelete(false) + setDeleteScope(null) }, [isOpen, wish, startDate, endDate, t]) if (!isOpen) return null @@ -151,31 +167,39 @@ export function PeriodPopover({ parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null, recurrence: form.recurrence, lastYear: form.recurrence === 'yearly' ? form.lastYear : null, + wantsDubbeldag: form.parentMode === 'both' ? form.wantsDubbeldag : false, }) } catch (err) { setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError')) } } - const handleDeleteClick = () => { - setIsConfirmingDelete(true) + const handleDeleteClick = (scope: PeriodDeleteScope) => { + setDeleteScope(scope) } const handleCancelDelete = () => { - setIsConfirmingDelete(false) + setDeleteScope(null) } const handleConfirmDelete = async () => { + if (!deleteScope) return try { setLocalError(null) - await onDelete() + await onDelete(deleteScope) } catch (err) { setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError')) } finally { - setIsConfirmingDelete(false) + setDeleteScope(null) } } + const deleteConfirmMessage: Record = { + day: t('calendar.periodDeleteConfirmDay'), + year: t('calendar.periodDeleteConfirmYear'), + allYears: t('calendar.periodDeleteConfirmAllYears'), + } + const title = wish ? resolveWishName(wish, t) : t('calendar.periodAdHocTitle') return ( @@ -297,6 +321,24 @@ export function PeriodPopover({ +
+ +

+ {form.parentMode === 'both' + ? t('calendar.wantsDubbeldagHint') + : t('calendar.wantsDubbeldagRequiresBothHint')} +

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