Let a day removed from a period stay removed

Taking one day out of a period the planner placed did nothing that
lasted: the period still asked for a full week, so the next sync put the
day straight back. The option was disabled rather than lying about it.

A removed day is now recorded as its own thing — no leave for this
parent, this child, this date — and the planner honours it on every
later sync. It is kept apart from the working-day overrides, which
answer a different question about the calendar rather than about the
plan.

The day belongs to the parent whose band was clicked, so removing one
parent's day leaves their partner's alone; where both parents were to be
off on one funding arrangement, the partner is simply funded on their
own instead. A removed day shortens what the period is asking for rather
than counting as a day the plan failed to find, so the advice about
closing a shortfall does not start arguing for a day nobody wants.

Nothing is removed without a way back: the toast undoes it, and the day
itself is marked in the calendar and offers to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 16:05:17 +02:00
parent 6d92b87bf3
commit 8cfb9c82c6
39 changed files with 2362 additions and 31 deletions
+144 -3
View File
@@ -13,6 +13,7 @@ import {
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
} from '@/hooks'
import { LanguageProvider } from '@/i18n'
import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal'
@@ -35,6 +36,8 @@ import {
makeRejection,
makePlanProposal,
makePlanApplyResponse,
makePlannedDayExclusionsResult,
makePlannedDayExclusion,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
@@ -61,6 +64,7 @@ vi.mock('@/hooks', () => ({
useLeaveWishes: vi.fn(),
useWorkspaceMembers: vi.fn(),
usePlanSync: vi.fn(),
usePlannedDayExclusions: vi.fn(),
}))
vi.mock('@/components/layout/Layout', () => ({
@@ -149,6 +153,16 @@ vi.mock('@/components/modals/PlannedDayModal', () => ({
delete
</button>
)}
{(props.excludedEntries ?? []).map((entry) => (
<button
key={entry.id}
data-testid={`modal-restore-excluded-${entry.id}`}
data-parent-name={entry.parentName}
onClick={() => void props.onRestoreExcluded?.(entry.id)}
>
restore {entry.parentName}
</button>
))}
<button data-testid="modal-close" onClick={props.onClose}>
close
</button>
@@ -164,6 +178,8 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
data-wish-id={props.wish?.id ?? 'null'}
data-start={props.startDate}
data-end={props.endDate}
data-remove-day-label={props.removeDayLabel ?? ''}
data-remove-day-parent={props.removeDayParentName ?? ''}
data-error={props.error ?? ''}
>
<button
@@ -272,12 +288,14 @@ describe('Calendar', () => {
const mockLeaveWishes = makeLeaveWishesResult()
const mockMembers = makeWorkspaceMembersResult()
const mockSync = makePlanSyncResult()
const mockExclusions = makePlannedDayExclusionsResult()
beforeEach(() => {
vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow)
vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes)
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
vi.mocked(usePlanSync).mockReturnValue(mockSync)
vi.mocked(usePlannedDayExclusions).mockReturnValue(mockExclusions)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
@@ -1947,15 +1965,31 @@ describe('Calendar', () => {
})
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 () => {
// Intent changed (was: '"day" scope is a no-op for a wish-linked band').
// The user asked for it to work: a single day removed from a
// planner-placed period is now recorded as a `PlannedDayExclusion`, which
// is what stops the next sync re-deriving it.
it('"day" scope records an exclusion for a wish-linked band, keyed on the band\'s parent', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const remove = vi.fn().mockResolvedValue(undefined)
const update = vi.fn().mockResolvedValue(wish)
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-1' }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({ exclude }),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
@@ -1963,9 +1997,116 @@ describe('Calendar', () => {
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-day'))
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
await waitFor(() =>
expect(exclude).toHaveBeenCalledWith({
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'person-1',
date: today,
}),
)
// The wish itself is untouched — only this one date goes.
expect(remove).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
})
it('offers an undo that restores the removed day', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-9' }))
const restore = vi.fn().mockResolvedValue(undefined)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({ exclude, restore }),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-day'))
await waitFor(() => expect(screen.getByTestId('calendar-toast-undo')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('calendar-toast-undo'))
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-9'))
})
it('offers the removed day back from the day popover, long after the toast is gone', async () => {
const today = todayDateString()
const restore = vi.fn().mockResolvedValue(undefined)
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
}),
)
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({
restore,
data: [
makePlannedDayExclusion({
id: 'ex-7',
date: today,
parentPersonalDataUuid: 'person-1',
}),
],
}),
)
render(<Calendar />)
const todayCell = screen
.getAllByTestId('calendar-day')
.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
const restoreButton = await screen.findByTestId('modal-restore-excluded-ex-7')
expect(restoreButton).toHaveAttribute('data-parent-name', 'Alex')
fireEvent.click(restoreButton)
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-7'))
})
it('tells the popover which date and which parent "this day" would remove', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
}),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
const popover = screen.getByTestId('period-popover')
expect(popover).toHaveAttribute('data-remove-day-parent', 'Alex')
expect(popover.getAttribute('data-remove-day-label')).toBeTruthy()
})
it('"every year" scope is a no-op for an ad-hoc band (disabled in the UI; nothing recurring to remove)', async () => {