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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user