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({
+