Link planned days to their wish, fix the age-4 cap, drop the planning goal

Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
This commit is contained in:
2026-09-15 20:28:06 +02:00
parent 7f516f2cab
commit cc3ced905e
59 changed files with 1427 additions and 740 deletions
@@ -1,94 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { PlanningGoalSelector } from './PlanningGoalSelector'
describe('PlanningGoalSelector', () => {
it('renders three planning goal options', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
expect(getByTestId('goal-max-time')).toBeInTheDocument()
expect(getByTestId('goal-max-money')).toBeInTheDocument()
expect(getByTestId('goal-balanced')).toBeInTheDocument()
})
it('renders as radio buttons', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
const radio1 = getByTestId('goal-max-time') as HTMLInputElement
const radio2 = getByTestId('goal-max-money') as HTMLInputElement
expect(radio1.type).toBe('radio')
expect(radio2.type).toBe('radio')
})
it('checks selected value', () => {
const { getByTestId } = render(
<PlanningGoalSelector value="max_money" onChange={() => {}} />
)
const radio = getByTestId('goal-max-money') as HTMLInputElement
expect(radio.checked).toBe(true)
})
it('calls onChange with max_time when selecting max_time option', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<PlanningGoalSelector onChange={onChange} />
)
const radio = getByTestId('goal-max-time')
fireEvent.click(radio)
expect(onChange).toHaveBeenCalledWith('max_time')
})
it('calls onChange with max_money when selecting max_money option', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<PlanningGoalSelector onChange={onChange} />
)
const radio = getByTestId('goal-max-money')
fireEvent.click(radio)
expect(onChange).toHaveBeenCalledWith('max_money')
})
it('calls onChange with balanced when selecting balanced option', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<PlanningGoalSelector onChange={onChange} />
)
const radio = getByTestId('goal-balanced')
fireEvent.click(radio)
expect(onChange).toHaveBeenCalledWith('balanced')
})
it('updates selection when value changes', () => {
const { getByTestId, rerender } = render(
<PlanningGoalSelector value="max_time" onChange={() => {}} />
)
let radio = getByTestId('goal-max-time') as HTMLInputElement
expect(radio.checked).toBe(true)
rerender(<PlanningGoalSelector value="balanced" onChange={() => {}} />)
radio = getByTestId('goal-balanced') as HTMLInputElement
expect(radio.checked).toBe(true)
})
it('only one option can be selected at a time', () => {
const { getByTestId } = render(
<PlanningGoalSelector value="max_time" onChange={() => {}} />
)
const radio1 = getByTestId('goal-max-time') as HTMLInputElement
const radio2 = getByTestId('goal-max-money') as HTMLInputElement
expect(radio1.checked).toBe(true)
expect(radio2.checked).toBe(false)
})
it('has no selection when value is undefined', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
const radio1 = getByTestId('goal-max-time') as HTMLInputElement
const radio2 = getByTestId('goal-max-money') as HTMLInputElement
expect(radio1.checked).toBe(false)
expect(radio2.checked).toBe(false)
})
})
@@ -1,62 +0,0 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
export type PlanningGoal = 'max_time' | 'max_money' | 'balanced'
export interface PlanningGoalSelectorProps {
value?: PlanningGoal
onChange: (goal: PlanningGoal) => void
}
export function PlanningGoalSelector({ value, onChange }: PlanningGoalSelectorProps): ReactNode {
const { t } = useTranslation()
return (
<div data-testid="planning-goal-selector" className="planning-goal-selector">
<fieldset className="planning-goal-selector__fieldset">
<legend className="planning-goal-selector__legend">{t('planningGoalSelector.legend')}</legend>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-max-time"
name="planning-goal"
value="max_time"
checked={value === 'max_time'}
onChange={(e) => onChange(e.target.value as PlanningGoal)}
data-testid="goal-max-time"
/>
<label htmlFor="goal-max-time">{t('planningGoalSelector.maxTime')}</label>
<small>{t('planningGoalSelector.maxTimeHelp')}</small>
</div>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-max-money"
name="planning-goal"
value="max_money"
checked={value === 'max_money'}
onChange={(e) => onChange(e.target.value as PlanningGoal)}
data-testid="goal-max-money"
/>
<label htmlFor="goal-max-money">{t('planningGoalSelector.maxMoney')}</label>
<small>{t('planningGoalSelector.maxMoneyHelp')}</small>
</div>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-balanced"
name="planning-goal"
value="balanced"
checked={value === 'balanced'}
onChange={(e) => onChange(e.target.value as PlanningGoal)}
data-testid="goal-balanced"
/>
<label htmlFor="goal-balanced">{t('planningGoalSelector.balanced')}</label>
<small>{t('planningGoalSelector.balancedHelp')}</small>
</div>
</fieldset>
</div>
)
}
@@ -42,10 +42,6 @@ const TIP_TRANSLATION_KEYS: Record<string, { title: TranslationKey; body: Transl
title: 'tip.stretchTimeAtHome.title',
body: 'tip.stretchTimeAtHome.body',
},
'tips.skip_fp_first_12_months': {
title: 'tip.skipFpFirst12Months.title',
body: 'tip.skipFpFirst12Months.body',
},
'tips.sgi_minimum_after_age_1': {
title: 'tip.sgiMinimumAfterAge1.title',
body: 'tip.sgiMinimumAfterAge1.body',
@@ -20,7 +20,6 @@ const mockWish: LeaveWish = {
priority: 1,
parentMode: 'either',
parentPersonalDataUuid: null,
goalOverride: null,
windowMode: 'fixed',
createdAt: '2026-01-01',
updatedAt: '2026-01-01',