Give the analytics overview a per-parent, per-year balance series

Each parent projection now carries one entry per calendar year from
birth to the fourth birthday: vacation earned, carried in, planned,
left, carried out and expiring under Semesterlagen's 20-day floor and
5-year carry-over, plus parental-leave days used and left at year end
with the age-4 clamp applied in the birthday year. The overview sums
them into per-year totals. Frontend types, mapper and fixtures follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-19 19:02:02 +02:00
parent dbef997da4
commit 4b802ba0be
14 changed files with 1365 additions and 37 deletions
+21 -12
View File
@@ -74,6 +74,7 @@ describe('Dashboard', () => {
combinedDaysUsed: 30,
combinedDaysLeft: 270,
combinedExpiringAtB4: 100,
yearTotals: [],
},
})
)
@@ -247,11 +248,12 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
],
combinedDaysUsed: 30,
combinedDaysLeft: 210,
combinedExpiringAtB4: 60,
yearTotals: [],
},
})
)
@@ -268,12 +270,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 },
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48, yearBalances: [] },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48, yearBalances: [] },
],
combinedDaysUsed: 31,
combinedDaysLeft: 279,
combinedExpiringAtB4: 191,
yearTotals: [],
},
})
)
@@ -405,12 +408,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
yearTotals: [],
},
})
)
@@ -563,6 +567,7 @@ describe('Dashboard', () => {
combinedDaysUsed: 50,
combinedDaysLeft: 200,
combinedExpiringAtB4: 104,
yearTotals: [],
},
})
)
@@ -578,11 +583,12 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96, yearBalances: [] },
],
combinedDaysUsed: 30,
combinedDaysLeft: 210,
combinedExpiringAtB4: 104,
yearTotals: [],
},
})
)
@@ -599,12 +605,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
yearTotals: [],
},
})
)
@@ -633,12 +640,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
yearTotals: [],
},
})
)
@@ -655,12 +663,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
{ personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
yearTotals: [],
},
})
)
+38
View File
@@ -33,11 +33,36 @@ describe('API response mappers (snake_case → camelCase)', () => {
savable_after_b4: 96,
expiring_at_b4: 134,
savable_cap_at_b4: 96,
year_balances: [
{
personal_data_uuid: 'pd1',
child_id: 'child1',
year: 2024,
vacation: {
year: 2024,
earned: 25,
is_assumed: true,
carried_in: 0,
planned_use: 2,
left: 23,
carried_out: 5,
expiring: 0,
},
parental_leave: {
year: 2024,
sjukpenning_used: 10,
lagsta_used: 0,
sjukpenning_left: 380,
lagsta_left: 90,
},
},
],
},
],
combined_days_used: 100,
combined_days_left: 380,
combined_expiring_at_b4: 134,
year_totals: [{ year: 2024, pl_left: 470, vacation_left: 23 }],
}
fetchMock.mockResolvedValue({
@@ -61,6 +86,19 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96)
const yearBalances = result.parentProjections[0]!.yearBalances
expect(yearBalances.length).toBe(1)
expect(yearBalances[0]!.personalDataUuid).toBe('pd1')
expect(yearBalances[0]!.childId).toBe('child1')
expect(yearBalances[0]!.year).toBe(2024)
expect(yearBalances[0]!.vacation.isAssumed).toBe(true)
expect(yearBalances[0]!.vacation.plannedUse).toBe(2)
expect(yearBalances[0]!.vacation.carriedOut).toBe(5)
expect(yearBalances[0]!.parentalLeave.sjukpenningUsed).toBe(10)
expect(yearBalances[0]!.parentalLeave.sjukpenningLeft).toBe(380)
expect(result.yearTotals).toEqual([{ year: 2024, plLeft: 470, vacationLeft: 23 }])
})
})
+84
View File
@@ -38,6 +38,10 @@ import type {
PersonalData,
CalendarWindow,
CalendarDayEntry,
VacationYearBalance,
ParentalLeaveYearBalance,
ParentYearBalance,
YearTotals,
} from '@/types'
const BASE = '/api'
@@ -106,6 +110,82 @@ function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch {
}
}
export interface RawVacationYearBalance {
year: number
earned: number
is_assumed: boolean
carried_in: number
planned_use: number
left: number
carried_out: number
expiring: number
}
function toVacationYearBalance(raw: RawVacationYearBalance): VacationYearBalance {
return {
year: raw.year,
earned: raw.earned,
isAssumed: raw.is_assumed,
carriedIn: raw.carried_in,
plannedUse: raw.planned_use,
left: raw.left,
carriedOut: raw.carried_out,
expiring: raw.expiring,
}
}
export interface RawParentalLeaveYearBalance {
year: number
sjukpenning_used: number
lagsta_used: number
sjukpenning_left: number
lagsta_left: number
}
function toParentalLeaveYearBalance(
raw: RawParentalLeaveYearBalance,
): ParentalLeaveYearBalance {
return {
year: raw.year,
sjukpenningUsed: raw.sjukpenning_used,
lagstaUsed: raw.lagsta_used,
sjukpenningLeft: raw.sjukpenning_left,
lagstaLeft: raw.lagsta_left,
}
}
export interface RawParentYearBalance {
personal_data_uuid: string
child_id: string
year: number
vacation: RawVacationYearBalance
parental_leave: RawParentalLeaveYearBalance
}
function toParentYearBalance(raw: RawParentYearBalance): ParentYearBalance {
return {
personalDataUuid: raw.personal_data_uuid,
childId: raw.child_id,
year: raw.year,
vacation: toVacationYearBalance(raw.vacation),
parentalLeave: toParentalLeaveYearBalance(raw.parental_leave),
}
}
export interface RawYearTotals {
year: number
pl_left: number
vacation_left: number
}
function toYearTotals(raw: RawYearTotals): YearTotals {
return {
year: raw.year,
plLeft: raw.pl_left,
vacationLeft: raw.vacation_left,
}
}
export interface RawAge4Projection {
personal_data_uuid: string
child_id: string
@@ -118,6 +198,7 @@ export interface RawAge4Projection {
savable_after_b4: number
expiring_at_b4: number
savable_cap_at_b4: number
year_balances: RawParentYearBalance[]
}
function toAge4Projection(raw: RawAge4Projection): Age4Projection {
@@ -133,6 +214,7 @@ function toAge4Projection(raw: RawAge4Projection): Age4Projection {
savableAfterB4: raw.savable_after_b4,
expiringAtB4: raw.expiring_at_b4,
savableCapAtB4: raw.savable_cap_at_b4,
yearBalances: raw.year_balances.map(toParentYearBalance),
}
}
@@ -143,6 +225,7 @@ export interface RawWorkspaceOverview {
combined_days_used: number
combined_days_left: number
combined_expiring_at_b4: number
year_totals: RawYearTotals[]
}
function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
@@ -153,6 +236,7 @@ function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
combinedDaysUsed: raw.combined_days_used,
combinedDaysLeft: raw.combined_days_left,
combinedExpiringAtB4: raw.combined_expiring_at_b4,
yearTotals: raw.year_totals.map(toYearTotals),
}
}
+51
View File
@@ -220,6 +220,53 @@ export interface CalendarWindow {
days: CalendarDayEntry[]
}
// Per (parent, child, calendar year) balance series shown on the analytics
// overview — birth year through the year of the 4th birthday inclusive. See
// the backend's `app.services.analytics_service.ParentYearBalance` docstring
// for the vacation carry-forward (Semesterlagen, 5-year expiry) algorithm.
export interface VacationYearBalance {
year: number
/** Entitlement for this year: the entered total, or the statutory default (25). */
earned: number
/** True when no `vacation_days` row exists for this (parent, year) — `earned` is the statutory default. */
isAssumed: boolean
/** Days carried in from prior years, still valid. */
carriedIn: number
/** Committed 'vacation' planned-leave rows in this year (across all of this parent's children). */
plannedUse: number
/** max(earned + carriedIn - plannedUse, 0). */
left: number
/** Days saved forward into next year's carriedIn. */
carriedOut: number
/** Days that hit the 5-year carry-forward cap this year — LOST if not used. */
expiring: number
}
export interface ParentalLeaveYearBalance {
year: number
sjukpenningUsed: number
lagstaUsed: number
/** Running balance at this year's end (age-4 clamp applied in the 4th-birthday year). */
sjukpenningLeft: number
lagstaLeft: number
}
export interface ParentYearBalance {
personalDataUuid: string
childId: string
year: number
vacation: VacationYearBalance
parentalLeave: ParentalLeaveYearBalance
}
/** Combined (all parents) balance for one calendar year, on the overview. */
export interface YearTotals {
year: number
plLeft: number
vacationLeft: number
}
export interface Age4Projection {
personalDataUuid: string
childId: string
@@ -236,6 +283,8 @@ export interface Age4Projection {
// parents. See `resolve_age4_savable_cap` (backend). Render the per-parent
// bar against this field directly; never re-derive the 48-vs-96 rule here.
savableCapAtB4: number
// Per-calendar-year vacation + FP balance series — see `ParentYearBalance`.
yearBalances: ParentYearBalance[]
}
export interface WorkspaceOverview {
@@ -245,6 +294,8 @@ export interface WorkspaceOverview {
combinedDaysUsed: number
combinedDaysLeft: number
combinedExpiringAtB4: number
// Combined (all parents) per-year totals — see `YearTotals`.
yearTotals: YearTotals[]
}
export interface Tip {