Files
F-r-ldraledigplannerare/frontend/src/services/api.ts
T
jacobh2 0e9b20ec37 Track preschool planning days, partner invites and real vacation forfeiture
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.

Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.

Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.

Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:22:07 +02:00

1535 lines
47 KiB
TypeScript

/**
* Typed API client. All requests go through `apiFetch`, which:
* - Attaches the Authorization header from the stored access token.
* - Parses error bodies into ApiRequestError.
* - Returns typed data (no `any`).
*
* This is the reference implementation — add one function per endpoint group.
*/
import { ApiRequestError } from '@/services/errors'
import type {
LoginRequest,
LeaveWishCreateRequest,
LeaveWishScope,
LeaveWishUpdateRequest,
ManualFetchRequest,
ParentalLeaveFetch,
RegisterRequest,
TokenResponse,
VacationDays,
VacationDaysCreateRequest,
WorkspaceCreateRequest,
WorkspaceUpdateRequest,
WorkspaceOverview,
PlanSyncResponse,
PreschoolPlanningDaysPlannedRequest,
PreschoolPlanningDaysTakenRequest,
CreateInvitationResponse,
} from '@/types'
import type {
Workspace,
LeaveWish,
PlanProposal,
PlanChange,
CostBreakdown,
Tip,
PlannedLeaveDay,
TierData,
Age4Projection,
Child,
PersonalData,
CalendarWindow,
CalendarDayEntry,
VacationYearBalance,
ParentalLeaveYearBalance,
ParentYearBalance,
YearTotals,
PreschoolPlanningDays,
Invitation,
} from '@/types'
const BASE = '/api'
// ---------------------------------------------------------------------------
// Wire format converters (snake_case → camelCase)
// ---------------------------------------------------------------------------
export interface RawTierData {
sjukpenning_total: number
sjukpenning_used: number
sjukpenning_left: number
lagsta_niva_total: number
lagsta_niva_used: number
lagsta_niva_left: number
}
function toTierData(raw: RawTierData): TierData {
return {
sjukpenningTotal: raw.sjukpenning_total,
sjukpenningUsed: raw.sjukpenning_used,
sjukpenningLeft: raw.sjukpenning_left,
lagstaNivaTotal: raw.lagsta_niva_total,
lagstaNivaUsed: raw.lagsta_niva_used,
lagstaNivaLeft: raw.lagsta_niva_left,
}
}
export interface RawParentalLeaveFetch {
id: string
personal_data_uuid: string
workspace_id: string
child_id: string
source: 'scraped' | 'manual' | 'deduced'
fetched_at: string
total_days: number
days_used: number
days_left: number
reserved_days_total: number
reserved_days_left: number
transferable_days_left: number
transferable_days_reported: boolean
dubbeldagar_left: number
tier_data: RawTierData
created_at: string
updated_at: string
}
function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch {
return {
id: raw.id,
personalDataUuid: raw.personal_data_uuid,
workspaceId: raw.workspace_id,
childId: raw.child_id,
source: raw.source,
fetchedAt: raw.fetched_at,
totalDays: raw.total_days,
daysUsed: raw.days_used,
daysLeft: raw.days_left,
reservedDaysTotal: raw.reserved_days_total,
reservedDaysLeft: raw.reserved_days_left,
transferableDaysLeft: raw.transferable_days_left,
transferableDaysReported: raw.transferable_days_reported,
dubbeldagarLeft: raw.dubbeldagar_left,
tierData: toTierData(raw.tier_data),
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
export interface RawVacationYearBalance {
year: number
earned: number
is_assumed: boolean
carried_in: number
planned_use: number
left: number
carried_out: number
expiring: number
expiring_age: number
expiring_over_cap: 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,
expiringAge: raw.expiring_age,
expiringOverCap: raw.expiring_over_cap,
}
}
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
birth_date: string
fourth_birthday: string
total_days: number
days_used: number
planned_days_before_b4: number
projected_left_at_b4: number
savable_after_b4: number
expiring_at_b4: number
savable_cap_at_b4: number
year_balances: RawParentYearBalance[]
}
function toAge4Projection(raw: RawAge4Projection): Age4Projection {
return {
personalDataUuid: raw.personal_data_uuid,
childId: raw.child_id,
birthDate: raw.birth_date,
fourthBirthday: raw.fourth_birthday,
totalDays: raw.total_days,
daysUsed: raw.days_used,
plannedDaysBeforeB4: raw.planned_days_before_b4,
projectedLeftAtB4: raw.projected_left_at_b4,
savableAfterB4: raw.savable_after_b4,
expiringAtB4: raw.expiring_at_b4,
savableCapAtB4: raw.savable_cap_at_b4,
yearBalances: raw.year_balances.map(toParentYearBalance),
}
}
export interface RawWorkspaceOverview {
workspace_id: string
child_id: string
parent_projections: RawAge4Projection[]
combined_days_used: number
combined_days_left: number
combined_expiring_at_b4: number
year_totals: RawYearTotals[]
}
function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
return {
workspaceId: raw.workspace_id,
childId: raw.child_id,
parentProjections: raw.parent_projections.map(toAge4Projection),
combinedDaysUsed: raw.combined_days_used,
combinedDaysLeft: raw.combined_days_left,
combinedExpiringAtB4: raw.combined_expiring_at_b4,
yearTotals: raw.year_totals.map(toYearTotals),
}
}
interface RawVacationDays {
id: string
personal_data_uuid: string
workspace_id: string
year: number
total_days: number
saved_days_in: number
created_at: string | null
updated_at: string | null
}
function toVacationDays(raw: RawVacationDays): VacationDays {
return {
id: raw.id,
personalDataUuid: raw.personal_data_uuid,
workspaceId: raw.workspace_id,
year: raw.year,
totalDays: raw.total_days,
savedDaysIn: raw.saved_days_in,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
interface RawPlannedLeaveDay {
id: string
workspace_id: string
child_id: string
parent_personal_data_uuid: string
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag: boolean
wish_id: string | null
is_pinned: boolean
created_at: string
updated_at: string
}
function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
date: raw.date,
dayType: raw.day_type,
isDubbeldag: raw.is_dubbeldag,
wishId: raw.wish_id,
isPinned: raw.is_pinned,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
export interface RawTip {
id: string
severity: 'info' | 'warning' | 'action'
message_key: string
data: Record<string, unknown>
anchor_date: string | null
anchor_range: [string, string] | null
}
function toTip(raw: RawTip): Tip {
return {
id: raw.id,
severity: raw.severity,
messageKey: raw.message_key,
data: raw.data,
anchorDate: raw.anchor_date,
anchorRange: raw.anchor_range,
}
}
interface RawLeaveWish {
id: string
workspace_id: string
child_id: string | null
name: string
name_message_key: string | null
window_start_month: number
window_start_day: number
window_end_month: number
window_end_day: number
target_weeks: number
target_days?: number | null
first_year: number
last_year: number | null
series_id: string | null
priority: number
parent_mode: 'either' | 'both' | 'specific'
parent_personal_data_uuid: string | null
window_mode: 'fixed' | 'flexible'
wants_dubbeldag?: boolean
created_at: string | null
updated_at: string | null
}
function toLeaveWish(raw: RawLeaveWish): LeaveWish {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
name: raw.name,
nameMessageKey: raw.name_message_key,
windowStartMonth: raw.window_start_month,
windowStartDay: raw.window_start_day,
windowEndMonth: raw.window_end_month,
windowEndDay: raw.window_end_day,
targetWeeks: raw.target_weeks,
targetDays: raw.target_days ?? null,
firstYear: raw.first_year,
seriesId: raw.series_id ?? null,
lastYear: raw.last_year,
priority: raw.priority,
parentMode: raw.parent_mode,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
windowMode: raw.window_mode ?? 'fixed',
wantsDubbeldag: raw.wants_dubbeldag ?? false,
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
wish_name_message_key: string | null
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,
wishNameMessageKey: o.wish_name_message_key,
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
created_at: string
take_pl_on_red_days: boolean
}
function toWorkspace(raw: RawWorkspace): Workspace {
return {
id: raw.id,
name: raw.name,
createdAt: raw.created_at,
takePlOnRedDays: raw.take_pl_on_red_days,
}
}
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,
}
}
interface RawPlannedDayExclusion {
id: string
workspace_id: string
child_id: string
parent_personal_data_uuid: string
date: string
created_at: string | null
updated_at: string | null
}
function toPlannedDayExclusion(
raw: RawPlannedDayExclusion,
): import('@/types').PlannedDayExclusion {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
date: raw.date,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
let _accessToken: string | null = null
export function setAccessToken(token: string | null): void {
_accessToken = token
}
// Endpoints that must never trigger a refresh-and-retry on 401 — retrying
// them would either be nonsensical (the refresh call retrying itself) or
// mask the real "not logged in" outcome (login/logout).
const AUTH_NO_RETRY_PATHS = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
// Dedupe concurrent 401s (e.g. Dashboard's ~7 parallel requests all
// expiring at once) into a single in-flight refresh call, shared across
// every apiFetch caller.
let refreshPromise: Promise<TokenResponse> | null = null
// AuthContext registers this so a refresh-and-retry failure can log the
// user out immediately, instead of leaving `isAuthenticated: true` while
// every subsequent request 401s. Kept out of this module so api.ts stays
// framework-agnostic.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler
}
async function apiFetch<T>(
path: string,
options: RequestInit = {},
isRetry = false,
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
}
if (_accessToken) {
headers['Authorization'] = `Bearer ${_accessToken}`
}
const response = await fetch(`${BASE}${path}`, { ...options, headers })
if (response.status === 401 && !isRetry && !AUTH_NO_RETRY_PATHS.has(path)) {
let refreshed: TokenResponse | null = null
try {
refreshPromise ??= refreshToken().finally(() => {
refreshPromise = null
})
refreshed = await refreshPromise
} catch {
// Refresh itself failed (expired/invalid refresh cookie, or a
// network error) — clear the token and let the caller's original
// 401 surface below so AuthContext can log the user out.
setAccessToken(null)
onUnauthorized?.()
}
if (refreshed) {
setAccessToken(refreshed.access_token)
// Retry exactly once, outside the try/catch above — `isRetry: true`
// means a 401 on this attempt is final and must propagate as-is
// rather than being caught here and re-reported as the original
// (now stale) 401.
return await apiFetch<T>(path, options, true)
}
}
if (!response.ok) {
let error: { code: string; message: string; details?: Record<string, unknown> } = {
code: String(response.status),
message: response.statusText,
}
try {
const body = (await response.json()) as {
detail?:
| string
| Array<{ msg?: string; loc?: unknown[] }>
| { message_key: string; data?: Record<string, unknown> }
code?: string
message?: string
}
if (
body.detail &&
typeof body.detail === 'object' &&
!Array.isArray(body.detail) &&
'message_key' in body.detail
) {
// Structured `{message_key, data}` error (see workspace.py's
// exception mapping) — kept out of `message` so callers never
// accidentally render the raw key; `details.messageKey`/`data` is
// the contract callers (e.g. Settings.tsx's `describePartnerError`)
// read instead.
const structured = body.detail
error = {
code: structured.message_key,
message: structured.message_key,
details: { messageKey: structured.message_key, data: structured.data ?? {} },
}
} else {
const detail = Array.isArray(body.detail)
? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ')
: body.detail
// FastAPI's default pydantic 422 shape puts the offending field name
// in each error's `loc` (e.g. ['body', 'sjukpenning_left']), not in
// `msg` — surface the last segment of each `loc` as `details.fields`
// so a caller (e.g. ParentBalanceForm) can map the failure back to
// one input instead of only showing the raw message.
const fields = Array.isArray(body.detail)
? body.detail
.map((e) => (Array.isArray(e.loc) ? e.loc[e.loc.length - 1] : undefined))
.filter((f): f is string => typeof f === 'string')
: []
error = {
code: body.code ?? String(response.status),
message: detail ?? body.message ?? response.statusText,
...(fields.length > 0 ? { details: { fields } } : {}),
}
}
} catch {
// response body wasn't JSON — use status text
}
throw new ApiRequestError(response.status, error)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export async function register(req: RegisterRequest): Promise<TokenResponse> {
return apiFetch('/auth/register', {
method: 'POST',
body: JSON.stringify({
email: req.email,
password: req.password,
display_name: req.displayName,
// Omitted (rather than sent as `undefined`) so the backend's
// "no invite" default (`None`) applies untouched.
...(req.inviteToken !== undefined ? { invite_token: req.inviteToken } : {}),
}),
})
}
export async function login(req: LoginRequest): Promise<TokenResponse> {
return apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email: req.email, password: req.password }),
})
}
export async function logout(): Promise<void> {
return apiFetch('/auth/logout', { method: 'POST' })
}
export async function refreshToken(): Promise<TokenResponse> {
return apiFetch('/auth/refresh', { method: 'POST' })
}
export interface UserContext {
personal_data_uuid: string
workspace_id: string
parent_name: string
children: Array<{ id: string; name: string; birth_date: string }>
}
export async function getUserContext(): Promise<UserContext> {
return apiFetch('/auth/context')
}
export interface CreateChildRequest {
workspace_id: string
name: string
birth_date: string
}
export async function createChild(req: CreateChildRequest): Promise<{ id: string; name: string }> {
return apiFetch('/children', {
method: 'POST',
body: JSON.stringify(req),
})
}
export async function getChild(childId: string): Promise<Child> {
const raw = (await apiFetch(`/children/${childId}`)) as RawChild
return toChild(raw)
}
// ---------------------------------------------------------------------------
// Workspace
// ---------------------------------------------------------------------------
export async function createWorkspace(req: WorkspaceCreateRequest): Promise<Workspace> {
return apiFetch('/workspace', { method: 'POST', body: JSON.stringify({ name: req.name }) })
}
export async function getWorkspace(workspaceId: string): Promise<Workspace> {
const raw = (await apiFetch(`/workspace/${workspaceId}`)) as RawWorkspace
return toWorkspace(raw)
}
export async function updateWorkspace(
workspaceId: string,
req: WorkspaceUpdateRequest,
): Promise<Workspace> {
const raw = (await apiFetch(`/workspace/${workspaceId}`, {
method: 'PUT',
body: JSON.stringify({
name: req.name,
take_pl_on_red_days: req.takePlOnRedDays,
}),
})) as RawWorkspace
return toWorkspace(raw)
}
export async function addPartner(
workspaceId: string,
displayName: string,
): Promise<{ uuid: string; name: string; salary_type: string; employment_status: string }> {
return apiFetch(`/workspace/${workspaceId}/parents`, {
method: 'POST',
body: JSON.stringify({ display_name: displayName }),
})
}
// ---------------------------------------------------------------------------
// Vacation days (reference endpoint group — mirror this pattern for others)
// ---------------------------------------------------------------------------
export async function upsertVacationDays(req: VacationDaysCreateRequest): Promise<VacationDays> {
return apiFetch('/vacation-days', {
method: 'POST',
body: JSON.stringify({
personal_data_uuid: req.personalDataUuid,
workspace_id: req.workspaceId,
year: req.year,
total_days: req.totalDays,
saved_days_in: req.savedDaysIn ?? 0,
}),
})
}
export async function getVacationDaysByWorkspace(workspaceId: string): Promise<VacationDays[]> {
const raw = (await apiFetch(`/vacation-days/workspace/${workspaceId}`)) as RawVacationDays[]
return raw.map(toVacationDays)
}
// ---------------------------------------------------------------------------
// Parental leave
// ---------------------------------------------------------------------------
export async function saveManualFetch(req: ManualFetchRequest): Promise<ParentalLeaveFetch> {
// Optional keys are omitted entirely (rather than sent as `undefined`,
// which `JSON.stringify` would drop anyway but less explicitly) so the
// backend's "null/omitted = derive it" defaulting kicks in — see
// `ManualFetchRequest`'s doc comment.
const body: Record<string, unknown> = {
personal_data_uuid: req.personalDataUuid,
workspace_id: req.workspaceId,
child_id: req.childId,
sjukpenning_left: req.sjukpenningLeft,
lagsta_niva_left: req.lagstaNivaLeft,
dubbeldagar_left: req.dubbeldagarLeft,
}
if (req.reservedDaysLeft !== undefined) body.reserved_days_left = req.reservedDaysLeft
if (req.totalDays !== undefined) body.total_days = req.totalDays
if (req.source !== undefined) body.source = req.source
const raw = (await apiFetch('/parental-leave/manual', {
method: 'POST',
body: JSON.stringify(body),
})) as RawParentalLeaveFetch
return toParentalLeaveFetch(raw)
}
// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
export async function getWorkspaceOverview(
workspaceId: string,
childId: string,
): Promise<WorkspaceOverview> {
const raw = (await apiFetch(`/analytics/${workspaceId}/overview?child_id=${childId}`)) as RawWorkspaceOverview
return toWorkspaceOverview(raw)
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
interface ParentSettingsUpdate {
salary_type?: string | undefined
employment_status?: string | undefined
}
export async function updateParentSettings(
personalDataUuid: string,
payload: ParentSettingsUpdate,
): Promise<{ uuid: string; name: string; salary_type: string; employment_status: string }> {
return apiFetch(`/settings/parent/${personalDataUuid}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
interface ChildSettingsUpdate {
birth_date?: string
preschool_start_date?: string
preschool_planning_days_per_year?: number
}
export async function updateChildSettings(
childId: string,
payload: ChildSettingsUpdate,
): Promise<Child> {
const raw = (await apiFetch(`/settings/child/${childId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})) as RawChild
return toChild(raw)
}
// ---------------------------------------------------------------------------
// Leave wishes ("N weeks off inside this yearly window") + planning engine
// ---------------------------------------------------------------------------
export async function getLeaveWishes(workspaceId: string): Promise<LeaveWish[]> {
const raw = (await apiFetch(`/leave-wishes/${workspaceId}`)) as RawLeaveWish[]
return raw.map(toLeaveWish)
}
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,
name_message_key: payload.nameMessageKey ?? null,
window_start_month: payload.windowStartMonth,
window_start_day: payload.windowStartDay,
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,
priority: payload.priority ?? 1,
parent_mode: payload.parentMode ?? 'either',
parent_personal_data_uuid: payload.parentPersonalDataUuid ?? null,
window_mode: payload.windowMode ?? 'fixed',
series_id: payload.seriesId ?? null,
wants_dubbeldag: payload.wantsDubbeldag ?? false,
}),
})) as RawLeaveWish
return toLeaveWish(raw)
}
/** `scope` decides how far the edit reaches on a repeating wish — see
* `LeaveWishScope`. Omitted means "this year only", matching the backend's
* own default, so existing single-occurrence callers need no change. */
export async function updateLeaveWish(
id: string,
payload: LeaveWishUpdateRequest,
scope: LeaveWishScope = 'occurrence',
): 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.nameMessageKey !== undefined) body.name_message_key = payload.nameMessageKey
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.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
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.windowMode !== undefined) body.window_mode = payload.windowMode
if (payload.wantsDubbeldag !== undefined) body.wants_dubbeldag = payload.wantsDubbeldag
const raw = (await apiFetch(`/leave-wishes/${id}?scope=${scope}`, {
method: 'PUT',
body: JSON.stringify(body),
})) as RawLeaveWish
return toLeaveWish(raw)
}
/** `scope: 'series'` removes every year of a repeating wish; the default
* removes just this one year's row. */
export async function deleteLeaveWish(
id: string,
scope: LeaveWishScope = 'occurrence',
): Promise<void> {
return apiFetch(`/leave-wishes/${id}?scope=${scope}`, { 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,
}
}
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)
// ---------------------------------------------------------------------------
export async function listWorkspaceMembers(workspaceId: string): Promise<PersonalData[]> {
const raw = (await apiFetch(`/workspace/${workspaceId}/members`)) as RawPersonalData[]
return raw.map(toPersonalData)
}
/** Renames a workspace member (partner or the logged-in user themselves —
* the backend deliberately allows self-rename through this route). */
export async function renameWorkspaceParent(
workspaceId: string,
personalDataUuid: string,
displayName: string,
): Promise<PersonalData> {
const raw = (await apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, {
method: 'PATCH',
body: JSON.stringify({ display_name: displayName }),
})) as RawPersonalData
return toPersonalData(raw)
}
/** Soft-deletes a workspace member. Refused (422) for the caller's own
* profile, a member with their own login, or one with dependent data —
* see `describePartnerError` in `Settings.tsx` for how those surface. */
export async function removeWorkspaceParent(
workspaceId: string,
personalDataUuid: string,
): Promise<void> {
return apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, {
method: 'DELETE',
})
}
// ---------------------------------------------------------------------------
// Tips
// ---------------------------------------------------------------------------
export async function getTips(workspaceId: string): Promise<Tip[]> {
const raw = (await apiFetch(`/tips/${workspaceId}`)) as RawTip[]
return raw.map(toTip)
}
export async function dismissTip(workspaceId: string, tipId: string): Promise<void> {
return apiFetch(`/tips/${workspaceId}/dismissals`, {
method: 'POST',
body: JSON.stringify({ tip_id: tipId }),
})
}
// ---------------------------------------------------------------------------
// Parental Leave Fetch
// ---------------------------------------------------------------------------
export async function getParentalLeave(
personalDataUuid: string,
childId: string,
): Promise<ParentalLeaveFetch | null> {
// The backend returns `null` (200 OK) rather than 404 when nothing has
// been saved yet for this parent/child — a brand-new partner, or self
// before any fetch. `toParentalLeaveFetch` assumes a real record, so it
// must not be called on that `null`.
const raw = (await apiFetch(
`/parental-leave/${personalDataUuid}/${childId}`,
)) as RawParentalLeaveFetch | null
return raw ? toParentalLeaveFetch(raw) : null
}
// ---------------------------------------------------------------------------
// Planned Days
// ---------------------------------------------------------------------------
export async function getPlannedDays(childId?: string): Promise<PlannedLeaveDay[]> {
const query = childId ? `?child_id=${childId}` : ''
const raw = (await apiFetch(`/planned-days${query}`)) as RawPlannedLeaveDay[]
return raw.map(toPlannedLeaveDay)
}
export interface CreatePlannedDayPayload {
workspace_id: string
child_id: string
parent_personal_data_uuid: string
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> {
const raw = (await apiFetch(`/planned-days`, {
method: 'POST',
body: JSON.stringify(payload),
})) as RawPlannedLeaveDay
return toPlannedLeaveDay(raw)
}
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> {
const raw = (await apiFetch(`/planned-days/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})) as RawPlannedLeaveDay
return toPlannedLeaveDay(raw)
}
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' })
}
// ---------------------------------------------------------------------------
// Planned-day exclusions — the days the user removed by hand and the planner
// must therefore leave empty on every subsequent sync.
// ---------------------------------------------------------------------------
export async function createPlannedDayExclusion(
req: import('@/types').PlannedDayExclusionCreateRequest,
): Promise<import('@/types').PlannedDayExclusion> {
const raw = (await apiFetch('/planned-day-exclusions', {
method: 'POST',
body: JSON.stringify({
workspace_id: req.workspaceId,
child_id: req.childId,
parent_personal_data_uuid: req.parentPersonalDataUuid,
date: req.date,
}),
})) as RawPlannedDayExclusion
return toPlannedDayExclusion(raw)
}
export async function listPlannedDayExclusionsByWorkspace(
workspaceId: string,
): Promise<import('@/types').PlannedDayExclusion[]> {
const raw = (await apiFetch(
`/planned-day-exclusions/workspace/${workspaceId}`,
)) as RawPlannedDayExclusion[]
return raw.map(toPlannedDayExclusion)
}
export async function deletePlannedDayExclusion(id: string): Promise<void> {
return apiFetch(`/planned-day-exclusions/${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)
// ---------------------------------------------------------------------------
interface ScraperStartRequest {
workspace_id?: string
personal_data_uuid?: string
child_id?: string
}
interface ScraperStartResponse {
session_id: string
status: string
workspace_id: string
personal_data_uuid: string
child_id: string
}
export async function startScraperSession(
payload: ScraperStartRequest,
): Promise<ScraperStartResponse> {
return apiFetch('/fetch-data/start', { method: 'POST', body: JSON.stringify(payload) })
}
export async function pollScraperSession(sessionId: string): Promise<unknown> {
return apiFetch(`/fetch-data/poll?session_id=${encodeURIComponent(sessionId)}`)
}
export async function cancelScraperSession(sessionId: string): Promise<void> {
return apiFetch('/fetch-data/cancel', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
})
}
// ---------------------------------------------------------------------------
// Preschool planning days ("studiedagar") — /api/preschool-planning-days
// ---------------------------------------------------------------------------
interface RawPreschoolPlanningDays {
id: string | null
child_id: string
year: number
planned_days: number
taken_days: number | null
is_default: boolean
created_at: string | null
updated_at: string | null
}
function toPreschoolPlanningDays(raw: RawPreschoolPlanningDays): PreschoolPlanningDays {
return {
id: raw.id,
childId: raw.child_id,
year: raw.year,
plannedDays: raw.planned_days,
takenDays: raw.taken_days,
isDefault: raw.is_default,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
export async function upsertPreschoolPlanningDaysPlanned(
childId: string,
year: number,
req: PreschoolPlanningDaysPlannedRequest,
): Promise<PreschoolPlanningDays> {
const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/planned`, {
method: 'PUT',
body: JSON.stringify({ planned_days: req.plannedDays }),
})) as RawPreschoolPlanningDays
return toPreschoolPlanningDays(raw)
}
export async function recordPreschoolPlanningDaysTaken(
childId: string,
year: number,
req: PreschoolPlanningDaysTakenRequest,
): Promise<PreschoolPlanningDays> {
const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/taken`, {
method: 'PUT',
body: JSON.stringify({ taken_days: req.takenDays }),
})) as RawPreschoolPlanningDays
return toPreschoolPlanningDays(raw)
}
export async function getPreschoolPlanningDaysYear(
childId: string,
year: number,
): Promise<PreschoolPlanningDays> {
const raw = (await apiFetch(
`/preschool-planning-days/${childId}/${year}`,
)) as RawPreschoolPlanningDays
return toPreschoolPlanningDays(raw)
}
export async function listPreschoolPlanningDays(childId: string): Promise<PreschoolPlanningDays[]> {
const raw = (await apiFetch(`/preschool-planning-days/${childId}`)) as RawPreschoolPlanningDays[]
return raw.map(toPreschoolPlanningDays)
}
// ---------------------------------------------------------------------------
// Invitations — /api/workspace/{id}/invitations, /api/invitations/*
// ---------------------------------------------------------------------------
interface RawInvitation {
id: string
workspace_id: string
status: 'pending' | 'accepted' | 'revoked' | 'expired'
expires_at: string
created_at: string
}
function toInvitation(raw: RawInvitation): Invitation {
return {
id: raw.id,
workspaceId: raw.workspace_id,
status: raw.status,
expiresAt: raw.expires_at,
createdAt: raw.created_at,
}
}
/** Creates a one-time invite for `workspaceId`; the raw `token` is returned
* once and never stored server-side — share it out-of-band (the backend
* only ever persists its hash). */
export async function createInvitation(workspaceId: string): Promise<CreateInvitationResponse> {
return apiFetch(`/workspace/${workspaceId}/invitations`, { method: 'POST' })
}
/** Lists this workspace's invitations. Never includes `token_hash` — the
* backend omits it from the wire response entirely. */
export async function listInvitations(workspaceId: string): Promise<Invitation[]> {
const raw = (await apiFetch(`/workspace/${workspaceId}/invitations`)) as RawInvitation[]
return raw.map(toInvitation)
}
export async function redeemInvitation(token: string): Promise<Invitation> {
const raw = (await apiFetch('/invitations/redeem', {
method: 'POST',
body: JSON.stringify({ token }),
})) as RawInvitation
return toInvitation(raw)
}
/** The caller's own workspace is inferred server-side from their session —
* no workspace id is sent. */
export async function revokeInvitation(invitationId: string): Promise<void> {
return apiFetch(`/invitations/${invitationId}`, { method: 'DELETE' })
}