Lead the overview with the days about to be lost

The page opened with generic tiles and a bar reading forty-eight of ninety-six
weeks, which was neither weeks nor connected to the age-four ceiling it appeared
to describe. It is replaced by one headline and a bar per parent, in days,
against each parent's own savable limit as the server reports it.

The headline has two framings on the same arithmetic. Before anything is
planned it says what there is to plan and what will be lost without a plan,
because on the first day almost the whole entitlement is technically at risk
and saying so bluntly reads as an accusation. Once a plan exists it warns about
the days genuinely at stake.

A timeline of the years to the fourth birthday answers what the next four years
look like, which a month-at-a-time calendar cannot. Shortfalls, surplus advice
and balances sit below the fold, read-only: applying is automatic now, so a
second apply button would be a way to get two different answers.

Rejection messages move to one shared place that resolves a parent's name, so
an identifier can no longer surface in their place.
This commit is contained in:
2026-09-17 21:32:15 +02:00
parent c2e64b504d
commit 8e1cf3524c
27 changed files with 1749 additions and 178 deletions
+142
View File
@@ -32,6 +32,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
projected_left_at_b4: 230,
savable_after_b4: 96,
expiring_at_b4: 134,
savable_cap_at_b4: 96,
},
],
combined_days_used: 100,
@@ -59,6 +60,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(result.parentProjections[0]!.projectedLeftAtB4).toBe(230)
expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96)
})
})
@@ -351,6 +353,60 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(body.name_message_key).toBe('wishEditor.seasonJul')
expect(result.nameMessageKey).toBe('wishEditor.seasonJul')
})
it('sends target_days for a calendar-drawn period and maps it back', async () => {
const rawResponse = {
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Ny period',
window_start_month: 6,
window_start_day: 1,
window_end_month: 6,
window_end_day: 5,
target_weeks: 1,
target_days: 5,
recurrence: 'once',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'specific',
parent_personal_data_uuid: 'pd1',
window_mode: 'fixed',
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Ny period',
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
windowEndDay: 5,
targetWeeks: 1,
targetDays: 5,
recurrence: 'once',
firstYear: 2026,
parentMode: 'specific',
parentPersonalDataUuid: 'pd1',
windowMode: 'fixed',
})
const calls = vi.mocked(fetchMock).mock.calls
if (calls.length === 0) throw new Error('No fetch calls made')
const [, options] = calls[0]!
const body = JSON.parse(options.body)
expect(body.target_days).toBe(5)
expect(result.targetDays).toBe(5)
})
})
describe('getWorkspace', () => {
@@ -1278,6 +1334,92 @@ describe('endpoint functions (representative coverage)', () => {
])
})
it('syncPlan posts the workspace id and maps the nested proposal/applyResult/changes', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
proposal: {
days: [],
outcomes: [],
warnings: [],
pool_balances: [],
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
},
apply_result: {
accepted: true,
created_count: 2,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 1,
collisions: [],
rejections: [],
created_days: [],
},
changes: [
{
kind: 'retyped',
date: '2028-06-20',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
old_day_type: 'vacation',
new_day_type: 'sjukpenning',
old_is_dubbeldag: false,
new_is_dubbeldag: false,
old_wish_id: 'lw1',
new_wish_id: 'lw1',
},
{
kind: 'added',
date: '2028-06-21',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
old_day_type: null,
new_day_type: 'vacation',
old_is_dubbeldag: null,
new_is_dubbeldag: false,
old_wish_id: null,
new_wish_id: 'lw1',
},
],
}),
})
const result = await api.syncPlan('ws1')
expect(lastRequest()).toEqual({ url: '/api/plan/sync', method: 'POST' })
expect(lastRequestBody()).toEqual({ workspace_id: 'ws1' })
expect(result.applyResult.createdCount).toBe(2)
expect(result.applyResult.replacedCount).toBe(1)
expect(result.changes).toHaveLength(2)
expect(result.changes[0]).toEqual({
kind: 'retyped',
date: '2028-06-20',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
oldDayType: 'vacation',
newDayType: 'sjukpenning',
oldIsDubbeldag: false,
newIsDubbeldag: false,
oldWishId: 'lw1',
newWishId: 'lw1',
})
expect(result.changes[1]).toEqual({
kind: 'added',
date: '2028-06-21',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
oldDayType: null,
newDayType: 'vacation',
oldIsDubbeldag: null,
newIsDubbeldag: false,
oldWishId: null,
newWishId: 'lw1',
})
})
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
+67
View File
@@ -21,11 +21,13 @@ import type {
WorkspaceCreateRequest,
WorkspaceUpdateRequest,
WorkspaceOverview,
PlanSyncResponse,
} from '@/types'
import type {
Workspace,
LeaveWish,
PlanProposal,
PlanChange,
CostBreakdown,
Tip,
PlannedLeaveDay,
@@ -114,6 +116,7 @@ export interface RawAge4Projection {
projected_left_at_b4: number
savable_after_b4: number
expiring_at_b4: number
savable_cap_at_b4: number
}
function toAge4Projection(raw: RawAge4Projection): Age4Projection {
@@ -128,6 +131,7 @@ function toAge4Projection(raw: RawAge4Projection): Age4Projection {
projectedLeftAtB4: raw.projected_left_at_b4,
savableAfterB4: raw.savable_after_b4,
expiringAtB4: raw.expiring_at_b4,
savableCapAtB4: raw.savable_cap_at_b4,
}
}
@@ -232,6 +236,7 @@ interface RawLeaveWish {
window_end_month: number
window_end_day: number
target_weeks: number
target_days?: number | null
recurrence: 'yearly' | 'once'
first_year: number
last_year: number | null
@@ -255,6 +260,7 @@ function toLeaveWish(raw: RawLeaveWish): LeaveWish {
windowEndMonth: raw.window_end_month,
windowEndDay: raw.window_end_day,
targetWeeks: raw.target_weeks,
targetDays: raw.target_days ?? null,
recurrence: raw.recurrence,
firstYear: raw.first_year,
lastYear: raw.last_year,
@@ -782,6 +788,7 @@ export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<
window_end_month: payload.windowEndMonth,
window_end_day: payload.windowEndDay,
target_weeks: payload.targetWeeks,
target_days: payload.targetDays ?? null,
recurrence: payload.recurrence,
first_year: payload.firstYear,
last_year: payload.lastYear ?? null,
@@ -807,6 +814,7 @@ export async function updateLeaveWish(
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.targetDays !== undefined) body.target_days = payload.targetDays
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
@@ -902,6 +910,59 @@ export async function applyPlan(
return toPlanApplyResponse(raw)
}
interface RawPlanChange {
kind: string
date: string
parent_personal_data_uuid: string
child_id: string
old_day_type: string | null
new_day_type: string | null
old_is_dubbeldag: boolean | null
new_is_dubbeldag: boolean | null
old_wish_id: string | null
new_wish_id: string | null
}
function toPlanChange(raw: RawPlanChange): PlanChange {
return {
kind: raw.kind as PlanChange['kind'],
date: raw.date,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
childId: raw.child_id,
oldDayType: raw.old_day_type as PlanChange['oldDayType'],
newDayType: raw.new_day_type as PlanChange['newDayType'],
oldIsDubbeldag: raw.old_is_dubbeldag,
newIsDubbeldag: raw.new_is_dubbeldag,
oldWishId: raw.old_wish_id,
newWishId: raw.new_wish_id,
}
}
interface RawPlanSyncResponse {
proposal: RawPlanProposal
apply_result: RawPlanApplyResponse
changes: RawPlanChange[]
}
/** POST /api/plan/sync — the direct-manipulation calendar's one call:
* recompute the plan from the workspace's `LeaveWish`es and persist it in
* one step (no separate preview), never retyping/overwriting a pinned day,
* and reporting exactly what changed via `changes` so the UI can render a
* toast ("Sommar 2028 flyttades 2 dagar") instead of silently rewriting the
* calendar underneath the user.
*/
export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
const raw = (await apiFetch('/plan/sync', {
method: 'POST',
body: JSON.stringify({ workspace_id: workspaceId }),
})) as RawPlanSyncResponse
return {
proposal: toPlanProposal(raw.proposal),
applyResult: toPlanApplyResponse(raw.apply_result),
changes: raw.changes.map(toPlanChange),
}
}
// ---------------------------------------------------------------------------
// Workspace members (used to resolve/select which parent a wish belongs to)
// ---------------------------------------------------------------------------
@@ -975,6 +1036,10 @@ export interface CreatePlannedDayPayload {
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
// True for a day hand-edited through the calendar's day popover — the
// planner (POST /api/plan/sync) must not silently retype it. See
// `PlannedLeaveDay.isPinned`.
is_pinned?: boolean
}
export async function createPlannedDay(payload: CreatePlannedDayPayload): Promise<PlannedLeaveDay> {
@@ -988,6 +1053,8 @@ export async function createPlannedDay(payload: CreatePlannedDayPayload): Promis
export interface UpdatePlannedDayPayload {
day_type?: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
// See `CreatePlannedDayPayload.is_pinned`.
is_pinned?: boolean
}
export async function updatePlannedDay(id: string, payload: UpdatePlannedDayPayload): Promise<PlannedLeaveDay> {