8cfb9c82c6
Taking one day out of a period the planner placed did nothing that lasted: the period still asked for a full week, so the next sync put the day straight back. The option was disabled rather than lying about it. A removed day is now recorded as its own thing — no leave for this parent, this child, this date — and the planner honours it on every later sync. It is kept apart from the working-day overrides, which answer a different question about the calendar rather than about the plan. The day belongs to the parent whose band was clicked, so removing one parent's day leaves their partner's alone; where both parents were to be off on one funding arrangement, the partner is simply funded on their own instead. A removed day shortens what the period is asking for rather than counting as a day the plan failed to find, so the advice about closing a shortfall does not start arguing for a day nobody wants. Nothing is removed without a way back: the toast undoes it, and the day itself is marked in the calendar and offers to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
248 lines
12 KiB
TypeScript
248 lines
12 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, 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
|
||
* 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 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')
|
||
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 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()
|
||
|
||
// 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) shows the plain
|
||
// date range and can only be deleted, not edited. `.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-popover__content')).toContainText(`${firstDate} – ${lastDate}`)
|
||
|
||
// 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
|
||
// 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
|
||
// `PlannedDayExclusion` — 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 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 `PlannedDayExclusion` 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-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()
|
||
})
|