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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user