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
File diff suppressed because it is too large Load Diff
+503 -30
View File
@@ -10,6 +10,8 @@
import { ApiRequestError } from '@/services/errors'
import type {
LoginRequest,
LeaveWishCreateRequest,
LeaveWishUpdateRequest,
ManualFetchRequest,
ParentalLeaveFetch,
RegisterRequest,
@@ -20,7 +22,20 @@ import type {
WorkspaceUpdateRequest,
WorkspaceOverview,
} from '@/types'
import type { Workspace, BreakPeriod, Tip, PlannedLeaveDay, TierData, Age4Projection } from '@/types'
import type {
Workspace,
LeaveWish,
PlanProposal,
CostBreakdown,
Tip,
PlannedLeaveDay,
TierData,
Age4Projection,
Child,
PersonalData,
CalendarWindow,
CalendarDayEntry,
} from '@/types'
const BASE = '/api'
@@ -186,34 +201,232 @@ function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay {
}
}
interface RawBreakPeriod {
interface RawLeaveWish {
id: string
workspace_id: string
child_id: string | null
name: string
start_date: string
end_date: string
window_start_month: number
window_start_day: number
window_end_month: number
window_end_day: number
target_weeks: number
created_at: string
updated_at: string
recurrence: 'yearly' | 'once'
first_year: number
last_year: number | null
priority: number
parent_mode: 'either' | 'both' | 'specific'
parent_personal_data_uuid: string | null
goal_override: 'max_time' | 'max_money' | 'balanced' | null
window_mode: 'fixed' | 'flexible'
created_at: string | null
updated_at: string | null
}
function toBreakPeriod(raw: RawBreakPeriod): BreakPeriod {
function toLeaveWish(raw: RawLeaveWish): LeaveWish {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
name: raw.name,
startDate: raw.start_date,
endDate: raw.end_date,
windowStartMonth: raw.window_start_month,
windowStartDay: raw.window_start_day,
windowEndMonth: raw.window_end_month,
windowEndDay: raw.window_end_day,
targetWeeks: raw.target_weeks,
recurrence: raw.recurrence,
firstYear: raw.first_year,
lastYear: raw.last_year,
priority: raw.priority,
parentMode: raw.parent_mode,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
goalOverride: raw.goal_override,
windowMode: raw.window_mode ?? 'fixed',
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
interface RawProposedDay {
date: string
parent_personal_data_uuid: string
child_id: string | null
day_type: 'free' | 'vacation' | 'sjukpenning' | 'lagsta'
is_dubbeldag: boolean
wish_id: string
reason: string
}
interface RawShortfallRemedy {
kind: string
message_key: string
data: Record<string, unknown>
days_freed: number
}
interface RawWindowOutcome {
wish_id: string
wish_name: string
year: number
start_date: string
end_date: string
weeks_wished: number
weeks_funded: number
shortfall_days: number
strategy: string
message_key: string
data: Record<string, unknown>
cost_breakdown: Record<string, number>
remedies: RawShortfallRemedy[]
}
interface RawPlanWarning {
kind: string
message_key: string
data: Record<string, unknown>
}
interface RawPoolBalance {
parent_personal_data_uuid: string
child_id: string
sjukpenning_left: number
lagsta_left: number
dubbeldagar_left: number
}
interface RawVacationBalance {
parent_personal_data_uuid: string
year: number
total_days: number
saved_days_in: number
already_used: number
is_assumed: boolean
}
interface RawSurplusAdvice {
kind: string
message_key: string
days_involved: number
data: Record<string, unknown>
}
interface RawPlanAge4Projection {
parent_personal_data_uuid: string
child_id: string
fourth_birthday: string
projected_left_at_b4: number
savable_after_b4: number
expiring_at_b4: number
}
interface RawPlanProposal {
days: RawProposedDay[]
outcomes: RawWindowOutcome[]
warnings: RawPlanWarning[]
pool_balances: RawPoolBalance[]
vacation_balances: RawVacationBalance[]
surplus_advice: RawSurplusAdvice[]
age4_projections: RawPlanAge4Projection[]
}
function toCostBreakdown(raw: Record<string, number>): CostBreakdown {
return {
free: raw.free ?? 0,
vacation: raw.vacation ?? 0,
sjukpenning: raw.sjukpenning ?? 0,
lagsta: raw.lagsta ?? 0,
}
}
function toPlanProposal(raw: RawPlanProposal): PlanProposal {
return {
days: raw.days.map((d) => ({
date: d.date,
parentPersonalDataUuid: d.parent_personal_data_uuid,
childId: d.child_id,
dayType: d.day_type,
isDubbeldag: d.is_dubbeldag,
wishId: d.wish_id,
reason: d.reason,
})),
outcomes: raw.outcomes.map((o) => ({
wishId: o.wish_id,
wishName: o.wish_name,
year: o.year,
startDate: o.start_date,
endDate: o.end_date,
weeksWished: o.weeks_wished,
weeksFunded: o.weeks_funded,
shortfallDays: o.shortfall_days,
strategy: o.strategy,
messageKey: o.message_key,
data: o.data,
costBreakdown: toCostBreakdown(o.cost_breakdown),
remedies: o.remedies.map((r) => ({
kind: r.kind,
messageKey: r.message_key,
data: r.data,
daysFreed: r.days_freed,
})),
})),
warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })),
poolBalances: raw.pool_balances.map((p) => ({
parentPersonalDataUuid: p.parent_personal_data_uuid,
childId: p.child_id,
sjukpenningLeft: p.sjukpenning_left,
lagstaLeft: p.lagsta_left,
dubbeldagarLeft: p.dubbeldagar_left,
})),
vacationBalances: raw.vacation_balances.map((v) => ({
parentPersonalDataUuid: v.parent_personal_data_uuid,
year: v.year,
totalDays: v.total_days,
savedDaysIn: v.saved_days_in,
alreadyUsed: v.already_used,
isAssumed: v.is_assumed,
})),
surplusAdvice: raw.surplus_advice.map((a) => ({
kind: a.kind,
messageKey: a.message_key,
daysInvolved: a.days_involved,
data: a.data,
})),
age4Projections: raw.age4_projections.map((p) => ({
parentPersonalDataUuid: p.parent_personal_data_uuid,
childId: p.child_id,
fourthBirthday: p.fourth_birthday,
projectedLeftAtB4: p.projected_left_at_b4,
savableAfterB4: p.savable_after_b4,
expiringAtB4: p.expiring_at_b4,
})),
}
}
interface RawPersonalData {
uuid: string
workspace_id: string
name: string
salary_type: 'monthly' | 'hourly'
employment_status: 'employed' | 'jobseeking' | 'none'
created_at: string | null
}
function toPersonalData(raw: RawPersonalData): PersonalData {
return {
uuid: raw.uuid,
workspaceId: raw.workspace_id,
name: raw.name,
salaryType: raw.salary_type,
employmentStatus: raw.employment_status,
createdAt: raw.created_at ?? '',
}
}
interface RawWorkspace {
id: string
name: string
planning_goal: 'max_time' | 'max_money' | 'balanced'
planning_mode: 'solo' | 'couple'
created_at: string
}
@@ -222,10 +435,55 @@ function toWorkspace(raw: RawWorkspace): Workspace {
id: raw.id,
name: raw.name,
planningGoal: raw.planning_goal,
planningMode: raw.planning_mode,
createdAt: raw.created_at,
}
}
interface RawChild {
id: string
workspace_id: string
name: string
birth_date: string
preschool_start_date: string | null
preschool_planning_days_per_year: number
created_at: string | null
}
function toChild(raw: RawChild): Child {
return {
id: raw.id,
workspaceId: raw.workspace_id,
name: raw.name,
birthDate: raw.birth_date,
preschoolStartDate: raw.preschool_start_date,
preschoolPlanningDaysPerYear: raw.preschool_planning_days_per_year,
createdAt: raw.created_at ?? '',
}
}
interface RawDayOverride {
id: string
workspace_id: string
date: string
is_working_day: boolean
note: string | null
created_at: string | null
updated_at: string | null
}
function toDayOverride(raw: RawDayOverride): import('@/types').DayOverride {
return {
id: raw.id,
workspaceId: raw.workspace_id,
date: raw.date,
isWorkingDay: raw.is_working_day,
note: raw.note,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
let _accessToken: string | null = null
export function setAccessToken(token: string | null): void {
@@ -325,6 +583,11 @@ export async function createChild(req: CreateChildRequest): Promise<{ id: string
})
}
export async function getChild(childId: string): Promise<Child> {
const raw = (await apiFetch(`/children/${childId}`)) as RawChild
return toChild(raw)
}
// ---------------------------------------------------------------------------
// Workspace
// ---------------------------------------------------------------------------
@@ -342,13 +605,15 @@ export async function updateWorkspace(
workspaceId: string,
req: WorkspaceUpdateRequest,
): Promise<Workspace> {
return apiFetch(`/workspace/${workspaceId}`, {
const raw = (await apiFetch(`/workspace/${workspaceId}`, {
method: 'PUT',
body: JSON.stringify({
name: req.name,
planning_goal: req.planningGoal,
planning_mode: req.planningMode,
}),
})
})) as RawWorkspace
return toWorkspace(raw)
}
export async function addPartner(
@@ -443,46 +708,169 @@ export async function updateParentSettings(
interface ChildSettingsUpdate {
birth_date?: string
preschool_start_date?: string
preschool_planning_days_per_year?: number
}
export async function updateChildSettings(
childId: string,
payload: ChildSettingsUpdate,
): Promise<{ id: string; name: string; birth_date: string }> {
return apiFetch(`/settings/child/${childId}`, {
): Promise<Child> {
const raw = (await apiFetch(`/settings/child/${childId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
})) as RawChild
return toChild(raw)
}
// ---------------------------------------------------------------------------
// Break Periods
// Leave wishes ("N weeks off inside this yearly window") + planning engine
// ---------------------------------------------------------------------------
export async function getBreakPeriods(workspaceId: string): Promise<BreakPeriod[]> {
const raw = (await apiFetch(`/break-periods/${workspaceId}`)) as RawBreakPeriod[]
return raw.map(toBreakPeriod)
export async function getLeaveWishes(workspaceId: string): Promise<LeaveWish[]> {
const raw = (await apiFetch(`/leave-wishes/${workspaceId}`)) as RawLeaveWish[]
return raw.map(toLeaveWish)
}
export async function createBreakPeriod(
export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<LeaveWish> {
const raw = (await apiFetch('/leave-wishes', {
method: 'POST',
body: JSON.stringify({
workspace_id: payload.workspaceId,
child_id: payload.childId ?? null,
name: payload.name,
window_start_month: payload.windowStartMonth,
window_start_day: payload.windowStartDay,
window_end_month: payload.windowEndMonth,
window_end_day: payload.windowEndDay,
target_weeks: payload.targetWeeks,
recurrence: payload.recurrence,
first_year: payload.firstYear,
last_year: payload.lastYear ?? null,
priority: payload.priority ?? 1,
parent_mode: payload.parentMode ?? 'either',
parent_personal_data_uuid: payload.parentPersonalDataUuid ?? null,
goal_override: payload.goalOverride ?? null,
window_mode: payload.windowMode ?? 'fixed',
}),
})) as RawLeaveWish
return toLeaveWish(raw)
}
export async function updateLeaveWish(
id: string,
payload: LeaveWishUpdateRequest,
): Promise<LeaveWish> {
const body: Record<string, unknown> = {}
if (payload.childId !== undefined) body.child_id = payload.childId
if (payload.name !== undefined) body.name = payload.name
if (payload.windowStartMonth !== undefined) body.window_start_month = payload.windowStartMonth
if (payload.windowStartDay !== undefined) body.window_start_day = payload.windowStartDay
if (payload.windowEndMonth !== undefined) body.window_end_month = payload.windowEndMonth
if (payload.windowEndDay !== undefined) body.window_end_day = payload.windowEndDay
if (payload.targetWeeks !== undefined) body.target_weeks = payload.targetWeeks
if (payload.recurrence !== undefined) body.recurrence = payload.recurrence
if (payload.firstYear !== undefined) body.first_year = payload.firstYear
if (payload.lastYear !== undefined) body.last_year = payload.lastYear
if (payload.priority !== undefined) body.priority = payload.priority
if (payload.parentMode !== undefined) body.parent_mode = payload.parentMode
if (payload.parentPersonalDataUuid !== undefined) {
body.parent_personal_data_uuid = payload.parentPersonalDataUuid
}
if (payload.goalOverride !== undefined) body.goal_override = payload.goalOverride
if (payload.windowMode !== undefined) body.window_mode = payload.windowMode
const raw = (await apiFetch(`/leave-wishes/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
})) as RawLeaveWish
return toLeaveWish(raw)
}
export async function deleteLeaveWish(id: string): Promise<void> {
return apiFetch(`/leave-wishes/${id}`, { method: 'DELETE' })
}
/** Read-only preview of the planning engine's proposal — persists nothing. */
export async function proposePlan(workspaceId: string): Promise<PlanProposal> {
const raw = (await apiFetch('/plan/propose', {
method: 'POST',
body: JSON.stringify({ workspace_id: workspaceId }),
})) as RawPlanProposal
return toPlanProposal(raw)
}
interface RawDayCollision {
date: string
parent_personal_data_uuid: string
child_id: string
existing_day_type: string
proposed_day_type: string
}
interface RawRejection {
message_key: string
data: Record<string, unknown>
}
interface RawPlanApplyResponse {
accepted: boolean
created_count: number
skipped_identical_count: number
skipped_free_count: number
replaced_count: number
collisions: RawDayCollision[]
rejections: RawRejection[]
created_days: object[]
}
function toPlanApplyResponse(raw: RawPlanApplyResponse): import('@/types').PlanApplyResponse {
return {
accepted: raw.accepted,
createdCount: raw.created_count,
skippedIdenticalCount: raw.skipped_identical_count,
skippedFreeCount: raw.skipped_free_count,
replacedCount: raw.replaced_count,
collisions: raw.collisions.map((c) => ({
date: c.date,
parentPersonalDataUuid: c.parent_personal_data_uuid,
childId: c.child_id,
existingDayType: c.existing_day_type,
proposedDayType: c.proposed_day_type,
})),
rejections: raw.rejections.map((r) => ({ messageKey: r.message_key, data: r.data })),
createdDays: raw.created_days,
}
}
export async function applyPlan(
workspaceId: string,
payload: { startDate: string; endDate: string; name: string; targetWeeks: number },
): Promise<BreakPeriod> {
const raw = (await apiFetch(`/break-periods`, {
days: import('@/types').ProposedDayApplyItem[],
replace: boolean,
): Promise<import('@/types').PlanApplyResponse> {
const raw = (await apiFetch('/plan/apply', {
method: 'POST',
body: JSON.stringify({
workspace_id: workspaceId,
start_date: payload.startDate,
end_date: payload.endDate,
name: payload.name,
target_weeks: payload.targetWeeks,
days: days.map((d) => ({
date: d.date,
parent_personal_data_uuid: d.parentPersonalDataUuid,
child_id: d.childId,
day_type: d.dayType,
is_dubbeldag: d.isDubbeldag ?? false,
})),
replace,
}),
})) as RawBreakPeriod
return toBreakPeriod(raw)
})) as RawPlanApplyResponse
return toPlanApplyResponse(raw)
}
export async function deleteBreakPeriod(id: string): Promise<void> {
return apiFetch(`/break-periods/${id}`, { method: 'DELETE' })
// ---------------------------------------------------------------------------
// Workspace members (used to resolve/select which parent a wish belongs to)
// ---------------------------------------------------------------------------
export async function listWorkspaceMembers(workspaceId: string): Promise<PersonalData[]> {
const raw = (await apiFetch(`/workspace/${workspaceId}/members`)) as RawPersonalData[]
return raw.map(toPersonalData)
}
// ---------------------------------------------------------------------------
@@ -549,6 +937,91 @@ export async function deletePlannedDay(id: string): Promise<void> {
return apiFetch(`/planned-days/${id}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------
// Day overrides (calendar exceptions)
// ---------------------------------------------------------------------------
export async function upsertDayOverride(
req: import('@/types').DayOverrideCreateRequest,
): Promise<import('@/types').DayOverride> {
const raw = (await apiFetch('/day-overrides', {
method: 'POST',
body: JSON.stringify({
workspace_id: req.workspaceId,
date: req.date,
is_working_day: req.isWorkingDay,
note: req.note ?? null,
}),
})) as RawDayOverride
return toDayOverride(raw)
}
export async function listDayOverridesByWorkspace(workspaceId: string): Promise<import('@/types').DayOverride[]> {
const raw = (await apiFetch(`/day-overrides/workspace/${workspaceId}`)) as RawDayOverride[]
return raw.map(toDayOverride)
}
export async function deleteDayOverride(id: string): Promise<void> {
return apiFetch(`/day-overrides/${id}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------
// Calendar (röda dagar / klämdagar) — GET /api/calendar/days, backs §3.4
// (Kalender shading), §3.6 (click-to-override) and §3.8 (Settings red-days
// list) from one endpoint. See ARCHITECTURE.md §13.3.
// ---------------------------------------------------------------------------
interface RawCalendarDay {
date: string
name: string | null
day_type: 'red_day' | 'klamdag' | 'weekend' | 'working_day'
is_override: boolean
override_is_working_day: boolean | null
is_free: boolean
}
interface RawCalendarWindow {
start_year: number
end_year: number
min_year: number | null
max_year: number | null
days: RawCalendarDay[]
}
function toCalendarDayEntry(raw: RawCalendarDay): CalendarDayEntry {
return {
date: raw.date,
name: raw.name,
dayType: raw.day_type,
isOverride: raw.is_override,
overrideIsWorkingDay: raw.override_is_working_day,
isFree: raw.is_free,
}
}
function toCalendarWindow(raw: RawCalendarWindow): CalendarWindow {
return {
startYear: raw.start_year,
endYear: raw.end_year,
minYear: raw.min_year,
maxYear: raw.max_year,
days: raw.days.map(toCalendarDayEntry),
}
}
/** Fetches `years` years (1-4, capped server-side) of calendar metadata
* starting at `startYear`, merged with the workspace's day overrides. */
export async function getCalendarDays(
workspaceId: string,
startYear: number,
years: number = 4,
): Promise<CalendarWindow> {
const raw = (await apiFetch(
`/calendar/days?workspace_id=${encodeURIComponent(workspaceId)}&start_year=${startYear}&years=${years}`,
)) as RawCalendarWindow
return toCalendarWindow(raw)
}
// ---------------------------------------------------------------------------
// FK Scraper (dev / authenticated users only — requires scraper_enabled=true)
// ---------------------------------------------------------------------------
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { ApiRequestError, isApiError, isUnauthorized } from './errors'
describe('ApiRequestError', () => {
it('carries status and error payload, and uses the message as its own', () => {
const err = new ApiRequestError(404, { code: 'not_found', message: 'Not found' })
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe('ApiRequestError')
expect(err.status).toBe(404)
expect(err.error).toEqual({ code: 'not_found', message: 'Not found' })
expect(err.message).toBe('Not found')
})
})
describe('isApiError', () => {
it('returns true for an ApiRequestError', () => {
const err = new ApiRequestError(500, { code: 'server_error', message: 'Boom' })
expect(isApiError(err)).toBe(true)
})
it('returns false for a plain Error', () => {
expect(isApiError(new Error('plain'))).toBe(false)
})
it('returns false for non-error values', () => {
expect(isApiError('nope')).toBe(false)
expect(isApiError(undefined)).toBe(false)
expect(isApiError(null)).toBe(false)
})
})
describe('isUnauthorized', () => {
it('returns true for a 401 ApiRequestError', () => {
const err = new ApiRequestError(401, { code: '401', message: 'Unauthorized' })
expect(isUnauthorized(err)).toBe(true)
})
it('returns false for a non-401 ApiRequestError', () => {
const err = new ApiRequestError(403, { code: '403', message: 'Forbidden' })
expect(isUnauthorized(err)).toBe(false)
})
it('returns false for a non-ApiRequestError', () => {
expect(isUnauthorized(new Error('plain'))).toBe(false)
})
})