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:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -17,8 +17,8 @@ const mockWish = {
|
||||
windowEndMonth: 7,
|
||||
windowEndDay: 26,
|
||||
targetWeeks: 6,
|
||||
recurrence: 'yearly' as const,
|
||||
firstYear: 2026,
|
||||
seriesId: null,
|
||||
lastYear: null,
|
||||
priority: 1,
|
||||
parentMode: 'either' as const,
|
||||
@@ -123,7 +123,39 @@ describe('useLeaveWishes', () => {
|
||||
await result.current.update('lw1', { targetWeeks: 8 })
|
||||
})
|
||||
|
||||
expect(api.updateLeaveWish).toHaveBeenCalledWith('lw1', { targetWeeks: 8 })
|
||||
expect(api.updateLeaveWish).toHaveBeenCalledWith('lw1', { targetWeeks: 8 }, 'occurrence')
|
||||
})
|
||||
|
||||
it('update() forwards an explicit series scope', async () => {
|
||||
vi.mocked(api.getLeaveWishes).mockResolvedValue([mockWish])
|
||||
vi.mocked(api.updateLeaveWish).mockResolvedValue({ ...mockWish, name: 'Alla år' })
|
||||
|
||||
const { result } = renderHook(() => useLeaveWishes('ws1'))
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update('lw1', { name: 'Alla år' }, 'series')
|
||||
})
|
||||
|
||||
expect(api.updateLeaveWish).toHaveBeenCalledWith('lw1', { name: 'Alla år' }, 'series')
|
||||
})
|
||||
|
||||
it('remove() forwards an explicit series scope', async () => {
|
||||
vi.mocked(api.getLeaveWishes).mockResolvedValue([mockWish])
|
||||
vi.mocked(api.deleteLeaveWish).mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useLeaveWishes('ws1'))
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.remove('lw1', 'series')
|
||||
})
|
||||
|
||||
expect(api.deleteLeaveWish).toHaveBeenCalledWith('lw1', 'series')
|
||||
})
|
||||
|
||||
it('remove() deletes a wish and refreshes the list', async () => {
|
||||
@@ -139,7 +171,7 @@ describe('useLeaveWishes', () => {
|
||||
await result.current.remove('lw1')
|
||||
})
|
||||
|
||||
expect(api.deleteLeaveWish).toHaveBeenCalledWith('lw1')
|
||||
expect(api.deleteLeaveWish).toHaveBeenCalledWith('lw1', 'occurrence')
|
||||
})
|
||||
|
||||
it('sets isLoading (not isRefreshing) during the initial load', async () => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { LeaveWish, LeaveWishCreateRequest, LeaveWishUpdateRequest } from '@/types'
|
||||
import type {
|
||||
LeaveWish,
|
||||
LeaveWishCreateRequest,
|
||||
LeaveWishScope,
|
||||
LeaveWishUpdateRequest,
|
||||
} from '@/types'
|
||||
|
||||
interface UseLeaveWishesResult {
|
||||
data: LeaveWish[]
|
||||
@@ -16,8 +21,11 @@ interface UseLeaveWishesResult {
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
add: (req: LeaveWishCreateRequest) => Promise<LeaveWish>
|
||||
update: (id: string, req: LeaveWishUpdateRequest) => Promise<LeaveWish>
|
||||
remove: (id: string) => Promise<void>
|
||||
/** `scope` decides how far the edit reaches on a repeating wish: the
|
||||
* clicked year only (default) or every year sharing its `seriesId`. */
|
||||
update: (id: string, req: LeaveWishUpdateRequest, scope?: LeaveWishScope) => Promise<LeaveWish>
|
||||
/** `scope: 'series'` removes every year of a repeating wish. */
|
||||
remove: (id: string, scope?: LeaveWishScope) => Promise<void>
|
||||
}
|
||||
|
||||
/** CRUD over a workspace's recurring leave wishes (`/api/leave-wishes`).
|
||||
@@ -73,8 +81,8 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, req: LeaveWishUpdateRequest) => {
|
||||
const updated = await api.updateLeaveWish(id, req)
|
||||
async (id: string, req: LeaveWishUpdateRequest, scope: LeaveWishScope = 'occurrence') => {
|
||||
const updated = await api.updateLeaveWish(id, req, scope)
|
||||
await fetch({ background: true })
|
||||
return updated
|
||||
},
|
||||
@@ -82,8 +90,8 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.deleteLeaveWish(id)
|
||||
async (id: string, scope: LeaveWishScope = 'occurrence') => {
|
||||
await api.deleteLeaveWish(id, scope)
|
||||
await fetch({ background: true })
|
||||
},
|
||||
[fetch],
|
||||
|
||||
@@ -113,6 +113,16 @@ export const calendar = {
|
||||
'calendar.periodRepeatNever': 'Never',
|
||||
'calendar.periodRepeatYearly': 'Yearly',
|
||||
'calendar.periodLastYearLabel': 'Last year',
|
||||
|
||||
// One year vs the whole series. A repeating period is stored as one row per
|
||||
// year, so "only this year" can finally be honoured — Easter moves, and a
|
||||
// single year has to be able to move without dragging the rest with it.
|
||||
// See backend/app/entities/leave_wish.py.
|
||||
'calendar.periodEditScopeLabel': 'Apply the change to',
|
||||
'calendar.periodEditScopeYear': 'Only this year',
|
||||
'calendar.periodEditScopeSeries': 'Every year',
|
||||
'calendar.periodEditScopeHint': 'Every year shifts the other years to match, keeping the weekdays.',
|
||||
'calendar.periodLastYearNeedsSeriesHint': 'Choose "Every year" to change the last year.',
|
||||
'calendar.periodSaveButton': 'Save period',
|
||||
'calendar.periodSavingButton': 'Saving…',
|
||||
'calendar.periodDeleteButton': 'Delete period',
|
||||
|
||||
@@ -113,6 +113,17 @@ export const calendar = {
|
||||
'calendar.periodRepeatNever': 'Aldrig',
|
||||
'calendar.periodRepeatYearly': 'Varje år',
|
||||
'calendar.periodLastYearLabel': 'Sista året',
|
||||
|
||||
// Per-år vs hela serien. En återkommande period sparas som en rad per år,
|
||||
// så "bara det här året" går att uppfylla på riktigt numera — påsken
|
||||
// flyttar sig, och då ska ett år kunna flyttas utan att dra med sig
|
||||
// resten. Se backend/app/entities/leave_wish.py.
|
||||
'calendar.periodEditScopeLabel': 'Spara ändringen för',
|
||||
'calendar.periodEditScopeYear': 'Bara det här året',
|
||||
'calendar.periodEditScopeSeries': 'Alla år',
|
||||
'calendar.periodEditScopeHint':
|
||||
'Alla år flyttar övriga år lika mycket och behåller veckodagarna.',
|
||||
'calendar.periodLastYearNeedsSeriesHint': 'Välj "Alla år" för att ändra sista året.',
|
||||
'calendar.periodSaveButton': 'Spara period',
|
||||
'calendar.periodSavingButton': 'Sparar…',
|
||||
'calendar.periodDeleteButton': 'Ta bort period',
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
},
|
||||
},
|
||||
|
||||
@@ -197,9 +197,9 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
||||
window_end_month: 7,
|
||||
window_end_day: 26,
|
||||
target_weeks: 6,
|
||||
recurrence: 'yearly',
|
||||
first_year: 2026,
|
||||
last_year: null,
|
||||
series_id: 'series-1',
|
||||
last_year: 2030,
|
||||
priority: 1,
|
||||
parent_mode: 'either',
|
||||
parent_personal_data_uuid: null,
|
||||
@@ -224,7 +224,8 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
||||
expect(result[0]!.windowStartMonth).toBe(6)
|
||||
expect(result[0]!.windowEndDay).toBe(26)
|
||||
expect(result[0]!.targetWeeks).toBe(6)
|
||||
expect(result[0]!.recurrence).toBe('yearly')
|
||||
expect(result[0]!.seriesId).toBe('series-1')
|
||||
expect(result[0]!.lastYear).toBe(2030)
|
||||
expect(result[0]!.parentMode).toBe('either')
|
||||
})
|
||||
})
|
||||
@@ -1065,10 +1066,21 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
|
||||
const result = await api.deleteLeaveWish('lw1')
|
||||
|
||||
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1', method: 'DELETE' })
|
||||
expect(lastRequest()).toEqual({
|
||||
url: '/api/leave-wishes/lw1?scope=occurrence',
|
||||
method: 'DELETE',
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deleteLeaveWish widens to the whole series when asked', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
||||
|
||||
await api.deleteLeaveWish('lw1', 'series')
|
||||
|
||||
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1?scope=series', method: 'DELETE' })
|
||||
})
|
||||
|
||||
it('updateLeaveWish only sends fields present in the payload', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -1096,11 +1108,87 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
|
||||
const result = await api.updateLeaveWish('lw1', { targetWeeks: 8 })
|
||||
|
||||
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1', method: 'PUT' })
|
||||
expect(lastRequest()).toEqual({
|
||||
url: '/api/leave-wishes/lw1?scope=occurrence',
|
||||
method: 'PUT',
|
||||
})
|
||||
expect(lastRequestBody()).toEqual({ target_weeks: 8 })
|
||||
expect(result.targetWeeks).toBe(8)
|
||||
})
|
||||
|
||||
it('updateLeaveWish carries an explicit series scope in the query string', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 'lw1',
|
||||
workspace_id: 'ws1',
|
||||
child_id: null,
|
||||
name: 'Sommar',
|
||||
window_start_month: 6,
|
||||
window_start_day: 15,
|
||||
window_end_month: 7,
|
||||
window_end_day: 26,
|
||||
target_weeks: 8,
|
||||
first_year: 2026,
|
||||
series_id: 'series-1',
|
||||
last_year: 2030,
|
||||
priority: 1,
|
||||
parent_mode: 'either',
|
||||
parent_personal_data_uuid: null,
|
||||
created_at: '2026-01-01',
|
||||
updated_at: '2026-01-01',
|
||||
}),
|
||||
})
|
||||
|
||||
await api.updateLeaveWish('lw1', { targetWeeks: 8 }, 'series')
|
||||
|
||||
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1?scope=series', method: 'PUT' })
|
||||
})
|
||||
|
||||
it('createLeaveWish sends series_id so a removed year can rejoin its series', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 'lw9',
|
||||
workspace_id: 'ws1',
|
||||
child_id: null,
|
||||
name: 'Sommar',
|
||||
window_start_month: 6,
|
||||
window_start_day: 15,
|
||||
window_end_month: 7,
|
||||
window_end_day: 26,
|
||||
target_weeks: 6,
|
||||
first_year: 2028,
|
||||
series_id: 'series-1',
|
||||
last_year: 2030,
|
||||
priority: 1,
|
||||
parent_mode: 'either',
|
||||
parent_personal_data_uuid: null,
|
||||
window_mode: 'fixed',
|
||||
created_at: '2026-01-01',
|
||||
updated_at: '2026-01-01',
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await api.createLeaveWish({
|
||||
workspaceId: 'ws1',
|
||||
name: 'Sommar',
|
||||
windowStartMonth: 6,
|
||||
windowStartDay: 15,
|
||||
windowEndMonth: 7,
|
||||
windowEndDay: 26,
|
||||
targetWeeks: 6,
|
||||
recurrence: 'once',
|
||||
firstYear: 2028,
|
||||
seriesId: 'series-1',
|
||||
})
|
||||
|
||||
expect(lastRequestBody()).toMatchObject({ series_id: 'series-1', recurrence: 'once' })
|
||||
expect(result.seriesId).toBe('series-1')
|
||||
})
|
||||
|
||||
it('updateLeaveWish sends name_message_key: null to explicitly clear it on rename', async () => {
|
||||
// Mirrors what WishEditor sends the moment a user edits a preset wish's
|
||||
// name — the request must carry an explicit `null`, not omit the field
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ApiRequestError } from '@/services/errors'
|
||||
import type {
|
||||
LoginRequest,
|
||||
LeaveWishCreateRequest,
|
||||
LeaveWishScope,
|
||||
LeaveWishUpdateRequest,
|
||||
ManualFetchRequest,
|
||||
ParentalLeaveFetch,
|
||||
@@ -241,9 +242,9 @@ interface RawLeaveWish {
|
||||
window_end_day: number
|
||||
target_weeks: number
|
||||
target_days?: number | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
first_year: number
|
||||
last_year: number | null
|
||||
series_id: string | null
|
||||
priority: number
|
||||
parent_mode: 'either' | 'both' | 'specific'
|
||||
parent_personal_data_uuid: string | null
|
||||
@@ -266,8 +267,8 @@ function toLeaveWish(raw: RawLeaveWish): LeaveWish {
|
||||
windowEndDay: raw.window_end_day,
|
||||
targetWeeks: raw.target_weeks,
|
||||
targetDays: raw.target_days ?? null,
|
||||
recurrence: raw.recurrence,
|
||||
firstYear: raw.first_year,
|
||||
seriesId: raw.series_id ?? null,
|
||||
lastYear: raw.last_year,
|
||||
priority: raw.priority,
|
||||
parentMode: raw.parent_mode,
|
||||
@@ -864,15 +865,20 @@ export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<
|
||||
parent_mode: payload.parentMode ?? 'either',
|
||||
parent_personal_data_uuid: payload.parentPersonalDataUuid ?? null,
|
||||
window_mode: payload.windowMode ?? 'fixed',
|
||||
series_id: payload.seriesId ?? null,
|
||||
wants_dubbeldag: payload.wantsDubbeldag ?? false,
|
||||
}),
|
||||
})) as RawLeaveWish
|
||||
return toLeaveWish(raw)
|
||||
}
|
||||
|
||||
/** `scope` decides how far the edit reaches on a repeating wish — see
|
||||
* `LeaveWishScope`. Omitted means "this year only", matching the backend's
|
||||
* own default, so existing single-occurrence callers need no change. */
|
||||
export async function updateLeaveWish(
|
||||
id: string,
|
||||
payload: LeaveWishUpdateRequest,
|
||||
scope: LeaveWishScope = 'occurrence',
|
||||
): Promise<LeaveWish> {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (payload.childId !== undefined) body.child_id = payload.childId
|
||||
@@ -895,15 +901,20 @@ export async function updateLeaveWish(
|
||||
if (payload.windowMode !== undefined) body.window_mode = payload.windowMode
|
||||
if (payload.wantsDubbeldag !== undefined) body.wants_dubbeldag = payload.wantsDubbeldag
|
||||
|
||||
const raw = (await apiFetch(`/leave-wishes/${id}`, {
|
||||
const raw = (await apiFetch(`/leave-wishes/${id}?scope=${scope}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
})) as RawLeaveWish
|
||||
return toLeaveWish(raw)
|
||||
}
|
||||
|
||||
export async function deleteLeaveWish(id: string): Promise<void> {
|
||||
return apiFetch(`/leave-wishes/${id}`, { method: 'DELETE' })
|
||||
/** `scope: 'series'` removes every year of a repeating wish; the default
|
||||
* removes just this one year's row. */
|
||||
export async function deleteLeaveWish(
|
||||
id: string,
|
||||
scope: LeaveWishScope = 'occurrence',
|
||||
): Promise<void> {
|
||||
return apiFetch(`/leave-wishes/${id}?scope=${scope}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Read-only preview of the planning engine's proposal — persists nothing. */
|
||||
|
||||
@@ -95,8 +95,15 @@ export interface LeaveWishCreateRequest {
|
||||
// whole number of weeks — omitted/`undefined` falls back to
|
||||
// `targetWeeks * 7` server-side. See `LeaveWish.targetDays`.
|
||||
targetDays?: number | null
|
||||
// An instruction, not a stored field: 'yearly' asks the backend to
|
||||
// materialise one row per year (sharing a `seriesId`) from `firstYear`
|
||||
// through `lastYear` — or, when that is omitted, the child's 4th birthday.
|
||||
// 'once' creates a single standalone occurrence. The response is always one
|
||||
// occurrence (the `firstYear` one).
|
||||
recurrence: 'yearly' | 'once'
|
||||
firstYear: number
|
||||
// Series-level end year; ignored when `recurrence` is 'once'. Changing it on
|
||||
// a wish that is already part of a series requires `scope: 'series'`.
|
||||
lastYear?: number | null
|
||||
priority?: number
|
||||
parentMode?: 'either' | 'both' | 'specific'
|
||||
@@ -105,6 +112,12 @@ export interface LeaveWishCreateRequest {
|
||||
// PLANNING_ENGINE_SPEC.md §10.1 — defaults to 'fixed' server-side when omitted.
|
||||
windowMode?: 'fixed' | 'flexible'
|
||||
|
||||
// Re-attach this single occurrence to an EXISTING series (only valid with
|
||||
// `recurrence: 'once'`; the backend 422s otherwise, and 422s on a series id
|
||||
// from another workspace). This is what lets "undo" put a removed year back
|
||||
// as part of its repeating wish instead of as a detached one-off.
|
||||
seriesId?: string | null
|
||||
|
||||
// §Decision A (co-leave, opt-in dubbeldagar) — only meaningful when
|
||||
// parentMode is 'both'; the backend rejects `true` otherwise. Omitted
|
||||
// defaults to `false` server-side. See `LeaveWish.wantsDubbeldag`.
|
||||
@@ -114,6 +127,13 @@ export interface LeaveWishCreateRequest {
|
||||
/** Partial update — only supplied fields are changed. */
|
||||
export type LeaveWishUpdateRequest = Partial<Omit<LeaveWishCreateRequest, 'workspaceId'>>
|
||||
|
||||
/** How far an edit or a delete reaches on a repeating wish.
|
||||
*
|
||||
* `'occurrence'` (the default) touches only the clicked year's row;
|
||||
* `'series'` touches every year sharing its `seriesId`. On a standalone wish
|
||||
* the two are the same thing. Sent as the `?scope=` query parameter. */
|
||||
export type LeaveWishScope = 'occurrence' | 'series'
|
||||
|
||||
// Planned days
|
||||
export interface AddPlannedDayRequest {
|
||||
workspaceId: string
|
||||
|
||||
@@ -57,8 +57,16 @@ export interface LeaveWish {
|
||||
// codebase (predating this field) still type-checks without every call
|
||||
// site needing an update.
|
||||
targetDays?: number | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
// The single year this occurrence's window starts in. A repeating wish is
|
||||
// stored as one row per year (see backend/app/entities/leave_wish.py), so
|
||||
// this is the occurrence's own year, never a pattern anchor.
|
||||
firstYear: number
|
||||
// Non-null when this occurrence is one year of a repeating wish; every year
|
||||
// of that wish shares the id. This replaces the old `recurrence` field —
|
||||
// "does this repeat?" is now `seriesId !== null`.
|
||||
seriesId: string | null
|
||||
// The series' final year, mirrored on every row of a series; `null` means
|
||||
// "until the child turns 4" (and is always `null` on a standalone wish).
|
||||
lastYear: number | null
|
||||
priority: number
|
||||
parentMode: 'either' | 'both' | 'specific'
|
||||
|
||||
Reference in New Issue
Block a user