Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one window per year at read time, so no year had anywhere to differ from the others. Editing one year rewrote them all, and removing a year in the middle had to split the wish in two. Neither survives contact with the thing this app is for: Easter moves, and the cheapest week is not the same week every year. Each year is now its own row, linked by a series id. Editing one year touches one row; editing the series re-projects the rest from it. Removing a year is one delete. The weekday snap is worked out once, when the rows are made, and stored, so a row now says what it means rather than being re-derived on every read. Recurrence stops being a stored field and becomes what it always was in practice — an instruction at create and update time; whether a period repeats is whether it belongs to a series. Migration 004 rebuilds the table and expands the existing rows, keeping each original row as its first year so the days already planned against it still point somewhere. A Feb 29 anchor recurring into a common year used to raise out of the allocator; it clamps now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
@@ -176,6 +176,7 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'yearly',
|
||||
scope: 'occurrence',
|
||||
lastYear: null,
|
||||
wantsDubbeldag: false,
|
||||
} satisfies PeriodPopoverSavePayload)
|
||||
@@ -183,6 +184,24 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
|
||||
>
|
||||
save
|
||||
</button>
|
||||
<button
|
||||
data-testid="period-save-series"
|
||||
onClick={() =>
|
||||
void props.onSave({
|
||||
name: 'Edited name',
|
||||
startDate: props.startDate,
|
||||
endDate: props.endDate,
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'yearly',
|
||||
scope: 'series',
|
||||
lastYear: 2031,
|
||||
wantsDubbeldag: false,
|
||||
} satisfies PeriodPopoverSavePayload)
|
||||
}
|
||||
>
|
||||
save (every year)
|
||||
</button>
|
||||
<button
|
||||
data-testid="period-save-once"
|
||||
onClick={() =>
|
||||
@@ -193,6 +212,7 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'once',
|
||||
scope: 'occurrence',
|
||||
lastYear: null,
|
||||
wantsDubbeldag: false,
|
||||
} satisfies PeriodPopoverSavePayload)
|
||||
@@ -725,17 +745,24 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-save'))
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ name: 'Edited name' })))
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'lw-1',
|
||||
expect.objectContaining({ name: 'Edited name' }),
|
||||
'occurrence',
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||||
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saving a later-year occurrence of a yearly wish preserves the original firstYear', async () => {
|
||||
// The wish was first taken in 2026 (the fixture default); the band
|
||||
// clicked here is its 2028 occurrence. Writing `firstYear: 2028` back
|
||||
// would silently drop the 2026/2027 occurrences — see the comment on
|
||||
// `handleSavePeriod` in Calendar.tsx.
|
||||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2026, recurrence: 'yearly' })
|
||||
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
|
||||
// and rewrite the shared month/day pattern for every year, because
|
||||
// writing `firstYear: 2028` would have dropped the earlier occurrences.
|
||||
// There are no "earlier occurrences" on this row to drop any more.
|
||||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
|
||||
@@ -753,15 +780,48 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getByTestId('period-save'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ firstYear: 2026 })),
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'lw-1',
|
||||
expect.objectContaining({ firstYear: 2028 }),
|
||||
'occurrence',
|
||||
),
|
||||
)
|
||||
// `lastYear` is series-level, so a this-year-only edit must not carry it.
|
||||
expect(update.mock.calls[0]![1]).not.toHaveProperty('lastYear')
|
||||
})
|
||||
|
||||
it('saving "every year" widens the scope and carries the series end year', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), 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: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-save-series'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'lw-1',
|
||||
expect.objectContaining({ firstYear: 2028, lastYear: 2031 }),
|
||||
'series',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('switching a later-year occurrence to "once" uses that occurrence\'s year as the single firstYear', async () => {
|
||||
// Unlike the yearly case above, there is exactly one occurrence once
|
||||
// recurrence becomes 'once' — its year is meaningful and must be the
|
||||
// edited/clicked date's year, not the wish's original anchor year.
|
||||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2026, recurrence: 'yearly' })
|
||||
it('switching an occurrence to "once" detaches it from the series at its own year', async () => {
|
||||
// 'once' on a series occurrence means "stop repeating": the clicked
|
||||
// year survives (at its own year, which is what the popover shows) and
|
||||
// the siblings go. That is series-wide by definition, so the popover
|
||||
// sends the narrow scope and the backend still drops the siblings.
|
||||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
|
||||
@@ -778,7 +838,11 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getByTestId('period-save-once'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ firstYear: 2028, recurrence: 'once' })),
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'lw-1',
|
||||
expect.objectContaining({ firstYear: 2028, recurrence: 'once' }),
|
||||
'occurrence',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -799,7 +863,7 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
|
||||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
@@ -848,7 +912,7 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
|
||||
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1208,7 +1272,11 @@ describe('Calendar', () => {
|
||||
fireEvent.click(undoButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenLastCalledWith('lw-1', expect.objectContaining({ name: 'Sommar' })),
|
||||
expect(update).toHaveBeenLastCalledWith(
|
||||
'lw-1',
|
||||
expect.objectContaining({ name: 'Sommar' }),
|
||||
'occurrence',
|
||||
),
|
||||
)
|
||||
// Undo re-syncs (days are derived — reverting the wish and re-syncing
|
||||
// is the correct undo, not touching planned days directly).
|
||||
@@ -1248,7 +1316,8 @@ describe('Calendar', () => {
|
||||
const syncCallsBeforeUndo = sync.mock.calls.length
|
||||
fireEvent.click(undoButton)
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset'))
|
||||
// The preset is a repeating wish, so undo takes the whole series.
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset', 'series'))
|
||||
await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
|
||||
expect(removeDay).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1938,106 +2007,169 @@ describe('Calendar', () => {
|
||||
expect(removeDay).not.toHaveBeenCalledWith('pd-2')
|
||||
})
|
||||
|
||||
describe('"year" scope on a recurring wish — narrows or splits firstYear/lastYear', () => {
|
||||
it('removing the first year of an open-ended wish bumps firstYear forward', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: null })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const remove = vi.fn()
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update, remove }))
|
||||
describe('"year" scope on a recurring wish — one row, one delete', () => {
|
||||
// Each year of a repeating wish is its own row now, so removing one is
|
||||
// a plain delete. The old shape had to narrow `firstYear`/`lastYear` —
|
||||
// and, for a middle year, split the wish into two wishes. Both of those
|
||||
// contortions are gone; the tests that described them are replaced by
|
||||
// the ones below, which assert the behaviour the user asked for
|
||||
// (remove exactly this year, leave the rest alone).
|
||||
const seriesWish = (firstYear: number) =>
|
||||
makeLeaveWish({ id: 'lw-1', seriesId: 'series-1', firstYear, lastYear: 2030 })
|
||||
|
||||
const renderWithBand = (wish: ReturnType<typeof makeLeaveWish>, isoDate: string) => {
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
data: [
|
||||
makePlannedLeaveDay({
|
||||
id: 'pd-1',
|
||||
date: isoDate,
|
||||
wishId: wish.id,
|
||||
parentPersonalDataUuid: 'person-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-06-15'), mockSetSearchParams])
|
||||
|
||||
vi.mocked(useSearchParams).mockReturnValue([
|
||||
new URLSearchParams(`date=${isoDate}`),
|
||||
mockSetSearchParams,
|
||||
])
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
}
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ firstYear: 2027 })))
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removing the last year of a bounded wish decrements lastYear', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: 2029 })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const remove = vi.fn()
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update, remove }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2029-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2029-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ lastYear: 2028 })))
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removing a middle year splits the wish into two, excluding just that year', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: 2030 })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const add = vi.fn().mockResolvedValue(makeLeaveWish({ id: 'lw-1-split' }))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update, add }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
// The original wish keeps 2026-2027; a new wish covers 2029-2030.
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ lastYear: 2027 })))
|
||||
expect(add).toHaveBeenCalledWith(expect.objectContaining({ firstYear: 2029, lastYear: 2030 }))
|
||||
})
|
||||
|
||||
it('removing the only year of a single-year wish deletes it entirely instead of leaving an empty range', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: 2026 })
|
||||
it('removes just the clicked year\'s row, leaving the rest of the series alone', async () => {
|
||||
const wish = seriesWish(2028)
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const update = vi.fn()
|
||||
const add = vi.fn()
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(
|
||||
makeLeaveWishesResult({ data: [wish], remove, update, add }),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-06-15'), mockSetSearchParams])
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
renderWithBand(wish, '2028-06-15')
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
|
||||
// No range-narrowing, and above all no second wish: the split hack is gone.
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('undo puts the removed year back INTO its series, not beside it', async () => {
|
||||
const wish = seriesWish(2028)
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const add = vi.fn().mockResolvedValue(wish)
|
||||
const sync = vi
|
||||
.fn()
|
||||
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
renderWithBand(wish, '2028-06-15')
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||||
fireEvent.click(undoButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ firstYear: 2028, seriesId: 'series-1', recurrence: 'once' }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('removing the first year of a series is the same plain delete', async () => {
|
||||
const wish = seriesWish(2026)
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const update = vi.fn()
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
renderWithBand(wish, '2026-06-15')
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a once-off wish treats "this year" the same as "every year" (there is only one occurrence)', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'once', firstYear: 2026 })
|
||||
const wish = makeLeaveWish({ id: 'lw-1', seriesId: null, firstYear: 2026 })
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
renderWithBand(wish, '2026-06-15')
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
|
||||
})
|
||||
|
||||
it('undo after removing the whole series recreates it as a repeating wish', async () => {
|
||||
const wish = seriesWish(2028)
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const add = vi.fn().mockResolvedValue(wish)
|
||||
const sync = vi
|
||||
.fn()
|
||||
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
renderWithBand(wish, '2028-06-15')
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||||
fireEvent.click(undoButton)
|
||||
|
||||
// Rebuilt as the repeating wish it was, spanning the years the series
|
||||
// covered — not as the single clicked occurrence.
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ recurrence: 'yearly', firstYear: 2028, lastYear: 2030 }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('undo after editing one year of a series restores that row without re-materialising', async () => {
|
||||
const wish = seriesWish(2028)
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const sync = vi
|
||||
.fn()
|
||||
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
renderWithBand(wish, '2028-06-15')
|
||||
fireEvent.click(screen.getByTestId('period-save'))
|
||||
|
||||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||||
fireEvent.click(undoButton)
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledTimes(2))
|
||||
const [, undoPatch, undoScope] = update.mock.calls[1]!
|
||||
expect(undoScope).toBe('occurrence')
|
||||
// The row was already part of a series, so its repeat-ness is restored
|
||||
// but the series-level end year is left alone (the backend rejects it
|
||||
// under this scope).
|
||||
expect(undoPatch).toMatchObject({ recurrence: 'yearly' })
|
||||
expect(undoPatch).not.toHaveProperty('lastYear')
|
||||
})
|
||||
|
||||
it('"every year" removes the whole series in one call', async () => {
|
||||
const wish = seriesWish(2028)
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
renderWithBand(wish, '2028-06-15')
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user