Let the planner fund a hand-drawn period, and remove one at a time
Three changes to calendar editing, too interleaved in the same files to land separately. Giving the same days to the second parent rewrote the first parent's days instead of adding their own. The save looked the existing day up against whoever was logged in rather than the parent being saved for, so parent A's leave turned into parent B's vacation. The parent choice now lives with the lookup, and two parents on the same dates produce two rows and two bands. Drawing a period by hand meant naming a day type up front. It now asks who is off and lets the planner fund it, as a one-off period the sync places, with double days offered when both parents are off and priced honestly in the hint. Pinning an exact day type is still there, moved behind Advanced, and editing a pinned day goes straight back to it. Removing a period offered only the two extremes. There are now three choices: this day, this year's period, or every year. Taking one year out of a repeating period narrows it, or splits it in two when the year is in the middle; where a scope cannot be expressed it is disabled with a reason rather than doing something surprising. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
@@ -89,12 +89,60 @@ vi.mock('@/components/modals/PlannedDayModal', () => ({
|
||||
data-parents-count={props.parents.length}
|
||||
data-is-range-mode={props.isRangeMode ? 'yes' : 'no'}
|
||||
data-day-count={props.dayCount ?? ''}
|
||||
data-selected-parent-id={props.selectedParentId}
|
||||
>
|
||||
{/* §todo10/11 default path — the planner decides funding. */}
|
||||
<button
|
||||
data-testid="modal-save"
|
||||
onClick={() => void props.onSave({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
|
||||
data-testid="modal-save-wish-either"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'either', parentPersonalDataUuid: null, wantsDubbeldag: false })}
|
||||
>
|
||||
save
|
||||
save wish (either)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-wish-both"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: false })}
|
||||
>
|
||||
save wish (both)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-wish-both-dubbeldag"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: true })}
|
||||
>
|
||||
save wish (both, dubbeldag)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-wish-specific"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false })}
|
||||
>
|
||||
save wish (specific)
|
||||
</button>
|
||||
{/* Deliberately sends `wantsDubbeldag: true` alongside `parentMode:
|
||||
'specific'` — the real modal itself disables the checkbox outside
|
||||
'both' mode, but Calendar.tsx's `handleSaveWish` must not trust
|
||||
that alone; it re-gates on `parentMode` before sending the wish. */}
|
||||
<button
|
||||
data-testid="modal-save-wish-specific-dubbeldag-attempt"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: true })}
|
||||
>
|
||||
save wish (specific, dubbeldag attempt)
|
||||
</button>
|
||||
{/* The "Advanced" escape hatch — pins an exact day type for whichever
|
||||
parent `selectedParentId` currently names (Calendar.tsx's lifted
|
||||
`advancedParentId`, updated live via `onParentChange`). */}
|
||||
<button
|
||||
data-testid="modal-save-pinned"
|
||||
onClick={() => void props.onSavePinned({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
|
||||
>
|
||||
save pinned (vacation)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-pinned-sjukpenning"
|
||||
onClick={() => void props.onSavePinned({ dayType: 'sjukpenning', parentId: props.selectedParentId, isDubbeldag: false })}
|
||||
>
|
||||
save pinned (sjukpenning)
|
||||
</button>
|
||||
<button data-testid="modal-change-parent-b" onClick={() => props.onParentChange?.('pd-b')}>
|
||||
switch advanced parent to B
|
||||
</button>
|
||||
{props.onDelete && (
|
||||
<button data-testid="modal-delete" onClick={() => void props.onDelete?.()}>
|
||||
@@ -129,6 +177,7 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'yearly',
|
||||
lastYear: null,
|
||||
wantsDubbeldag: false,
|
||||
} satisfies PeriodPopoverSavePayload)
|
||||
}
|
||||
>
|
||||
@@ -145,13 +194,21 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'once',
|
||||
lastYear: null,
|
||||
wantsDubbeldag: false,
|
||||
} satisfies PeriodPopoverSavePayload)
|
||||
}
|
||||
>
|
||||
save once
|
||||
</button>
|
||||
<button data-testid="period-delete" onClick={() => void props.onDelete()}>
|
||||
delete
|
||||
{/* §todo9 — the three-way remove. */}
|
||||
<button data-testid="period-delete-day" onClick={() => void props.onDelete('day')}>
|
||||
delete (day)
|
||||
</button>
|
||||
<button data-testid="period-delete-year" onClick={() => void props.onDelete('year')}>
|
||||
delete (year)
|
||||
</button>
|
||||
<button data-testid="period-delete-all-years" onClick={() => void props.onDelete('allYears')}>
|
||||
delete (all years)
|
||||
</button>
|
||||
<button data-testid="period-close" onClick={props.onClose}>
|
||||
close
|
||||
@@ -450,7 +507,7 @@ describe('Calendar', () => {
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true })))
|
||||
})
|
||||
@@ -469,7 +526,7 @@ describe('Calendar', () => {
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||||
fireEvent.click(todayCell!)
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith('pd-1', expect.objectContaining({ is_pinned: true })),
|
||||
@@ -484,7 +541,7 @@ describe('Calendar', () => {
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
// The mocked modal doesn't surface `error`, but the save must not
|
||||
// crash and must leave the modal open with the day still selected.
|
||||
@@ -740,7 +797,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||||
@@ -762,7 +819,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||||
fireEvent.click(undoButton)
|
||||
@@ -789,7 +846,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toBeInTheDocument())
|
||||
@@ -810,7 +867,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
|
||||
// Sync was still called once, from the initial auto-sync on mount —
|
||||
@@ -1220,7 +1277,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||||
fireEvent.click(undoButton)
|
||||
@@ -1460,7 +1517,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
|
||||
})
|
||||
@@ -1530,7 +1587,7 @@ describe('Calendar', () => {
|
||||
// requires a re-render for the new hook return value to take effect.
|
||||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
|
||||
rerender(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1585,7 +1642,7 @@ describe('Calendar', () => {
|
||||
|
||||
const modal = await screen.findByTestId('modal')
|
||||
expect(modal).toHaveAttribute('data-day-count', '3')
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
// The pre-existing day is updated (pinned), the other two are added
|
||||
// (also pinned) — never a LeaveWish, never a sync.
|
||||
@@ -1676,4 +1733,312 @@ describe('Calendar', () => {
|
||||
expect(reservedHeights[0]).not.toBe('0px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo.md #14 — two parents cannot both be given the same days', () => {
|
||||
it('saving the pinned/advanced path targets the modal-selected parent, never the logged-in one', async () => {
|
||||
const today = todayDateString()
|
||||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
|
||||
}),
|
||||
)
|
||||
// Parent A already has a pinned sjukpenning day on `today` — this is
|
||||
// exactly the state the bug report described after "parent A takes 2
|
||||
// days on PL".
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [
|
||||
makePlannedLeaveDay({
|
||||
id: 'pd-a-day',
|
||||
date: today,
|
||||
parentPersonalDataUuid: 'person-1',
|
||||
dayType: 'sjukpenning',
|
||||
isPinned: true,
|
||||
}),
|
||||
],
|
||||
add,
|
||||
update,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))!
|
||||
fireEvent.click(todayCell)
|
||||
|
||||
// Opens defaulted to A (the logged-in parent) — A's existing day shows.
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes')
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'person-1')
|
||||
|
||||
// Switch the advanced parent to B — B has nothing here yet.
|
||||
fireEvent.click(screen.getByTestId('modal-change-parent-b'))
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'pd-b')
|
||||
|
||||
// Now select vacation for B and save through the advanced/pinned path.
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
// A's row must never be touched — the old bug called `update` on it.
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parent_personal_data_uuid: 'pd-b', day_type: 'vacation' })))
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deleting through the advanced path removes the currently-selected parent\'s day, not the other parent\'s', async () => {
|
||||
const today = todayDateString()
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [
|
||||
makePlannedLeaveDay({ id: 'pd-a-day', date: today, parentPersonalDataUuid: 'person-1', dayType: 'sjukpenning' }),
|
||||
makePlannedLeaveDay({ id: 'pd-b-day', date: today, parentPersonalDataUuid: 'pd-b', dayType: 'vacation' }),
|
||||
],
|
||||
remove,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))!
|
||||
fireEvent.click(todayCell)
|
||||
fireEvent.click(screen.getByTestId('modal-change-parent-b'))
|
||||
fireEvent.click(screen.getByTestId('modal-delete'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-b-day'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo.md #10/#11 — the manual-add modal defaults to a planner-funded wish', () => {
|
||||
it('a click/drag by default creates a fixed, once-off wish (not a pinned day) for the chosen parent mode', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const addDay = vi.fn()
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add: addDay }))
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||||
fireEvent.click(currentMonthDay)
|
||||
fireEvent.click(screen.getByTestId('modal-save-wish-both'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ windowMode: 'fixed', recurrence: 'once', parentMode: 'both', wantsDubbeldag: false }),
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||||
// The default path never touches planned days directly.
|
||||
expect(addDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards wantsDubbeldag only when the wish targets both parents', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||||
fireEvent.click(currentMonthDay)
|
||||
fireEvent.click(screen.getByTestId('modal-save-wish-both-dubbeldag'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true })))
|
||||
})
|
||||
|
||||
it('never sends wantsDubbeldag for a specific-parent wish, even if the modal tried to', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||||
fireEvent.click(currentMonthDay)
|
||||
fireEvent.click(screen.getByTestId('modal-save-wish-specific-dubbeldag-attempt'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false }),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo.md #9 — the three-way remove', () => {
|
||||
it('"day" scope is a no-op for a wish-linked band (disabled in the UI; would just reappear on the next sync)', async () => {
|
||||
const today = todayDateString()
|
||||
const wish = makeLeaveWish({ id: 'lw-1' })
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-day'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"every year" scope is a no-op for an ad-hoc band (disabled in the UI; nothing recurring to remove)', async () => {
|
||||
const today = todayDateString()
|
||||
const removeDay = vi.fn()
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
|
||||
remove: removeDay,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
|
||||
expect(removeDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"day" scope on an ad-hoc band removes only the clicked occurrence\'s first date', async () => {
|
||||
const removeDay = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [
|
||||
makePlannedLeaveDay({ id: 'pd-1', date: '2027-03-10', wishId: null, parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-2', date: '2027-03-11', wishId: null, parentPersonalDataUuid: 'person-1' }),
|
||||
],
|
||||
remove: removeDay,
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2027-03-10'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-day'))
|
||||
|
||||
await waitFor(() => expect(removeDay).toHaveBeenCalledWith('pd-1'))
|
||||
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 }))
|
||||
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]!)
|
||||
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 })
|
||||
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]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
})
|
||||
|
||||
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 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]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+254
-62
@@ -26,7 +26,7 @@ import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek'
|
||||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
|
||||
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
|
||||
import { getColorForType } from '@/utils/leaveTypes'
|
||||
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
|
||||
@@ -101,6 +101,7 @@ function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
windowMode: w.windowMode,
|
||||
wantsDubbeldag: w.wantsDubbeldag,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
windowMode: w.windowMode,
|
||||
wantsDubbeldag: w.wantsDubbeldag,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +253,14 @@ export function Calendar(): ReactNode {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
// §todo14 — which parent the day popover's "Advanced" (pin exactly) path
|
||||
// targets. Lifted here (rather than kept as PlannedDayModal-local state)
|
||||
// so `existingDay`/save/delete all resolve against the SAME parent the
|
||||
// modal is currently showing — see `getExistingDay`'s doc comment for the
|
||||
// bug this fixes. Reset to the logged-in parent whenever a fresh
|
||||
// date/range is selected; updated live via the modal's `onParentChange`
|
||||
// while it stays open.
|
||||
const [advancedParentId, setAdvancedParentId] = useState(personalDataUuid ?? '')
|
||||
|
||||
const [activeBand, setActiveBand] = useState<BandClickInfo | null>(null)
|
||||
const [isPeriodOpen, setIsPeriodOpen] = useState(false)
|
||||
@@ -364,9 +374,12 @@ export function Calendar(): ReactNode {
|
||||
if (dates.length > 0) {
|
||||
// §Task7 revision — a drag (single- or multi-day) always opens the
|
||||
// day popover on the exact dragged dates; it never creates a
|
||||
// LeaveWish/synced period on its own. The user decides the leave
|
||||
// type/parent for every one of those days in the popover.
|
||||
// LeaveWish/synced period on its own by itself — the default path
|
||||
// now creates a fixed one-off wish and lets the planner fund it
|
||||
// (§todo10/11); the "Advanced" disclosure still writes exact
|
||||
// pinned days, same as before.
|
||||
setSelectedDates(new Set(dates))
|
||||
setAdvancedParentId(personalDataUuid ?? '')
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
}
|
||||
@@ -379,7 +392,7 @@ export function Calendar(): ReactNode {
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [isDragging, dragAnchor, dragCurrent])
|
||||
}, [isDragging, dragAnchor, dragCurrent, personalDataUuid])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('calendar.loading')} />
|
||||
@@ -489,21 +502,31 @@ export function Calendar(): ReactNode {
|
||||
|
||||
const handleDayClick = (date: string) => {
|
||||
setSelectedDates(new Set([date]))
|
||||
setAdvancedParentId(personalDataUuid ?? '')
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
|
||||
// §todo14 fix — takes the parent explicitly rather than assuming the
|
||||
// logged-in one. The old version always resolved against
|
||||
// `personalDataUuid`, so saving parent B's day on a date where parent A
|
||||
// already had one found (and silently overwrote) A's row instead of
|
||||
// creating B's. Every caller below now passes the parent the save/delete
|
||||
// is actually FOR — the modal-selected one, never the logged-in default.
|
||||
const getExistingDay = (date: string, parentId: string): PlannedLeaveDay | undefined => {
|
||||
return (plannedDaysHook.data ?? []).find(
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === parentId,
|
||||
)
|
||||
}
|
||||
|
||||
const handleSavePlannedDay = async (payload: {
|
||||
// The "Advanced" escape hatch — pins an exact day type for an exact
|
||||
// parent, writing `planned_leave_day` rows directly (today's pre-existing
|
||||
// behaviour, unchanged except for the §todo14 lookup fix above).
|
||||
const handleSavePinnedDay = async (payload: {
|
||||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
parentId: string
|
||||
isDubbeldag: boolean
|
||||
}) => {
|
||||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id || !personalDataUuid) return
|
||||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
@@ -512,9 +535,7 @@ export function Calendar(): ReactNode {
|
||||
setLastLeaveType(payload.dayType)
|
||||
|
||||
for (const date of selectedDates) {
|
||||
const existingDay = (plannedDaysHook.data ?? []).find(
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
|
||||
)
|
||||
const existingDay = getExistingDay(date, payload.parentId)
|
||||
|
||||
if (existingDay) {
|
||||
// §Task7 — a hand-edit through the day popover pins the day so a
|
||||
@@ -546,6 +567,70 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
// §todo10/11 default path — the planner decides funding. Builds a fixed,
|
||||
// one-off `LeaveWish` spanning every selected date and lets the existing
|
||||
// sync machinery place it, exactly like `handleSelectPreset` below (same
|
||||
// add-then-sync-then-undo shape).
|
||||
const handleSaveWish = async (payload: {
|
||||
parentMode: 'either' | 'both' | 'specific'
|
||||
parentPersonalDataUuid: string | null
|
||||
wantsDubbeldag: boolean
|
||||
}) => {
|
||||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
|
||||
|
||||
const orderedDates = Array.from(selectedDates).sort()
|
||||
const first = orderedDates[0]
|
||||
const last = orderedDates[orderedDates.length - 1]
|
||||
if (!first || !last) return
|
||||
const start = splitISODate(first)
|
||||
const end = splitISODate(last)
|
||||
const dayCount = orderedDates.length
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setModalError(null)
|
||||
|
||||
const created = await wishesHook.add({
|
||||
workspaceId: workspace.id,
|
||||
childId: selectedChildId,
|
||||
name: t('wishEditor.manualPeriodName'),
|
||||
nameMessageKey: 'wishEditor.manualPeriodName',
|
||||
windowStartMonth: start.month,
|
||||
windowStartDay: start.day,
|
||||
windowEndMonth: end.month,
|
||||
windowEndDay: end.day,
|
||||
targetWeeks: Math.max(1, Math.ceil(dayCount / 7)),
|
||||
targetDays: dayCount,
|
||||
recurrence: 'once',
|
||||
firstYear: start.year,
|
||||
priority: wishesHook.data.length + 1,
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
windowMode: 'fixed',
|
||||
wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false,
|
||||
})
|
||||
|
||||
setIsModalOpen(false)
|
||||
setSelectedDates(new Set())
|
||||
|
||||
await runSync(workspace.id, {
|
||||
watchWishId: created.id,
|
||||
watchWish: created,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.remove(created.id)
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
setModalError(err instanceof Error ? err.message : t('calendar.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlannedDay = async () => {
|
||||
if (selectedDates.size !== 1) return
|
||||
|
||||
@@ -557,7 +642,7 @@ export function Calendar(): ReactNode {
|
||||
const date = dates[0]
|
||||
if (!date) return
|
||||
|
||||
const existingDay = getExistingDay(date)
|
||||
const existingDay = getExistingDay(date, advancedParentId)
|
||||
if (existingDay) {
|
||||
await plannedDaysHook.remove(existingDay.id)
|
||||
}
|
||||
@@ -619,6 +704,7 @@ export function Calendar(): ReactNode {
|
||||
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
wantsDubbeldag: payload.wantsDubbeldag,
|
||||
})
|
||||
closePeriodPopover()
|
||||
await runSync(workspace.id, {
|
||||
@@ -638,61 +724,165 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePeriod = async () => {
|
||||
// §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) => {
|
||||
const previousWish = wishesHook.data.find((w) => w.id === wishId)
|
||||
await wishesHook.remove(wishId)
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
undo: previousWish
|
||||
? {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.add(createRequestFromWish(previousWish))
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
// §todo9 — the ad-hoc "this period" delete (unchanged behaviour, and also
|
||||
// what ad-hoc's "this day" falls back to for a single-day band).
|
||||
const deleteAdhocRange = async (start: string, end: string, parentUuid: string) => {
|
||||
const bandDates = new Set(datesInRange(start, end))
|
||||
const toDelete = (plannedDaysHook.data ?? []).filter(
|
||||
(pd) => pd.parentPersonalDataUuid === parentUuid && pd.wishId === null && bandDates.has(pd.date),
|
||||
)
|
||||
const snapshot = toDelete.map((pd) => ({ ...pd }))
|
||||
for (const pd of toDelete) {
|
||||
await plannedDaysHook.remove(pd.id)
|
||||
}
|
||||
closePeriodPopover()
|
||||
if (snapshot.length > 0) {
|
||||
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
for (const pd of snapshot) {
|
||||
await plannedDaysHook.add({
|
||||
workspace_id: pd.workspaceId,
|
||||
child_id: pd.childId,
|
||||
parent_personal_data_uuid: pd.parentPersonalDataUuid,
|
||||
date: pd.date,
|
||||
day_type: pd.dayType,
|
||||
is_dubbeldag: pd.isDubbeldag,
|
||||
is_pinned: pd.isPinned,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// §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 })
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.update(wish.id, patchFromWish(wish))
|
||||
await wishesHook.remove(created.id)
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePeriod = async (scope: PeriodDeleteScope) => {
|
||||
if (!workspace?.id || !activeBand) return
|
||||
|
||||
try {
|
||||
setIsPeriodSaving(true)
|
||||
setPeriodError(null)
|
||||
|
||||
if (scope === 'allYears') {
|
||||
if (activeBand.wishId) await deleteWholeWish(activeBand.wishId, workspace.id)
|
||||
else closePeriodPopover() // disabled in the UI for ad-hoc bands
|
||||
return
|
||||
}
|
||||
|
||||
if (scope === 'day') {
|
||||
if (activeBand.wishId) {
|
||||
// Disabled in the UI (would just reappear on the next sync).
|
||||
closePeriodPopover()
|
||||
return
|
||||
}
|
||||
await deleteAdhocRange(activeBand.startDate, activeBand.startDate, activeBand.parentPersonalDataUuid)
|
||||
return
|
||||
}
|
||||
|
||||
// scope === 'year'
|
||||
if (activeBand.wishId) {
|
||||
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
|
||||
await wishesHook.remove(activeBand.wishId)
|
||||
closePeriodPopover()
|
||||
await runSync(workspace.id, {
|
||||
undo: previousWish
|
||||
? {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.add(createRequestFromWish(previousWish))
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
}
|
||||
: null,
|
||||
})
|
||||
if (!previousWish) {
|
||||
closePeriodPopover()
|
||||
return
|
||||
}
|
||||
await narrowWishByOneYear(previousWish, splitISODate(activeBand.startDate).year, workspace.id)
|
||||
} else {
|
||||
// Ad-hoc band (no wishId): delete the underlying planned days
|
||||
// directly — there is no wish to resync from.
|
||||
const bandDates = new Set(datesInRange(activeBand.startDate, activeBand.endDate))
|
||||
const toDelete = (plannedDaysHook.data ?? []).filter(
|
||||
(pd) =>
|
||||
pd.parentPersonalDataUuid === activeBand.parentPersonalDataUuid &&
|
||||
pd.wishId === null &&
|
||||
bandDates.has(pd.date),
|
||||
)
|
||||
const snapshot = toDelete.map((pd) => ({ ...pd }))
|
||||
for (const pd of toDelete) {
|
||||
await plannedDaysHook.remove(pd.id)
|
||||
}
|
||||
closePeriodPopover()
|
||||
if (snapshot.length > 0) {
|
||||
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
for (const pd of snapshot) {
|
||||
await plannedDaysHook.add({
|
||||
workspace_id: pd.workspaceId,
|
||||
child_id: pd.childId,
|
||||
parent_personal_data_uuid: pd.parentPersonalDataUuid,
|
||||
date: pd.date,
|
||||
day_type: pd.dayType,
|
||||
is_dubbeldag: pd.isDubbeldag,
|
||||
is_pinned: pd.isPinned,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
await deleteAdhocRange(activeBand.startDate, activeBand.endDate, activeBand.parentPersonalDataUuid)
|
||||
}
|
||||
} catch (err) {
|
||||
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
@@ -971,11 +1161,13 @@ export function Calendar(): ReactNode {
|
||||
<PlannedDayModal
|
||||
date={firstSelectedDate}
|
||||
isOpen={isModalOpen}
|
||||
existingDay={getExistingDay(firstSelectedDate) ?? null}
|
||||
existingDay={getExistingDay(firstSelectedDate, advancedParentId) ?? null}
|
||||
parents={parentsForModals}
|
||||
selectedParentId={personalDataUuid ?? ''}
|
||||
onSave={handleSavePlannedDay}
|
||||
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
|
||||
selectedParentId={advancedParentId}
|
||||
onParentChange={setAdvancedParentId}
|
||||
onSaveWish={handleSaveWish}
|
||||
onSavePinned={handleSavePinnedDay}
|
||||
onDelete={getExistingDay(firstSelectedDate, advancedParentId) ? handleDeletePlannedDay : undefined}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setSelectedDates(new Set())
|
||||
|
||||
Reference in New Issue
Block a user