/** * The two authoring gestures the calendar rework is actually about: * dragging a range to create planned days, and deleting them again through * the period popover. Neither was covered end-to-end before — the existing * specs only check rendering (bands, red days, age-4 figures) of state the * API seeded directly. * * 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 (`handleSavePinnedDay`): * a solo household reaches it by picking a concrete type in the modal's * type select (a couple through the "Advanced" disclosure) — the first * test below does so 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 * leading/trailing-week gating bug, where `CalendarWeek.tsx` used to wire * pointer handlers only for cells matching `weekDates[0]`'s month, so a * trailing day belonging to the next month was inert and a drag could never * reach it. * * Uses a brand-new, empty account (see `support/onboarding.ts`) rather than * the shared demo workspace, so the calendar starts with nothing on it. * * The target month is derived from "today" (never a hardcoded year). */ import { test, expect } from '@playwright/test' import { goToCalendarMonth, calendarDayCell, dragSelectDates } from '../support/calendarNav' import { toIsoDate, addDays, isSwedishFreeDay } from '../support/dates' import { uniqueEmail, registerAndReachOnboarding, fillChildStep, continueSoloAndSkipBalances } from '../support/onboarding' test.use({ storageState: { cookies: [], origins: [] } }) /** * A month, at least two months out from today, whose last day is NOT a * Sunday — i.e. the grid actually renders a trailing-week row bleeding into * the following month (a month that ends on Sunday has no trailing row at * all after the "no phantom week" fix; see `Calendar.tsx`'s week-building * loop). Returns that month plus the last two of its days and the first day * of the next month — the exact range the drag below crosses. * * None of the three dates may be a free day (weekend or red day): the * first test below saves them with an explicit `vacation` day type, and * the second lets the planner fund them as a wish — both deliberately drop * free days from what actually gets persisted (see `handleSavePinnedDay` * in `src/pages/Calendar.tsx` and the backend's `PlanningService. * _reject_illegal_free_day`/`is_free_day`). Checking only the month's last * day for a Sunday missed two cases: (1) that day being a Monday instead, * which makes `dates[0]` (the day before it) a Sunday, and (2) a red day * landing on any of the three regardless of weekday (e.g. the month rolls * into a New Year's Day) — either would silently persist/fund fewer than * three days, which the assertions below don't expect. */ function targetRange(): { month: Date; dates: [string, string, string] } { const now = new Date() let month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1)) let lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0)) let candidateDates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)] while (candidateDates.some(isSwedishFreeDay)) { month = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1)) lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0)) candidateDates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)] } const dates = candidateDates.map(toIsoDate) as [string, string, string] return { month, dates } } test('dragging a range and pinning an exact type plans every day exactly, and deleting it removes the days for good', async ({ page, }) => { await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson') await fillChildStep(page, 'Nova') await continueSoloAndSkipBalances(page) const { month, dates } = targetRange() const [firstDate, , lastDate] = dates await goToCalendarMonth(page, month) const adhocBand = page.locator('[data-testid^="calendar-band-adhoc:"]') await expect(adhocBand).toHaveCount(0) // a brand-new account — nothing here yet // The drag spans the month's last two days plus the first day of the // following month, rendered as a trailing cell in this same grid. await dragSelectDates(page, dates) // 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 ("let the planner choose") would create a fixed // wish instead (see the next test) — picking a concrete type keeps this // test on the exact-pinned-days path todo.md #10/#11 kept as an escape // hatch. A solo account has no "Advanced" toggle: the select is right // there. await page.getByTestId('day-type-select').selectOption('vacation') await page.getByTestId('save-button').click() await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() // All three dragged days are one continuous ad-hoc PERIOD, but the grid // renders a multi-week band as one DOM element per week ROW it crosses // (see CalendarWeek.tsx's `computeBandSegments` and // calendar-band.spec.ts) — here Nov 29 (a Sunday, the last column of its // week) and Nov 30 (a Monday, the first column of the next) fall in // different rows even though they're one calendar day apart. Both // elements share the same `data-testid` (derived from the band's key + // overall start date), which IS the "one period" assertion: a real // second period would get its own id. await expect(adhocBand.first()).toBeVisible() await expect .poll(async () => { const testIds = await adhocBand.evaluateAll((els) => els.map((el) => el.getAttribute('data-testid'))) return new Set(testIds).size }) .toBe(1) // Open it through its band — an ad-hoc band (no wishId) gets the full // editor too (§"every calendar band is a real event"), seeded from its own // pinned days; saving it would promote it to a real `LeaveWish`. This test // only exercises delete, so it stops at the ad-hoc notice/seeded dates. // `.first()` because the band may render as more than one DOM segment (see // above); either one opens the same underlying period. await adhocBand.first().click() await expect(page.getByTestId('period-popover__content')).toBeVisible() await expect(page.getByTestId('period-adhoc-notice')).toBeVisible() await expect(page.getByTestId('period-start-input')).toHaveValue(firstDate) await expect(page.getByTestId('period-end-input')).toHaveValue(lastDate) // todo.md #9 — the three delete scopes sit behind "Ta bort…". 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 page.getByTestId('period-delete-menu-button').click() 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 // label is long enough to wrap without `white-space: nowrap` on `.btn` // (src/styles/primitives.css), which used to make it visibly taller than // "Cancel" sitting next to it. Assert both the CSS guard and the rendered // heights actually matching. const cancelButton = page.getByTestId('period-delete-cancel-button') const confirmButton = page.getByTestId('period-delete-confirm-button') await expect(cancelButton).toHaveCSS('white-space', 'nowrap') await expect(confirmButton).toHaveCSS('white-space', 'nowrap') const [cancelBox, confirmBox] = await Promise.all([cancelButton.boundingBox(), confirmButton.boundingBox()]) expect(cancelBox).not.toBeNull() expect(confirmBox).not.toBeNull() expect(confirmBox?.height).toBe(cancelBox?.height) await page.getByTestId('period-delete-confirm-button').click() await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible() // The regression this guards ("Remove a period's days along with the // period"): the underlying planned days must actually be gone // server-side, not merely dropped from client state. A reload refetches // from the API, so this is the assertion a surviving-days bug would fail. await page.reload() await goToCalendarMonth(page, month) await expect(page.locator('[data-testid^="calendar-band-adhoc:"]')).toHaveCount(0) // And the day itself is empty again — clicking it opens the "plan a new // day" modal, not an edit of a day that supposedly no longer exists. 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" now // works on a planner-placed day too (the removal is recorded as a // `DayAssertion` with `dayType: 'none'` — see the next test); "this period" on a // standalone (non-repeating) wish — one row, no series — removes the whole // thing, same as "every year". await wishBand.first().click() await expect(page.getByTestId('period-popover__content')).toBeVisible() await page.getByTestId('period-delete-menu-button').click() await expect(page.getByTestId('period-delete-day-button')).toBeEnabled() 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) }) test('removing a single day out of a planner-placed period keeps it removed across syncs', async ({ page, }) => { // The user's requirement: "removing a single day should stay permanent. // Even if it's an auto added period day." The wish behind the band still // asks for the whole window, so without a stored `DayAssertion` (`dayType: // 'none'`) the next `POST /api/plan/sync` — which the calendar runs on every load — // would simply put the day back. await registerAndReachOnboarding(page, uniqueEmail('remove-day'), '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') await page.getByTestId('save-button').click() await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() await expect(wishBand.first()).toBeVisible() // Clicking the band itself targets its first placed date; clicking one of // its day slots would target that date instead (see CalendarWeek's // `clickedDate`). Either way the popover's "this day" removes exactly one. await wishBand.first().click() await expect(page.getByTestId('period-popover__content')).toBeVisible() await page.getByTestId('period-delete-menu-button').click() await page.getByTestId('period-delete-day-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() // There is a way back, offered immediately. await expect(page.getByTestId('calendar-toast-undo')).toBeVisible() // The removed date is marked rather than silently empty — a hole in an // otherwise-funded week would read as a planner bug. await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1) // The rest of the period survives: this removed one day, not the wish. await expect(wishBand.first()).toBeVisible() // The real assertion: a reload re-syncs the plan from the wish, and the // day must still be gone afterwards. await page.reload() await goToCalendarMonth(page, month) await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1) await expect(page.locator('[data-testid^="calendar-band-wish:"]').first()).toBeVisible() })