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:
2026-09-19 15:53:25 +02:00
parent c6593062e3
commit 6d92b87bf3
43 changed files with 2735 additions and 638 deletions
+227 -95
View File
@@ -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'))
})
})
})
+94 -106
View File
@@ -16,6 +16,7 @@ import type {
CalendarDayEntry,
LeaveWish,
LeaveWishCreateRequest,
LeaveWishScope,
LeaveWishUpdateRequest,
PersonalData,
PlanChange,
@@ -81,8 +82,14 @@ function parseDateParam(searchParams: URLSearchParams): { month: Date; iso: stri
* recreate — used both to "undo" a period delete (recreate the wish) and,
* minus `workspaceId`/`childId`, as the patch that "undoes" a period edit
* (restore every field to its pre-edit value; §Task7 "undo reverts the
* wish edit, not the days"). */
function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
* wish edit, not the days").
*
* A wish row is ONE year now (see `LeaveWish.seriesId`), so this recreates
* exactly that year as a standalone occurrence. `seriesId`, when passed,
* re-attaches it to the series it came from instead — undoing "remove this
* year" has to put the year back as part of the repeating wish, not as a
* detached one-off a later "every year" edit would miss. */
function createRequestFromWish(w: LeaveWish, seriesId?: string | null): LeaveWishCreateRequest {
return {
workspaceId: w.workspaceId,
childId: w.childId,
@@ -94,9 +101,9 @@ function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
windowEndDay: w.windowEndDay,
targetWeeks: w.targetWeeks,
targetDays: w.targetDays ?? null,
recurrence: w.recurrence,
recurrence: 'once',
firstYear: w.firstYear,
lastYear: w.lastYear,
seriesId: seriesId ?? null,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
@@ -105,7 +112,24 @@ function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
}
}
function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
/** The patch that restores one wish row's pre-edit fields, applied at the same
* scope the edit used.
*
* `recurrence` is restored from whether the row belonged to a series, so
* undoing "make this repeat" (or "stop repeating") really undoes it.
* `lastYear` is series-level and only sent where the backend accepts it: when
* the row was standalone (so the patch is what creates the series) or when the
* undo is series-wide. */
function patchFromWish(w: LeaveWish, scope: LeaveWishScope): LeaveWishUpdateRequest {
const seriesWide = w.seriesId === null || scope === 'series'
return {
...patchFieldsFromWish(w),
recurrence: w.seriesId !== null ? 'yearly' : 'once',
...(seriesWide ? { lastYear: w.lastYear } : {}),
}
}
function patchFieldsFromWish(w: LeaveWish): LeaveWishUpdateRequest {
return {
name: w.name,
nameMessageKey: w.nameMessageKey,
@@ -115,9 +139,7 @@ function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
windowEndDay: w.windowEndDay,
targetWeeks: w.targetWeeks,
targetDays: w.targetDays ?? null,
recurrence: w.recurrence,
firstYear: w.firstYear,
lastYear: w.lastYear,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
@@ -676,43 +698,52 @@ export function Calendar(): ReactNode {
const start = splitISODate(payload.startDate)
const end = splitISODate(payload.endDate)
// The popover now prefills from the *clicked* occurrence's real placed
// dates (see PeriodPopover's `formStateFromWish`), so `payload.startDate`
// may carry a later year than the wish's `firstYear` — e.g. clicking a
// 2028 occurrence of a wish first taken in 2026. For a yearly wish, the
// anchor year must stay put: writing `firstYear: 2028` here would
// silently drop the 2026/2027 occurrences, and this popover has no
// "this occurrence only" concept (yet — a later change adds that choice
// explicitly). So only the recurring window's month/day come from the
// edited dates; the anchor year is preserved unless recurrence is being
// set to 'once', where there is exactly one occurrence and its year
// *is* the edited date's year.
const firstYear = payload.recurrence === 'yearly' ? previousWish.firstYear : start.year
// One wish row is one year, so the edited dates ARE this row's window —
// year included. The old shape had to keep the wish's anchor year and
// rewrite the shared month/day pattern for every year, because writing
// the clicked year into `firstYear` would have dropped the earlier
// occurrences; there is nothing to preserve now.
const patch: LeaveWishUpdateRequest = {
name: payload.name,
nameMessageKey: null,
windowStartMonth: start.month,
windowStartDay: start.day,
windowEndMonth: end.month,
windowEndDay: end.day,
firstYear: start.year,
recurrence: payload.recurrence,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
wantsDubbeldag: payload.wantsDubbeldag,
}
// `lastYear` belongs to the whole series, so only send it where the
// backend will take it: when this occurrence is becoming a series, or
// when the user explicitly chose "every year".
const isSeries = previousWish.seriesId !== null
if (payload.recurrence === 'yearly' && (!isSeries || payload.scope === 'series')) {
patch.lastYear = payload.lastYear
}
try {
setIsPeriodSaving(true)
setPeriodError(null)
await wishesHook.update(previousWish.id, {
name: payload.name,
nameMessageKey: null,
windowStartMonth: start.month,
windowStartDay: start.day,
windowEndMonth: end.month,
windowEndDay: end.day,
firstYear,
recurrence: payload.recurrence,
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
wantsDubbeldag: payload.wantsDubbeldag,
})
await wishesHook.update(previousWish.id, patch, payload.scope)
closePeriodPopover()
await runSync(workspace.id, {
watchWishId: previousWish.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.update(previousWish.id, patchFromWish(previousWish))
// Restores this row's own fields at the same scope the edit used.
// A series-wide edit's undo re-projects the siblings from these
// values, which is as faithful as the shape allows: per-year
// divergence the user had before an "every year" save is gone
// once that save lands.
await wishesHook.update(
previousWish.id,
patchFromWish(previousWish, payload.scope),
payload.scope,
)
await runSync(workspace.id, { silent: true })
},
},
@@ -724,19 +755,29 @@ export function Calendar(): ReactNode {
}
}
// §todo9 — the whole-wish delete (unchanged behaviour), factored out so
// both the "every year" scope and the "narrow down to nothing" edges of
// the "this year" scope (below) can fall back to it.
const deleteWholeWish = async (wishId: string, workspaceId: string) => {
// §todo9 "this period, every year" — removes every row of the series (or
// just the one row, when the period does not repeat). Undo recreates the
// repeating wish from the clicked occurrence's fields; the rows get fresh
// ids, exactly as before.
const deleteWishSeries = async (wishId: string, workspaceId: string) => {
const previousWish = wishesHook.data.find((w) => w.id === wishId)
await wishesHook.remove(wishId)
const siblingYears = previousWish?.seriesId
? wishesHook.data.filter((w) => w.seriesId === previousWish.seriesId).map((w) => w.firstYear)
: []
await wishesHook.remove(wishId, 'series')
closePeriodPopover()
await runSync(workspaceId, {
undo: previousWish
? {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.add(createRequestFromWish(previousWish))
const restore = createRequestFromWish(previousWish)
if (siblingYears.length > 0) {
restore.recurrence = 'yearly'
restore.firstYear = Math.min(...siblingYears)
restore.lastYear = previousWish.lastYear ?? Math.max(...siblingYears)
}
await wishesHook.add(restore)
await runSync(workspaceId, { silent: true })
},
}
@@ -776,74 +817,19 @@ export function Calendar(): ReactNode {
}
}
// §todo9 "this period" (this year only) on a recurring wish — the option
// with no existing mechanism. Narrows `firstYear`/`lastYear` to exclude
// just the clicked occurrence's year, or splits the wish in two around a
// middle year, using only fields the wish already has (no backend
// change). Degenerates to the whole-wish delete when narrowing would
// leave no valid year. A `recurrence: 'once'` wish has exactly one
// occurrence, so "this year" and "every year" coincide — handled by the
// same fallback.
const narrowWishByOneYear = async (wish: LeaveWish, clickedYear: number, workspaceId: string) => {
if (wish.recurrence === 'once') {
await deleteWholeWish(wish.id, workspaceId)
return
}
const first = wish.firstYear
const last = wish.lastYear
const finishWithUpdate = async (patch: LeaveWishUpdateRequest) => {
await wishesHook.update(wish.id, patch)
closePeriodPopover()
await runSync(workspaceId, {
watchWishId: wish.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.update(wish.id, patchFromWish(wish))
await runSync(workspaceId, { silent: true })
},
},
})
}
if (clickedYear === first) {
const newFirstYear = first + 1
if (last !== null && newFirstYear > last) {
await deleteWholeWish(wish.id, workspaceId)
return
}
await finishWithUpdate({ firstYear: newFirstYear })
return
}
if (last !== null && clickedYear === last) {
const newLastYear = last - 1
if (newLastYear < first) {
await deleteWholeWish(wish.id, workspaceId)
return
}
await finishWithUpdate({ lastYear: newLastYear })
return
}
// A middle year (strictly between `first` and a defined `last`, or past
// `first` with `last` still open-ended): split into two wishes around
// the excluded year, each keeping the original's other fields.
const created = await wishesHook.add({
...createRequestFromWish(wish),
firstYear: clickedYear + 1,
lastYear: wish.lastYear,
})
await wishesHook.update(wish.id, { lastYear: clickedYear - 1 })
// §todo9 "this period" (this year only). A year of a repeating wish is a
// real row now, so this is one plain delete — no narrowing `firstYear`/
// `lastYear`, and no splitting the wish in two around a middle year.
const deleteWishOccurrence = async (wish: LeaveWish, workspaceId: string) => {
await wishesHook.remove(wish.id, 'occurrence')
closePeriodPopover()
await runSync(workspaceId, {
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.update(wish.id, patchFromWish(wish))
await wishesHook.remove(created.id)
// `seriesId` puts the year back INTO its repeating wish rather than
// beside it, so a later "every year" edit still reaches it.
await wishesHook.add(createRequestFromWish(wish, wish.seriesId))
await runSync(workspaceId, { silent: true })
},
},
@@ -858,7 +844,7 @@ export function Calendar(): ReactNode {
setPeriodError(null)
if (scope === 'allYears') {
if (activeBand.wishId) await deleteWholeWish(activeBand.wishId, workspace.id)
if (activeBand.wishId) await deleteWishSeries(activeBand.wishId, workspace.id)
else closePeriodPopover() // disabled in the UI for ad-hoc bands
return
}
@@ -880,7 +866,7 @@ export function Calendar(): ReactNode {
closePeriodPopover()
return
}
await narrowWishByOneYear(previousWish, splitISODate(activeBand.startDate).year, workspace.id)
await deleteWishOccurrence(previousWish, workspace.id)
} else {
await deleteAdhocRange(activeBand.startDate, activeBand.endDate, activeBand.parentPersonalDataUuid)
}
@@ -919,7 +905,9 @@ export function Calendar(): ReactNode {
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.remove(created.id)
// The preset is a repeating wish, so `created` is only its first
// year — undo has to take the whole series with it.
await wishesHook.remove(created.id, 'series')
await runSync(workspace.id, { silent: true })
},
},