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:
2026-09-19 15:14:24 +02:00
parent 11f2a17b43
commit 35e479a005
17 changed files with 1514 additions and 750 deletions
@@ -41,33 +41,47 @@ describe('PeriodPopover', () => {
expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument()
})
it('shows the ad-hoc delete label', () => {
it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => {
render(<PeriodPopover {...defaultProps} />)
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar')
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar')
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toHaveAttribute(
'title',
'Den här perioden är inte kopplad till en återkommande plan.',
)
})
it('shows an inline confirmation instead of deleting immediately', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument()
})
it('calls onDelete after confirming inline', async () => {
it('calls onDelete with the "year" scope after confirming inline', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
})
it('calls onDelete with the "day" scope for the day button', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
})
it('does not call onDelete when the inline confirmation is cancelled', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.queryByTestId('period-delete-confirm')).not.toBeInTheDocument()
expect(screen.getByTestId('period-delete-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument()
})
})
@@ -223,6 +237,7 @@ describe('PeriodPopover', () => {
parentPersonalDataUuid: null,
recurrence: 'yearly',
lastYear: null,
wantsDubbeldag: false,
})
})
})
@@ -280,27 +295,88 @@ describe('PeriodPopover', () => {
})
})
describe('dubbeldagar checkbox', () => {
it('is enabled only when parentMode is "both"', () => {
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, parentMode: 'both' })} />)
expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).not.toBeDisabled()
})
it('is disabled for a non-"both" parentMode', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).toBeDisabled()
})
it('sends wantsDubbeldag: true only when checked in "both" mode', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, parentMode: 'both' })} />)
fireEvent.click(screen.getByTestId('period-wants-dubbeldag-checkbox'))
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true })))
})
it('never sends wantsDubbeldag: true outside "both" mode, even if it was checked before switching', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, parentMode: 'both', wantsDubbeldag: true })} />)
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'either' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: false })))
})
})
it('disables "this day" and shows why, for a wish-linked band (would just reappear on the next sync)', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-day-button')).toBeDisabled()
expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute(
'title',
"Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.",
)
expect(screen.getByTestId('period-delete-year-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).not.toBeDisabled()
})
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
mockOnDelete.mockRejectedValue('boom')
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
})
})
it('calls onDelete for a wish-linked band after confirmation', async () => {
it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears'))
})
it('shows the wish-linked delete label', () => {
it('calls onDelete with the "year" scope for a wish-linked band', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period')
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
})
it('shows the wish-linked "this period" and "every year" labels', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden')
expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år')
})
it('shows a scope-specific confirmation message for each delete button', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
)
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort perioden — alla år?',
)
})
it('displays a local error when onSave rejects', async () => {