782415122d
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
258 lines
13 KiB
TypeScript
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()
|
|
})
|