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
@@ -94,8 +94,10 @@ describe('PeriodPopover', () => {
windowEndMonth: 7,
windowEndDay: 26,
firstYear: 2028,
// One year of a repeating wish: the series link is what makes it
// "repeat" now, and what makes the this-year/every-year choice real.
seriesId: 'series-1',
lastYear: null,
recurrence: 'yearly',
parentMode: 'either',
})
@@ -141,7 +143,7 @@ describe('PeriodPopover', () => {
windowEndMonth: 7,
windowEndDay: 26,
firstYear: 2026,
recurrence: 'yearly',
seriesId: 'series-1',
})
render(
<PeriodPopover
@@ -197,8 +199,11 @@ describe('PeriodPopover', () => {
})
it('re-checking "until age 4" hides the last-year input and clears it on save', async () => {
// The end year is series-wide, so the flow starts by widening the scope
// — the control is disabled until then (see the scope tests above).
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
fireEvent.click(screen.getByTestId('period-scope-series'))
fireEvent.click(screen.getByTestId('period-until-age4'))
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('period-save-button'))
@@ -208,12 +213,69 @@ describe('PeriodPopover', () => {
it('editing the last-year input updates the value sent on save', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
fireEvent.click(screen.getByTestId('period-scope-series'))
fireEvent.change(screen.getByTestId('period-last-year-input'), { target: { value: '2032' } })
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2032)
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: 2032 })))
})
// Per-year vs whole-series editing. Only a real choice once the
// occurrence belongs to a series — that is what materialising the years
// into their own rows bought (Easter moves, so one year has to be able to
// move without the rest).
describe('this year vs every year', () => {
it('offers the choice for an occurrence of a repeating wish', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-scope-fieldset')).toBeInTheDocument()
expect(screen.getByTestId('period-scope-occurrence')).toBeChecked()
expect(screen.getByTestId('period-scope-series')).not.toBeChecked()
})
it('does not offer it for a one-off period, where there is nothing to choose between', () => {
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, seriesId: null })} />)
expect(screen.queryByTestId('period-scope-fieldset')).not.toBeInTheDocument()
})
it('hides it again when the user turns repeating off', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-repeat-never'))
expect(screen.queryByTestId('period-scope-fieldset')).not.toBeInTheDocument()
})
it('saves this year only by default', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() =>
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ scope: 'occurrence' })),
)
})
it('saves every year once "Alla år" is picked', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-scope-series'))
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() =>
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ scope: 'series' })),
)
})
it('locks the series end year until the edit is series-wide', () => {
// `lastYear` belongs to the whole series, so the backend rejects a
// change to it under the this-year-only scope. Say so in the UI
// rather than let the user hit a 422.
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
expect(screen.getByTestId('period-last-year-input')).toBeDisabled()
expect(screen.getByTestId('period-last-year-locked-hint')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('period-scope-series'))
expect(screen.getByTestId('period-last-year-input')).toBeEnabled()
expect(screen.queryByTestId('period-last-year-locked-hint')).not.toBeInTheDocument()
})
})
it('toggles to "Never" repeat and hides the yearly options', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-repeat-never'))
@@ -236,6 +298,9 @@ describe('PeriodPopover', () => {
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'yearly',
// Defaults to the narrow scope: an edit must not silently rewrite
// the other years of the series.
scope: 'occurrence',
lastYear: null,
wantsDubbeldag: false,
})
@@ -1,23 +1,28 @@
import { ReactNode, useEffect, useState } from 'react'
import type { LeaveWish } from '@/types'
import type { LeaveWish, LeaveWishScope } from '@/types'
import { useTranslation, type TranslationKey } from '@/i18n'
import { resolveWishName } from '@/utils/wishName'
import './period-popover.css'
export interface PeriodPopoverSavePayload {
name: string
// ISO-8601 dates for the period's first (or only) occurrence. Month/day
// become the wish's recurring anchor; the start date's year becomes
// `firstYear`. See `LeaveWish.window_for_year()` (backend) for how a
// wrapping window (end sorts before start) is inferred from month/day
// alone, not from the actual year gap between these two dates.
// ISO-8601 dates for the clicked occurrence. Month/day plus the start
// date's year ARE that occurrence's window — one wish row is one year (see
// backend/app/entities/leave_wish.py). A wrapping window (end sorts before
// start) is still inferred from month/day alone, not from the actual year
// gap between these two dates.
startDate: string
endDate: string
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
recurrence: 'yearly' | 'once'
// Meaningful only when `recurrence === 'yearly'`. `null` = "until the
// child turns 4" (no explicit end year).
// How far the edit reaches. Only a genuine choice on an occurrence that
// already belongs to a series — `'occurrence'` rewrites just the clicked
// year, `'series'` re-projects every year from it. Making a one-off repeat
// (or a repeat stop) is inherently series-wide whatever this says.
scope: LeaveWishScope
// The series' last year; only sent when the scope makes it meaningful (see
// Calendar.tsx's `handleSavePeriod`). `null` = "until the child turns 4".
lastYear: number | null
// §Decision A (co-leave, opt-in dubbeldagar) — only meaningful/sent when
// `parentMode === 'both'`. See `LeaveWish.wantsDubbeldag` and
@@ -58,10 +63,23 @@ interface FormState {
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
recurrence: 'yearly' | 'once'
scope: LeaveWishScope
lastYear: number | null
wantsDubbeldag: boolean
}
const EMPTY_FORM: FormState = {
name: '',
startDate: '',
endDate: '',
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'once',
scope: 'occurrence',
lastYear: null,
wantsDubbeldag: false,
}
/** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py):
* whether (end_month, end_day) sorts before (start_month, start_day). */
function windowWraps(startMonth: number, startDay: number, endMonth: number, endDay: number): boolean {
@@ -84,14 +102,12 @@ function formStateFromWish(
t: (key: TranslationKey) => string,
): FormState {
// Prefer the clicked band's actual placed dates (the `startDate`/`endDate`
// props — see `PeriodPopoverProps`'s doc comment) over recomputing from
// the wish's static recurring window. `wish.firstYear` + the window only
// describes the *first* occurrence; clicking a later-year occurrence of a
// yearly wish (or any occurrence of a flexible wish, whose placement can
// drift inside its bounding range) needs the real placed dates, not the
// anchor year's. Fall back to the window-derived dates if the caller
// didn't supply real placed dates (defensive — callers are expected to
// always pass them).
// props — see `PeriodPopoverProps`'s doc comment) over the row's stored
// window. The row IS one year, so the two agree for a fixed wish; a
// flexible wish's placement still drifts inside its bounding range, so the
// real placed dates are the ones to show. Fall back to the window-derived
// dates if the caller didn't supply real placed dates (defensive — callers
// are expected to always pass them).
const wraps = windowWraps(wish.windowStartMonth, wish.windowStartDay, wish.windowEndMonth, wish.windowEndDay)
const endYear = wraps ? wish.firstYear + 1 : wish.firstYear
return {
@@ -100,7 +116,12 @@ function formStateFromWish(
endDate: endDate || formatISO(endYear, wish.windowEndMonth, wish.windowEndDay),
parentMode: wish.parentMode,
parentPersonalDataUuid: wish.parentPersonalDataUuid,
recurrence: wish.recurrence,
// "Does this repeat?" is "does it belong to a series?" now — there is no
// `recurrence` field on the wire any more.
recurrence: wish.seriesId !== null ? 'yearly' : 'once',
// Default to the narrow, newly-possible choice: an edit should not
// silently rewrite every other year.
scope: 'occurrence',
lastYear: wish.lastYear,
wantsDubbeldag: wish.wantsDubbeldag,
}
@@ -119,16 +140,7 @@ export function PeriodPopover({
onClose,
}: PeriodPopoverProps): ReactNode {
const { t } = useTranslation()
const [form, setForm] = useState<FormState>({
name: '',
startDate,
endDate,
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'once',
lastYear: null,
wantsDubbeldag: false,
})
const [form, setForm] = useState<FormState>({ ...EMPTY_FORM, startDate, endDate })
const [localError, setLocalError] = useState<string | null>(null)
const [deleteScope, setDeleteScope] = useState<PeriodDeleteScope | null>(null)
@@ -136,16 +148,7 @@ export function PeriodPopover({
if (wish) {
setForm(formStateFromWish(wish, startDate, endDate, t))
} else {
setForm({
name: '',
startDate,
endDate,
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'once',
lastYear: null,
wantsDubbeldag: false,
})
setForm({ ...EMPTY_FORM, startDate, endDate })
}
setLocalError(null)
setDeleteScope(null)
@@ -154,6 +157,15 @@ export function PeriodPopover({
if (!isOpen) return null
const parentSelectValue = form.parentMode === 'specific' ? (form.parentPersonalDataUuid ?? '') : form.parentMode
// The "this year" vs "every year" choice is only real for an occurrence
// that already has siblings to spare (or to change). Turning repeat on or
// off is series-wide by definition, so no choice is offered for it.
const isSeries = wish?.seriesId != null
const showScopeChoice = isSeries && form.recurrence === 'yearly'
// The end year belongs to the whole series, so it can only be moved with
// the "every year" scope — the backend rejects it otherwise rather than
// let one row's mirror drift out of step with its siblings'.
const lastYearLocked = showScopeChoice && form.scope !== 'series'
const handleSave = async () => {
if (!wish) return
@@ -166,6 +178,7 @@ export function PeriodPopover({
parentMode: form.parentMode,
parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null,
recurrence: form.recurrence,
scope: isSeries && form.recurrence === 'yearly' ? form.scope : 'occurrence',
lastYear: form.recurrence === 'yearly' ? form.lastYear : null,
wantsDubbeldag: form.parentMode === 'both' ? form.wantsDubbeldag : false,
})
@@ -365,6 +378,35 @@ export function PeriodPopover({
</label>
</fieldset>
{showScopeChoice && (
<fieldset className="period-popover__fieldset" data-testid="period-scope-fieldset">
<legend className="field__label">{t('calendar.periodEditScopeLabel')}</legend>
<label className="period-popover__radio-label">
<input
type="radio"
name="period-scope"
checked={form.scope === 'occurrence'}
onChange={() => setForm((prev) => ({ ...prev, scope: 'occurrence' }))}
disabled={isSaving}
data-testid="period-scope-occurrence"
/>
{' '}{t('calendar.periodEditScopeYear')}
</label>
<label className="period-popover__radio-label">
<input
type="radio"
name="period-scope"
checked={form.scope === 'series'}
onChange={() => setForm((prev) => ({ ...prev, scope: 'series' }))}
disabled={isSaving}
data-testid="period-scope-series"
/>
{' '}{t('calendar.periodEditScopeSeries')}
</label>
<p className="period-popover__hint">{t('calendar.periodEditScopeHint')}</p>
</fieldset>
)}
{form.recurrence === 'yearly' && (
<div className="field" data-testid="period-yearly-options">
<label className="period-popover__checkbox-label">
@@ -377,11 +419,16 @@ export function PeriodPopover({
lastYear: e.target.checked ? null : parseISO(prev.startDate).year,
}))
}
disabled={isSaving}
disabled={isSaving || lastYearLocked}
data-testid="period-until-age4"
/>
{' '}{t('wishEditor.untilAge4')}
</label>
{lastYearLocked && (
<p className="period-popover__hint" data-testid="period-last-year-locked-hint">
{t('calendar.periodLastYearNeedsSeriesHint')}
</p>
)}
{form.lastYear !== null && (
<label className="field">
@@ -394,7 +441,7 @@ export function PeriodPopover({
onChange={(e) =>
setForm((prev) => ({ ...prev, lastYear: Number(e.target.value) }))
}
disabled={isSaving}
disabled={isSaving || lastYearLocked}
data-testid="period-last-year-input"
className="field__control"
/>