Keep a repeating week on its weekday when the planner picks the dates

A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
2026-09-20 12:04:20 +02:00
parent fdc6439614
commit ad0e3c0b33
19 changed files with 1217 additions and 72 deletions
+98
View File
@@ -770,6 +770,50 @@ describe('Calendar', () => {
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
})
it('saving a period edit pins exact dates: windowMode flips to fixed with a matching target', async () => {
// §bug — a preset-born wish is `windowMode: 'flexible'` (a bounding
// range the planner searches inside, not a placement). Once the user
// has dragged/typed exact dates in the popover, those dates ARE the
// placement, so the save must flip the row to 'fixed' with a target
// matching the edited span. Leaving it 'flexible' turns the (now
// week-long) bound into a single flexible candidate, which repeats the
// same calendar date every year on a drifting weekday instead of
// preserving the weekday.
const wish = makeLeaveWish({ id: 'lw-1', windowMode: 'flexible' })
const update = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-05'), mockSetSearchParams])
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({ id: 'pd-1', date: '2026-10-05', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-2', date: '2026-10-06', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-3', date: '2026-10-07', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-4', date: '2026-10-08', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-5', date: '2026-10-09', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-6', date: '2026-10-10', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-7', date: '2026-10-11', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2026-10-05')
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-end', '2026-10-11')
fireEvent.click(screen.getByTestId('period-save'))
await waitFor(() =>
expect(update).toHaveBeenCalledWith(
'lw-1',
expect.objectContaining({ windowMode: 'fixed', targetDays: 7, targetWeeks: 1 }),
'occurrence',
),
)
})
it('saving one year of a repeating wish writes that year and keeps the scope narrow', async () => {
// The band clicked here is the 2028 row of a series. Its own year IS
// what gets written — the old shape had to keep the wish's anchor year
@@ -1225,6 +1269,60 @@ describe('Calendar', () => {
expect(toast).toHaveTextContent('saknas')
})
it('shows a toast when a flexible window auto-shifted to catch extra red days', async () => {
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [makePlanChange({ newWishId: 'lw-preset' })],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-preset',
shiftGainDays: 2,
data: { wish_name: 'Sommar', wish_name_message_key: null },
}),
],
}),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Sommar')
expect(toast).toHaveTextContent('flyttad')
expect(toast).toHaveTextContent('2')
})
it('does not show the shift toast when the wish with a positive shiftGainDays is not in changes', async () => {
// A plain re-open (nothing actually touched this wish's dates this
// sync) must never re-announce a stable, already-applied shift --
// `changes` membership is what distinguishes the two, not
// `shiftGainDays > 0` alone.
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [],
proposal: makePlanProposal({
outcomes: [makeWindowOutcome({ wishId: 'lw-preset', shiftGainDays: 2 })],
}),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(add).toHaveBeenCalled())
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)