Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes

Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
+247 -158
View File
@@ -1,173 +1,235 @@
import { ReactNode, useState, useEffect } from 'react'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { useWorkspace } from '@/context'
import { useVacationDays, useBreakPeriods, useShowWeekNumbers } from '@/hooks'
import { useVacationDays, useShowWeekNumbers, useChildSettings, useDayOverrides, useCalendarDays } from '@/hooks'
import * as api from '@/services/api'
import { useTranslation, type Locale } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { ParentProfileInput } from '@/components/inputs/ParentProfileInput'
import { PlanningGoalSelector } from '@/components/inputs/PlanningGoalSelector'
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
import { PlanningGoalSelector, type PlanningGoal } from '@/components/inputs/PlanningGoalSelector'
import { PlanningModeSelector, type PlanningMode } from '@/components/inputs/PlanningModeSelector'
import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
import { BreakPeriodsInput } from '@/components/inputs/BreakPeriodsInput'
import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable'
import { DayOverridesInput } from '@/components/inputs/DayOverridesInput'
import { RedDaysList } from '@/components/calendar/RedDaysList'
const RED_DAYS_WINDOW_YEARS = 4
const DEFAULT_VACATION_DAYS = 25
function isSalaryType(value: string): value is SalaryType {
return value === 'monthly' || value === 'hourly'
}
function isEmploymentStatus(value: string): value is EmploymentStatus {
return value === 'employed' || value === 'jobseeking' || value === 'none'
}
/** Current year through (at minimum) the year the child turns 4. */
function computeVacationYearRange(birthDate: string | undefined, currentYear: number): number[] {
let endYear = currentYear
if (birthDate) {
const fourthBirthday = new Date(birthDate)
if (!isNaN(fourthBirthday.getTime())) {
fourthBirthday.setFullYear(fourthBirthday.getFullYear() + 4)
endYear = Math.max(endYear, fourthBirthday.getFullYear())
}
}
const years: number[] = []
for (let year = currentYear; year <= endYear; year++) {
years.push(year)
}
return years
}
export function Settings(): ReactNode {
const { t, explicitLocale, setLocale } = useTranslation()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
const { data: vacDays, isLoading: vacLoading, upsert } = useVacationDays(workspace?.id ?? '')
const { data: breakPeriods, isLoading: bpLoading, add: addBreakPeriod, remove: removeBreakPeriod } = useBreakPeriods(
workspace?.id ?? null
)
const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '')
const {
data: childDetails,
isLoading: childDetailsLoading,
update: updateChildDetails,
} = useChildSettings(selectedChildId)
const {
data: dayOverrides,
isLoading: dayOverridesLoading,
upsert: upsertDayOverride,
remove: removeDayOverride,
} = useDayOverrides(workspace?.id ?? '')
const [showWeekNumbers, setShowWeekNumbers] = useShowWeekNumbers()
const [redDaysStartYear, setRedDaysStartYear] = useState(() => new Date().getFullYear())
const {
data: calendarWindow,
isLoading: calendarLoading,
error: calendarError,
} = useCalendarDays(workspace?.id ?? '', redDaysStartYear, RED_DAYS_WINDOW_YEARS)
const [childBirthDate, setChildBirthDate] = useState('')
const [parentData, setParentData] = useState({ salaryType: '', employmentStatus: '' })
const [vacationDaysValue, setVacationDaysValue] = useState(vacDays?.[0]?.totalDays ?? 25)
const [planningGoal, setPlanningGoal] = useState<'max_time' | 'max_money' | 'balanced'>(
(workspace?.planningGoal as 'max_time' | 'max_money' | 'balanced') ?? 'balanced',
)
const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6)
const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({})
const [planningGoal, setPlanningGoal] = useState<PlanningGoal>(workspace?.planningGoal ?? 'balanced')
const [planningMode, setPlanningMode] = useState<PlanningMode>(workspace?.planningMode ?? 'solo')
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [newPartnerName, setNewPartnerName] = useState('')
const isLoading = wsLoading || vacLoading || bpLoading
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
useEffect(() => {
if (vacDays?.[0]?.totalDays) {
setVacationDaysValue(vacDays[0].totalDays)
if (childDetails) {
setChildBirthDate(childDetails.birthDate)
setPreschoolPlanningDays(childDetails.preschoolPlanningDaysPerYear)
}
}, [vacDays])
}, [childDetails])
useEffect(() => {
if (workspace?.planningGoal) {
setPlanningGoal(workspace.planningGoal)
}
}, [workspace?.planningGoal])
if (isLoading) {
return <LoadingSpinner message="Loading settings..." />
}
if (workspace?.planningMode) {
setPlanningMode(workspace.planningMode)
}
}, [workspace?.planningGoal, workspace?.planningMode])
const selectedChild = children.find((c) => c.id === selectedChildId)
const handleSaveVacationDays = async () => {
const currentYear = new Date().getFullYear()
const vacationYears = useMemo(
() => computeVacationYearRange(selectedChild?.birth_date, currentYear),
[selectedChild?.birth_date, currentYear],
)
const vacationRows: VacationDaysTableRow[] = useMemo(
() =>
vacationYears.map((year) => {
const existing = vacDays.find((v) => v.year === year)
return {
year,
totalDays: existing?.totalDays ?? DEFAULT_VACATION_DAYS,
savedDaysIn: existing?.savedDaysIn ?? 0,
// §3.2 — no vacation_days row for this year yet: the statutory
// default is shown, not an entered value. Mirrors the backend's
// `VacationBalanceResponse.is_assumed` for the propose-time table.
isAssumed: existing === undefined,
}
}),
[vacationYears, vacDays],
)
if (isLoading) {
return <LoadingSpinner message={t('settings.loading')} />
}
const handleLanguageChange = (value: string) => {
setLocale(value === 'auto' ? null : (value as Locale))
}
const showMessage = (type: 'success' | 'error', text: string) => {
setSaveMessage({ type, text })
setTimeout(() => setSaveMessage(null), 3000)
}
const handleSaveChild = async () => {
try {
if (workspace?.id && personalDataUuid) {
if (selectedChildId && childBirthDate) {
setIsSaving(true)
await upsert({
personalDataUuid,
workspaceId: workspace.id,
year: new Date().getFullYear(),
totalDays: vacationDaysValue,
await updateChildDetails({
birthDate: childBirthDate,
preschoolPlanningDaysPerYear: preschoolPlanningDays,
})
setSaveMessage({ type: 'success', text: 'Vacation days saved!' })
setTimeout(() => setSaveMessage(null), 3000)
showMessage('success', t('settings.saveChildSuccess'))
}
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to save vacation days',
})
showMessage('error', err instanceof Error ? err.message : t('settings.saveChildError'))
} finally {
setIsSaving(false)
}
}
const handleParentChange = (field: string, val: string) => {
setParentData((prev) => {
const next = { ...prev }
if (field === 'salaryType') {
if (isSalaryType(val)) {
next.salaryType = val
} else {
delete next.salaryType
}
} else if (field === 'employmentStatus') {
if (isEmploymentStatus(val)) {
next.employmentStatus = val
} else {
delete next.employmentStatus
}
}
return next
})
}
const handleSaveParentProfile = async () => {
try {
if (personalDataUuid) {
setIsSaving(true)
await api.updateParentSettings(personalDataUuid, {
salary_type: parentData.salaryType || undefined,
employment_status: parentData.employmentStatus || undefined,
salary_type: parentData.salaryType,
employment_status: parentData.employmentStatus,
})
setSaveMessage({ type: 'success', text: 'Parent profile saved!' })
setTimeout(() => setSaveMessage(null), 3000)
showMessage('success', t('settings.saveProfileSuccess'))
}
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to save parent profile',
})
showMessage('error', err instanceof Error ? err.message : t('settings.saveProfileError'))
} finally {
setIsSaving(false)
}
}
const handleSavePlanningGoal = async () => {
const handleSavePlanning = async () => {
try {
if (workspace?.id) {
setIsSaving(true)
await api.updateWorkspace(workspace.id, {
planningGoal: planningGoal as 'max_time' | 'max_money' | 'balanced',
})
await api.updateWorkspace(workspace.id, { planningGoal, planningMode })
await refreshContext()
setSaveMessage({ type: 'success', text: 'Planning goal saved!' })
setTimeout(() => setSaveMessage(null), 3000)
showMessage('success', t('settings.savePlanningSuccess'))
}
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to save planning goal',
})
showMessage('error', err instanceof Error ? err.message : t('settings.savePlanningError'))
} finally {
setIsSaving(false)
}
}
const handleSaveChildBirthDate = async () => {
const handleSaveVacationYear = async (year: number, totalDays: number, savedDaysIn: number) => {
try {
if (selectedChildId && childBirthDate) {
if (workspace?.id && personalDataUuid) {
setIsSaving(true)
await api.updateChildSettings(selectedChildId, { birth_date: childBirthDate })
setSaveMessage({ type: 'success', text: 'Child birth date saved!' })
setTimeout(() => setSaveMessage(null), 3000)
await upsertVacationDays({
personalDataUuid,
workspaceId: workspace.id,
year,
totalDays,
savedDaysIn,
})
showMessage('success', t('settings.saveVacationSuccess', { year }))
}
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to save child birth date',
})
showMessage('error', err instanceof Error ? err.message : t('settings.saveVacationError'))
} finally {
setIsSaving(false)
}
}
const handleAddBreakPeriod = async (period: { name: string; startDate: string; endDate: string; targetWeeks: number }) => {
try {
if (workspace?.id) {
setIsSaving(true)
await addBreakPeriod(period.startDate, period.endDate, period.name, period.targetWeeks)
setSaveMessage({ type: 'success', text: 'Break period added!' })
setTimeout(() => setSaveMessage(null), 3000)
}
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to add break period',
})
} finally {
setIsSaving(false)
}
const handleRedDaysPrevWindow = () => {
setRedDaysStartYear((y) => y - RED_DAYS_WINDOW_YEARS)
}
const handleRemoveBreakPeriod = async (id: string) => {
try {
setIsSaving(true)
await removeBreakPeriod(id)
setSaveMessage({ type: 'success', text: 'Break period removed!' })
setTimeout(() => setSaveMessage(null), 3000)
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to remove break period',
})
} finally {
setIsSaving(false)
}
const handleRedDaysNextWindow = () => {
setRedDaysStartYear((y) => y + RED_DAYS_WINDOW_YEARS)
}
const handleAddPartner = async () => {
if (!newPartnerName.trim() || !workspace?.id) {
setSaveMessage({ type: 'error', text: 'Please enter a partner name' })
showMessage('error', t('settings.partnerNameRequired'))
return
}
@@ -177,13 +239,9 @@ export function Settings(): ReactNode {
await api.addPartner(workspace.id, newPartnerName)
await refreshContext()
setNewPartnerName('')
setSaveMessage({ type: 'success', text: 'Partner added! Refresh the page to see changes.' })
setTimeout(() => setSaveMessage(null), 3000)
showMessage('success', t('settings.addPartnerSuccess'))
} catch (err) {
setSaveMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to add partner',
})
showMessage('error', err instanceof Error ? err.message : t('settings.addPartnerError'))
} finally {
setIsSaving(false)
}
@@ -192,7 +250,7 @@ export function Settings(): ReactNode {
return (
<Layout>
<div data-testid="settings-page" className="settings-page">
<h1>Settings</h1>
<h1>{t('settings.title')}</h1>
{saveMessage && (
<div
@@ -205,115 +263,146 @@ export function Settings(): ReactNode {
)}
<section className="settings-page__section">
<h2>Child Information</h2>
{selectedChild && (
<h2>{t('settings.childSectionTitle')}</h2>
{selectedChild ? (
<>
<p>Selected child: <strong>{selectedChild.name}</strong></p>
<p>
{t('settings.selectedChildLabel')} <strong>{selectedChild.name}</strong>
</p>
<ChildBirthdayInput
birthDate={childBirthDate}
onBirthDateChange={setChildBirthDate}
label={t('settings.childBirthDateLabel')}
/>
<div className="settings-page__field">
<p className="settings-page__help">{t('settings.preschoolHelp')}</p>
<VacationDaysInput
label={t('settings.preschoolPlanningDaysLabel')}
value={preschoolPlanningDays}
onChange={setPreschoolPlanningDays}
min={0}
max={30}
/>
</div>
<button
onClick={handleSaveChildBirthDate}
onClick={handleSaveChild}
className="settings-page__button"
disabled={isSaving}
data-testid="save-child-button"
>
{isSaving ? 'Saving...' : 'Save Child Birth Date'}
{isSaving ? t('common.saving') : t('settings.saveChildButton')}
</button>
</>
) : (
<p>{t('settings.noChildSelected')}</p>
)}
</section>
<section className="settings-page__section">
<h2>Parent Profile</h2>
<ParentProfileInput
parent={parentData}
onChange={(field, val) => setParentData({ ...parentData, [field]: val })}
/>
<h2>{t('settings.profileSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.profileHelp')}</p>
<ParentProfileInput parent={parentData} onChange={handleParentChange} />
<button
onClick={handleSaveParentProfile}
className="settings-page__button"
disabled={isSaving}
data-testid="save-parent-button"
>
{isSaving ? 'Saving...' : 'Save Parent Profile'}
{isSaving ? t('common.saving') : t('settings.saveProfileButton')}
</button>
</section>
<section className="settings-page__section">
<h2>Workspace Settings</h2>
<PlanningGoalSelector
value={planningGoal}
onChange={(goal) => setPlanningGoal(goal as 'max_time' | 'max_money' | 'balanced')}
/>
<h2>{t('settings.planningSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.planningHelp')}</p>
<PlanningGoalSelector value={planningGoal} onChange={setPlanningGoal} />
<PlanningModeSelector value={planningMode} onChange={setPlanningMode} />
<button
onClick={handleSavePlanningGoal}
onClick={handleSavePlanning}
className="settings-page__button"
disabled={isSaving}
data-testid="save-planning-goal-button"
>
{isSaving ? 'Saving...' : 'Save Planning Goal'}
{isSaving ? t('common.saving') : t('settings.savePlanningButton')}
</button>
</section>
<section className="settings-page__section">
<h2>Vacation Days</h2>
<VacationDaysInput
label="Annual vacation days"
value={vacationDaysValue}
onChange={setVacationDaysValue}
min={0}
max={365}
/>
<button
onClick={handleSaveVacationDays}
className="settings-page__button"
disabled={isSaving}
data-testid="save-vacation-button"
>
{isSaving ? 'Saving...' : 'Save Vacation Days'}
</button>
<h2>{t('settings.vacationSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.vacationHelp')}</p>
<VacationDaysTable rows={vacationRows} onSave={handleSaveVacationYear} isSaving={isSaving} />
</section>
<section className="settings-page__section">
<h2>Break Periods</h2>
<BreakPeriodsInput
periods={breakPeriods ?? []}
onAdd={handleAddBreakPeriod}
onRemove={handleRemoveBreakPeriod}
/>
</section>
<section className="settings-page__section">
<h2>Calendar</h2>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<h2>{t('settings.calendarSectionTitle')}</h2>
<label className="settings-page__checkbox-label">
<input
type="checkbox"
checked={showWeekNumbers}
onChange={(e) => setShowWeekNumbers(e.target.checked)}
data-testid="show-week-numbers-checkbox"
/>
<span>Visa veckonummer</span>
<span>{t('settings.showWeekNumbers')}</span>
</label>
</section>
<section className="settings-page__section">
<h2>Partner</h2>
<p>Add a partner to plan parental leave together.</p>
<div style={{ display: 'flex', gap: '8px' }}>
<h2>{t('settings.languageSectionTitle')}</h2>
<label htmlFor="language-select" className="settings-page__label">
{t('settings.languageLabel')}
</label>
<select
id="language-select"
className="settings-page__select"
value={explicitLocale ?? 'auto'}
onChange={(e) => handleLanguageChange(e.target.value)}
data-testid="language-select"
>
<option value="auto">{t('settings.languageAutoOption')}</option>
<option value="sv">{t('settings.languageSwedishOption')}</option>
<option value="en">{t('settings.languageEnglishOption')}</option>
</select>
</section>
<section className="settings-page__section">
<h2>{t('settings.dayOverridesSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.dayOverridesHelp')}</p>
<DayOverridesInput
overrides={dayOverrides}
isLoading={dayOverridesLoading || isSaving}
onAdd={upsertDayOverride}
onRemove={removeDayOverride}
workspaceId={workspace?.id ?? ''}
/>
</section>
<section className="settings-page__section">
<h2>{t('settings.redDaysSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.redDaysHelp')}</p>
<RedDaysList
days={calendarWindow?.days ?? []}
startYear={redDaysStartYear}
years={RED_DAYS_WINDOW_YEARS}
minYear={calendarWindow?.minYear ?? null}
maxYear={calendarWindow?.maxYear ?? null}
isLoading={calendarLoading}
error={calendarError}
onPrevWindow={handleRedDaysPrevWindow}
onNextWindow={handleRedDaysNextWindow}
/>
</section>
<section className="settings-page__section">
<h2>{t('settings.partnerSectionTitle')}</h2>
<p>{t('settings.partnerIntro')}</p>
<div className="settings-page__partner-row">
<input
type="text"
placeholder="Partner name"
placeholder={t('settings.partnerNamePlaceholder')}
value={newPartnerName}
onChange={(e) => setNewPartnerName(e.target.value)}
disabled={isSaving}
style={{
flex: 1,
padding: '8px 10px',
border: '1px solid #d0d0d0',
borderRadius: '4px',
}}
className="settings-page__partner-input"
/>
<button
onClick={handleAddPartner}
@@ -321,7 +410,7 @@ export function Settings(): ReactNode {
disabled={isSaving}
data-testid="add-partner-button"
>
{isSaving ? 'Adding...' : 'Lägg till partner'}
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
</section>