Files
F-r-ldraledigplannerare/frontend/e2e/tests/drag-create-delete.spec.ts
T
jacobh2 782415122d Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:20:14 +02:00

258 lines
13 KiB
TypeScript

/**
* 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 } 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.
*/
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))
while (lastDayOfMonth.getUTCDay() === 0 /* Sunday */) {
month = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1))
lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0))
}
const dates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)].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()
})