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:
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Age4Bars } from './Age4Bars'
|
||||
|
||||
describe('Age4Bars', () => {
|
||||
it('renders days (never weeks) against each parent\'s own savable cap', () => {
|
||||
render(
|
||||
<Age4Bars
|
||||
bars={[
|
||||
{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 40, savableCapAtB4: 48, expiringAtB4: 0 },
|
||||
{ personalDataUuid: 'pd-2', name: 'Sam', projectedLeftAtB4: 239, savableCapAtB4: 48, expiringAtB4: 191 },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('40 av 48 dagar kan sparas')
|
||||
expect(screen.getByTestId('age4-bar-savable-pd-2')).toHaveTextContent('48 av 48 dagar kan sparas')
|
||||
expect(screen.getByTestId('age4-bar-expiring-pd-2')).toHaveTextContent('191 dagar förfaller')
|
||||
expect(screen.queryByTestId('age4-bar-expiring-pd-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a resolved parent name, never a raw uuid', () => {
|
||||
render(
|
||||
<Age4Bars
|
||||
bars={[
|
||||
{ personalDataUuid: 'pd-1', name: 'Förälder 1', projectedLeftAtB4: 10, savableCapAtB4: 96, expiringAtB4: 0 },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Förälder 1')
|
||||
expect(screen.getByTestId('age4-bar-pd-1')).not.toHaveTextContent('pd-1')
|
||||
})
|
||||
|
||||
it('sets fill width as a percentage of the savable cap, capped at 100%', () => {
|
||||
render(
|
||||
<Age4Bars
|
||||
bars={[
|
||||
{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 24, savableCapAtB4: 48, expiringAtB4: 0 },
|
||||
{ personalDataUuid: 'pd-2', name: 'Sam', projectedLeftAtB4: 300, savableCapAtB4: 96, expiringAtB4: 204 },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('age4-bar-fill-pd-1')).toHaveStyle({ width: '50%' })
|
||||
expect(screen.getByTestId('age4-bar-fill-pd-2')).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('renders one row per bar, in the given order', () => {
|
||||
render(
|
||||
<Age4Bars
|
||||
bars={[
|
||||
{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 10, savableCapAtB4: 48, expiringAtB4: 0 },
|
||||
{ personalDataUuid: 'pd-2', name: 'Sam', projectedLeftAtB4: 10, savableCapAtB4: 48, expiringAtB4: 0 },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId(/^age4-bar-pd-/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders nothing but the title when there are no bars', () => {
|
||||
render(<Age4Bars bars={[]} />)
|
||||
expect(screen.getByTestId('age4-bars')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/av .* dagar kan sparas/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a zero-width fill instead of dividing by zero when the savable cap is 0', () => {
|
||||
render(
|
||||
<Age4Bars
|
||||
bars={[{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 10, savableCapAtB4: 0, expiringAtB4: 10 }]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('age4-bar-fill-pd-1')).toHaveStyle({ width: '0%' })
|
||||
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('0 av 0 dagar kan sparas')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
|
||||
/** One parent's bar — already resolved to a display name (never a raw
|
||||
* UUID; see `nameResolver`/the "Förälder N" fallback in `Dashboard.tsx`) and
|
||||
* carrying the backend-resolved `savableCapAtB4` (48 per parent in a
|
||||
* `couple` workspace with two parents, 96 otherwise — see
|
||||
* `Age4Projection.savableCapAtB4`). Never re-derive that cap here.
|
||||
*/
|
||||
export interface Age4BarDatum {
|
||||
personalDataUuid: string
|
||||
name: string
|
||||
projectedLeftAtB4: number
|
||||
savableCapAtB4: number
|
||||
expiringAtB4: number
|
||||
}
|
||||
|
||||
export interface Age4BarsProps {
|
||||
bars: Age4BarDatum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-parent "days left at the 4th birthday" bars — measured in **days**
|
||||
* against that parent's own savable cap, replacing the old `ProgressChart`
|
||||
* (which rendered `Math.floor(days / 5)` as "weeks" and was never wired to
|
||||
* the age-4 cap at all).
|
||||
*/
|
||||
export function Age4Bars({ bars }: Age4BarsProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div data-testid="age4-bars" className="age4-bars">
|
||||
<h3 className="age4-bars__title">{t('age4Bars.title')}</h3>
|
||||
{bars.map((bar) => {
|
||||
const savable = Math.min(bar.projectedLeftAtB4, bar.savableCapAtB4)
|
||||
const percentage = bar.savableCapAtB4 > 0 ? Math.min((savable / bar.savableCapAtB4) * 100, 100) : 0
|
||||
return (
|
||||
<div key={bar.personalDataUuid} className="age4-bars__row" data-testid={`age4-bar-${bar.personalDataUuid}`}>
|
||||
<div className="age4-bars__label">{bar.name}</div>
|
||||
<div className="age4-bars__track">
|
||||
<div
|
||||
className="age4-bars__fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
data-testid={`age4-bar-fill-${bar.personalDataUuid}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="age4-bars__stats">
|
||||
<span data-testid={`age4-bar-savable-${bar.personalDataUuid}`}>
|
||||
{t('age4Bars.savableStat', { left: savable, cap: bar.savableCapAtB4 })}
|
||||
</span>
|
||||
{bar.expiringAtB4 > 0 && (
|
||||
<span
|
||||
className="age4-bars__expiring"
|
||||
data-testid={`age4-bar-expiring-${bar.personalDataUuid}`}
|
||||
>
|
||||
{t('age4Bars.expiringStat', { expiring: bar.expiringAtB4 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Hero } from './Hero'
|
||||
|
||||
describe('Hero', () => {
|
||||
it('uses the forward-looking framing when no plan exists yet', () => {
|
||||
render(
|
||||
<Hero hasPlan={false} daysLeft={480} daysAtRisk={384} childName="Nova" fourthBirthday="2029-06-01" />,
|
||||
)
|
||||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
|
||||
'Du har 480 dagar att planera. Utan en plan försvinner 384 av dem när Nova fyller 4.',
|
||||
)
|
||||
expect(screen.getByTestId('dashboard-hero')).toHaveClass('dashboard-hero--motivate')
|
||||
expect(screen.getByTestId('dashboard-hero')).not.toHaveAttribute('role', 'alert')
|
||||
})
|
||||
|
||||
it('uses the warning framing once a plan exists', () => {
|
||||
render(
|
||||
<Hero hasPlan={true} daysLeft={200} daysAtRisk={191} childName="Nova" fourthBirthday="2029-06-01" />,
|
||||
)
|
||||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
|
||||
'191 dagar riskerar att försvinna när Nova fyller 4 (1 juni 2029).',
|
||||
)
|
||||
expect(screen.getByTestId('dashboard-hero')).toHaveClass('dashboard-hero--warning')
|
||||
expect(screen.getByTestId('dashboard-hero')).toHaveAttribute('role', 'alert')
|
||||
})
|
||||
|
||||
it('renders the same daysAtRisk figure in both framings without recomputing it', () => {
|
||||
const { rerender } = render(
|
||||
<Hero hasPlan={false} daysLeft={480} daysAtRisk={104} childName="Nova" fourthBirthday="2029-06-01" />,
|
||||
)
|
||||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('104')
|
||||
|
||||
rerender(<Hero hasPlan={true} daysLeft={480} daysAtRisk={104} childName="Nova" fourthBirthday="2029-06-01" />)
|
||||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('104')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
|
||||
export interface HeroProps {
|
||||
/** True once at least one day has actually been planned for this child. */
|
||||
hasPlan: boolean
|
||||
/** Days still available to plan (total minus used) — the forward-looking framing's "N". */
|
||||
daysLeft: number
|
||||
/** `combinedExpiringAtB4` — days projected to expire at the 4th birthday
|
||||
* given the current plan (zero planned days, in the no-plan framing). */
|
||||
daysAtRisk: number
|
||||
childName: string
|
||||
/** ISO-8601 date. */
|
||||
fourthBirthday: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline: same arithmetic (`daysAtRisk` = `combinedExpiringAtB4`), two
|
||||
* framings depending on whether anything has been planned yet — a forward-
|
||||
* looking nudge with nothing planned (the entire entitlement is technically
|
||||
* "at risk" on day one, which reads as an accusation before the user has
|
||||
* done anything), a plain warning once a plan exists.
|
||||
*/
|
||||
export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday }: HeroProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const message = hasPlan
|
||||
? t('dashboard.hero.hasPlanMessage', {
|
||||
atRisk: daysAtRisk,
|
||||
childName,
|
||||
date: formatDateForDisplay(fourthBirthday),
|
||||
})
|
||||
: t('dashboard.hero.noPlanMessage', {
|
||||
days: daysLeft,
|
||||
atRisk: daysAtRisk,
|
||||
childName,
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="dashboard-hero"
|
||||
className={`dashboard-hero ${hasPlan ? 'dashboard-hero--warning' : 'dashboard-hero--motivate'}`}
|
||||
role={hasPlan ? 'alert' : undefined}
|
||||
>
|
||||
<p className="dashboard-hero__message" data-testid="dashboard-hero-message">
|
||||
{message}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PlanFold } from './PlanFold'
|
||||
import * as hooks from '@/hooks'
|
||||
import {
|
||||
makeLeaveWishesResult,
|
||||
makePlanProposalResult,
|
||||
makePersonalData,
|
||||
makeLeaveWish,
|
||||
makePlanProposal,
|
||||
makeWindowOutcome,
|
||||
makeSurplusAdvice,
|
||||
makePoolBalance,
|
||||
makeVacationBalance,
|
||||
} from '../../../tests/fixtures'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
|
||||
const members = [
|
||||
makePersonalData({ uuid: 'pd-a', name: 'Alex' }),
|
||||
makePersonalData({ uuid: 'pd-b', name: 'Sam' }),
|
||||
]
|
||||
|
||||
describe('PlanFold', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [makeLeaveWish()] }))
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
|
||||
})
|
||||
|
||||
it('is collapsed by default (a native <details> element)', () => {
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const details = screen.getByTestId('plan-fold')
|
||||
expect(details.tagName).toBe('DETAILS')
|
||||
expect(details).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('shows a hint instead of a proposal when there are no wishes', () => {
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-no-wishes')).toHaveTextContent(
|
||||
'Lägg till önskemål under Planering för att se ett förslag här.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the proposal error when the read-only propose call fails', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ error: 'Kunde inte beräkna förslag' }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-proposal-error')).toHaveTextContent('Kunde inte beräkna förslag')
|
||||
})
|
||||
|
||||
it('auto-proposes once wishes are loaded, without a manual button press', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose again once a proposal is already loaded', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ propose, data: makePlanProposal() }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose while there are no wishes yet', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose while wishes are still loading', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ isLoading: true, data: [] }))
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose again while a propose call is already in flight', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose, isLoading: true }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders per-period wished-vs-funded from the proposal', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2026, weeksWished: 6, weeksFunded: 4 })],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-overview-year-2026')).toHaveTextContent('Sommar: 4/6 veckor')
|
||||
})
|
||||
|
||||
it('shows the empty-horizon message when the proposal has no outcomes', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ data: makePlanProposal({ outcomes: [] }) }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-overview')).toHaveTextContent('Inga fönster inom planeringshorisonten.')
|
||||
})
|
||||
|
||||
it('renders surplus advice with a resolved parent name, never a raw uuid', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
surplusAdvice: [
|
||||
makeSurplusAdvice({
|
||||
kind: 'wish_more_leave',
|
||||
messageKey: 'planSurplusAdvice.wish_more_leave',
|
||||
daysInvolved: 12,
|
||||
data: { parent_personal_data_uuid: 'pd-b', child_id: 'child-1', weeks: 2 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
|
||||
expect(advice).toHaveTextContent('Sam')
|
||||
expect(advice).not.toHaveTextContent('pd-b')
|
||||
})
|
||||
|
||||
it('falls back to a generic label for surplus advice when no member matches', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
surplusAdvice: [
|
||||
makeSurplusAdvice({
|
||||
kind: 'wish_more_leave',
|
||||
messageKey: 'planSurplusAdvice.wish_more_leave',
|
||||
daysInvolved: 12,
|
||||
data: { parent_personal_data_uuid: 'pd-unknown', child_id: 'child-1', weeks: 2 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
|
||||
expect(advice).not.toHaveTextContent('pd-unknown')
|
||||
})
|
||||
|
||||
it('renders an unrecognised surplus advice kind/messageKey by falling back to the raw value', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
surplusAdvice: [
|
||||
makeSurplusAdvice({
|
||||
kind: 'brand_new_kind',
|
||||
messageKey: 'planSurplusAdvice.brand_new_kind',
|
||||
data: {},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
|
||||
expect(advice).toHaveTextContent('brand_new_kind')
|
||||
})
|
||||
|
||||
it('does not render a surplus-advice section when there is none', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ data: makePlanProposal({ surplusAdvice: [] }) }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.queryByTestId('plan-fold-surplus-advice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders pool and vacation balance tables', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
poolBalances: [makePoolBalance({ parentPersonalDataUuid: 'pd-a', sjukpenningLeft: 300, lagstaLeft: 80, dubbeldagarLeft: 50 })],
|
||||
vacationBalances: [makeVacationBalance({ parentPersonalDataUuid: 'pd-a', year: 2026, alreadyUsed: 10, totalDays: 25, savedDaysIn: 0 })],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('Alex')
|
||||
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('300')
|
||||
expect(screen.getByTestId('plan-fold-vacation-balances')).toHaveTextContent('25')
|
||||
})
|
||||
|
||||
it('renders nothing beyond the summary/hint when there is no proposal yet', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ data: null }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.queryByTestId('plan-fold-overview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plan-fold-balances')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Folds the wished-vs-funded/surplus/balance content that used to live only
|
||||
* on the Plan tab's `PlanResults.tsx` into Översikt, collapsed below the
|
||||
* fold. Read `PlanResults.tsx` for the reference implementation; this is a
|
||||
* separate, trimmed-down component (not a shared import) because
|
||||
* `PlanResults.tsx` is scheduled for deletion by another task and must not
|
||||
* be edited here.
|
||||
*
|
||||
* Read-only. The calendar's `POST /api/plan/sync` (see `Calendar.tsx`) is
|
||||
* now the only way a plan is written — it proposes and persists in one
|
||||
* step, so there is no separate "propose, review, then apply" flow left
|
||||
* anywhere in the app, and this fold never mutates anything. It still calls
|
||||
* `usePlanProposal` (`POST /api/plan/propose`) to get the wished-vs-funded
|
||||
* figures, surplus advice and balance tables below, because that data has
|
||||
* no other source: there is no GET endpoint for "the current plan" shaped
|
||||
* like a proposal, and `/api/plan/propose` is documented read-only —
|
||||
* "persists nothing, safe to spam" (`backend/app/routers/plan.py`).
|
||||
*
|
||||
* Dropped relative to `PlanResults.tsx` (kept there / on the Plan tab
|
||||
* instead, to avoid duplicating content the Hero and `Age4Bars` already
|
||||
* cover): the warnings section, the age4Projections table, and the
|
||||
* per-window explanation/remedy detail cards. Also dropped, as of the move
|
||||
* to `/api/plan/sync`: the apply button, collision-confirmation UI, and
|
||||
* rejection rendering — applying is no longer a thing this fold does (see
|
||||
* `frontend/src/utils/rejectionMessages.ts` for where rejection rendering
|
||||
* now lives, for the sync toast in `Calendar.tsx`).
|
||||
*/
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import type { PersonalData, SurplusAdvice, WindowOutcome } from '@/types'
|
||||
import { useLeaveWishes, usePlanProposal } from '@/hooks'
|
||||
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
|
||||
import { nameResolver } from '@/utils/parentNames'
|
||||
import { resolveWishName } from '@/utils/wishName'
|
||||
|
||||
type Translate = (key: TranslationKey, params?: TranslationParams) => string
|
||||
|
||||
// §9.2 surplus-advice kinds — mirrors `PlanResults.tsx`'s `SURPLUS_ADVICE_KEYS`/
|
||||
// `SURPLUS_ADVICE_BODY_KEYS` (the two catalogues are independent copies, not
|
||||
// a shared import — see the file banner above).
|
||||
const SURPLUS_ADVICE_KEYS: Record<string, TranslationKey> = {
|
||||
wish_more_leave: 'planResults.surplusAdviceWishMoreLeave',
|
||||
shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp',
|
||||
front_load_early_years: 'planResults.surplusAdviceFrontLoadEarlyYears',
|
||||
use_less_vacation: 'planResults.surplusAdviceUseLessVacation',
|
||||
}
|
||||
|
||||
const SURPLUS_ADVICE_BODY_KEYS: Record<string, TranslationKey> = {
|
||||
'planSurplusAdvice.wish_more_leave': 'planResults.surplusAdviceBodyWishMoreLeave',
|
||||
'planSurplusAdvice.shift_mix_to_fp': 'planResults.surplusAdviceBodyShiftMixToFp',
|
||||
'planSurplusAdvice.front_load_early_years': 'planResults.surplusAdviceBodyFrontLoadEarlyYears',
|
||||
'planSurplusAdvice.use_less_vacation': 'planResults.surplusAdviceBodyUseLessVacation',
|
||||
}
|
||||
|
||||
function nameForSafe(members: PersonalData[], t: Translate): (uuid: string) => string {
|
||||
const resolve = nameResolver(members)
|
||||
return (uuid: string) => {
|
||||
const name = resolve(uuid)
|
||||
return name === uuid ? t('dashboard.planFold.unknownParent') : name
|
||||
}
|
||||
}
|
||||
|
||||
function renderSurplusAdviceBody(advice: SurplusAdvice, t: Translate, nameFor: (uuid: string) => string): string {
|
||||
const key = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey]
|
||||
if (!key) return advice.messageKey
|
||||
const params = toTranslationParams(advice.data)
|
||||
const uuid = advice.data.parent_personal_data_uuid
|
||||
if (typeof uuid === 'string') {
|
||||
params.parent_name = nameFor(uuid)
|
||||
}
|
||||
params.days_involved = advice.daysInvolved
|
||||
return t(key, params)
|
||||
}
|
||||
|
||||
function groupByYear(outcomes: WindowOutcome[]): Array<{ year: number; outcomes: WindowOutcome[] }> {
|
||||
const years = Array.from(new Set(outcomes.map((o) => o.year))).sort((a, b) => a - b)
|
||||
return years.map((year) => ({
|
||||
year,
|
||||
outcomes: outcomes.filter((o) => o.year === year).sort((a, b) => a.startDate.localeCompare(b.startDate)),
|
||||
}))
|
||||
}
|
||||
|
||||
export interface PlanFoldProps {
|
||||
workspaceId: string
|
||||
members: PersonalData[]
|
||||
}
|
||||
|
||||
export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspaceId)
|
||||
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspaceId)
|
||||
const nameFor = nameForSafe(members, t)
|
||||
|
||||
// Auto-propose once wishes are available — this section is read-only-by-
|
||||
// default (collapsed), so nothing should require the user to first find
|
||||
// and press a separate "suggest" button just to see it. `propose` calls
|
||||
// the read-only `/api/plan/propose` endpoint — it persists nothing.
|
||||
useEffect(() => {
|
||||
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing) {
|
||||
void propose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wishesLoading, wishes.length, proposal])
|
||||
|
||||
return (
|
||||
<details className="plan-fold" data-testid="plan-fold">
|
||||
<summary className="plan-fold__summary" data-testid="plan-fold-summary">
|
||||
{t('dashboard.planFold.summary')}
|
||||
</summary>
|
||||
|
||||
<div className="plan-fold__content">
|
||||
{!wishesLoading && wishes.length === 0 && (
|
||||
<p className="plan-fold__hint" data-testid="plan-fold-no-wishes">
|
||||
{t('dashboard.planFold.noWishesHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{proposalError && (
|
||||
<div className="plan-fold__error" role="alert" data-testid="plan-fold-proposal-error">
|
||||
{proposalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal && (
|
||||
<>
|
||||
<section className="plan-fold__section" data-testid="plan-fold-overview">
|
||||
<h4>{t('planResults.yearOverviewTitle')}</h4>
|
||||
{proposal.outcomes.length === 0 ? (
|
||||
<p>{t('planResults.noWindowsInHorizon')}</p>
|
||||
) : (
|
||||
<table className="plan-overview-table" data-testid="plan-fold-overview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('planResults.yearHeader')}</th>
|
||||
<th>{t('planResults.leavesHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupByYear(proposal.outcomes).map(({ year, outcomes }) => (
|
||||
<tr key={year} data-testid={`plan-fold-overview-year-${year}`}>
|
||||
<td>{year}</td>
|
||||
<td>
|
||||
{outcomes.map((outcome) => (
|
||||
<span key={outcome.wishId} className="plan-overview-badge">
|
||||
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
|
||||
: {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{proposal.surplusAdvice.length > 0 && (
|
||||
<section className="plan-fold__section" data-testid="plan-fold-surplus-advice">
|
||||
<h4>{t('planResults.surplusAdviceTitle')}</h4>
|
||||
<p>{t('planResults.surplusAdviceIntro')}</p>
|
||||
<ul>
|
||||
{proposal.surplusAdvice.map((advice, idx) => {
|
||||
const titleKey = SURPLUS_ADVICE_KEYS[advice.kind]
|
||||
return (
|
||||
<li key={`${advice.kind}-${idx}`} data-testid={`plan-fold-surplus-advice-${idx}`}>
|
||||
<strong>{titleKey ? t(titleKey) : advice.kind}</strong>
|
||||
<p>{renderSurplusAdviceBody(advice, t, nameFor)}</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="plan-fold__section" data-testid="plan-fold-balances">
|
||||
<h4>{t('planResults.balancesTitle')}</h4>
|
||||
<table className="plan-balances-table" data-testid="plan-fold-pool-balances">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('planResults.parentHeader')}</th>
|
||||
<th>{t('planResults.sjukpenningLeftHeader')}</th>
|
||||
<th>{t('planResults.lagstaLeftHeader')}</th>
|
||||
<th>{t('planResults.dubbeldagarLeftHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proposal.poolBalances.map((pool) => (
|
||||
<tr key={`${pool.parentPersonalDataUuid}-${pool.childId}`}>
|
||||
<td>{nameFor(pool.parentPersonalDataUuid)}</td>
|
||||
<td>{pool.sjukpenningLeft}</td>
|
||||
<td>{pool.lagstaLeft}</td>
|
||||
<td>{pool.dubbeldagarLeft}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table className="plan-balances-table" data-testid="plan-fold-vacation-balances">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('planResults.parentHeader')}</th>
|
||||
<th>{t('planResults.yearHeader')}</th>
|
||||
<th>{t('planResults.usedHeader')}</th>
|
||||
<th>{t('planResults.totalAvailableHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proposal.vacationBalances.map((budget) => (
|
||||
<tr key={`${budget.parentPersonalDataUuid}-${budget.year}`}>
|
||||
<td>{nameFor(budget.parentPersonalDataUuid)}</td>
|
||||
<td>{budget.year}</td>
|
||||
<td>{budget.alreadyUsed}</td>
|
||||
<td>{budget.totalDays + budget.savedDaysIn}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ProgressChart } from './ProgressChart'
|
||||
|
||||
describe('ProgressChart', () => {
|
||||
it('renders progress chart', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={50} />
|
||||
)
|
||||
expect(getByTestId('progress-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calculates percentage correctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={25} targetWeeks={100} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('25%')
|
||||
})
|
||||
|
||||
it('displays weeks correctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={50} />
|
||||
)
|
||||
expect(getByTestId('current-weeks').textContent).toBe('10 / 50 veckor')
|
||||
})
|
||||
|
||||
it('sets fill width based on percentage', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={50} targetWeeks={100} />
|
||||
)
|
||||
const fill = getByTestId('progress-fill')
|
||||
expect(fill).toHaveStyle({ width: '50%' })
|
||||
})
|
||||
|
||||
it('caps percentage at 100%', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={150} targetWeeks={100} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('100%')
|
||||
expect(getByTestId('progress-fill')).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('shows 0% when no progress', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={0} targetWeeks={50} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('0%')
|
||||
})
|
||||
|
||||
it('displays child age when provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={50} childAge={2} />
|
||||
)
|
||||
expect(getByTestId('progress-chart').textContent).toContain('Ålder: 2')
|
||||
})
|
||||
|
||||
it('handles zero target weeks', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={0} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('0%')
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
|
||||
export interface ProgressChartProps {
|
||||
currentWeeks: number
|
||||
targetWeeks: number
|
||||
childAge?: number
|
||||
}
|
||||
|
||||
export function ProgressChart({
|
||||
currentWeeks,
|
||||
targetWeeks,
|
||||
childAge,
|
||||
}: ProgressChartProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const percentage = targetWeeks > 0 ? Math.min((currentWeeks / targetWeeks) * 100, 100) : 0
|
||||
|
||||
return (
|
||||
<div data-testid="progress-chart" className="progress-chart">
|
||||
<div className="progress-chart__header">
|
||||
<h3>{t('progressChart.title')}</h3>
|
||||
{childAge !== undefined && (
|
||||
<span className="progress-chart__age">{t('progressChart.age', { age: childAge })}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="progress-chart__bar">
|
||||
<div
|
||||
className="progress-chart__fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
data-testid="progress-fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-chart__stats">
|
||||
<span data-testid="current-weeks">
|
||||
{t('progressChart.weeksStat', { current: currentWeeks, target: targetWeeks })}
|
||||
</span>
|
||||
<span data-testid="percentage" className="progress-chart__percentage">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { YearTimeline } from './YearTimeline'
|
||||
import { makePlannedLeaveDay } from '../../../tests/fixtures'
|
||||
|
||||
describe('YearTimeline', () => {
|
||||
it('renders one row per year across the horizon', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
for (const year of [2025, 2026, 2027, 2028, 2029]) {
|
||||
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('groups a contiguous run of planned days into a single band', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'sjukpenning' }),
|
||||
]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Three consecutive days -> exactly one band for 2026, dominant type
|
||||
// vacation (2 of 3 days).
|
||||
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
|
||||
'title',
|
||||
'Semester, 2026-06-15 – 2026-06-17',
|
||||
)
|
||||
})
|
||||
|
||||
it('splits into separate bands across a gap bigger than a bridged weekend', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-01-05', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-06-15', dayType: 'vacation' }),
|
||||
]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('never lets a band span a calendar-year boundary', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-12-30', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2027-01-02', dayType: 'vacation' }),
|
||||
]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('year-timeline-band-2027-0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes planned days outside the horizon', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2030-01-01', dayType: 'vacation' })]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId(/^year-timeline-band-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onBandClick with the band start date when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onBandClick = vi.fn()
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'sjukpenning' })]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={onBandClick}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
|
||||
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
|
||||
|
||||
export interface YearTimelineProps {
|
||||
plannedDays: PlannedLeaveDay[]
|
||||
/** ISO-8601 date — horizon start (the child's birth date). */
|
||||
startDate: string
|
||||
/** ISO-8601 date — horizon end (the child's 4th birthday). */
|
||||
endDate: string
|
||||
/** Called with a band's ISO-8601 start date when clicked. */
|
||||
onBandClick: (date: string) => void
|
||||
}
|
||||
|
||||
interface Band {
|
||||
startDate: string
|
||||
endDate: string
|
||||
dominantType: PlannedLeaveDay['dayType']
|
||||
}
|
||||
|
||||
// A run of planned days separates into a new band once the gap between
|
||||
// consecutive dates exceeds this many days — bridges a weekend (2 days) or
|
||||
// a weekend plus a single röd dag (3 days) without splitting one leave
|
||||
// period into several bands.
|
||||
const GAP_DAYS = 3
|
||||
|
||||
// Tie-break order when a band's days split evenly across types.
|
||||
const TYPE_PRIORITY: PlannedLeaveDay['dayType'][] = ['sjukpenning', 'lagsta', 'vacation']
|
||||
|
||||
function toUtcDate(iso: string): Date {
|
||||
return new Date(iso + 'T00:00:00Z')
|
||||
}
|
||||
|
||||
function dayOfYearUtc(d: Date): number {
|
||||
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
|
||||
return Math.floor((d.getTime() - yearStart) / 86_400_000) + 1
|
||||
}
|
||||
|
||||
function daysInYear(year: number): number {
|
||||
return Math.floor((Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 86_400_000)
|
||||
}
|
||||
|
||||
function dominantType(days: PlannedLeaveDay[]): PlannedLeaveDay['dayType'] {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const d of days) {
|
||||
counts[d.dayType] = (counts[d.dayType] ?? 0) + 1
|
||||
}
|
||||
let best = TYPE_PRIORITY[0]!
|
||||
let bestCount = -1
|
||||
for (const type of TYPE_PRIORITY) {
|
||||
const count = counts[type] ?? 0
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
best = type
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Groups sorted planned days into contiguous bands (one leave period each),
|
||||
* never spanning a calendar-year boundary — each year gets its own row. */
|
||||
function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Band[] {
|
||||
const start = toUtcDate(startDate)
|
||||
const end = toUtcDate(endDate)
|
||||
const inHorizon = plannedDays
|
||||
.filter((d) => {
|
||||
const date = toUtcDate(d.date)
|
||||
return date >= start && date <= end
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
const bands: Band[] = []
|
||||
let current: PlannedLeaveDay[] = []
|
||||
|
||||
const closeCurrent = () => {
|
||||
if (current.length === 0) return
|
||||
bands.push({
|
||||
startDate: current[0]!.date,
|
||||
endDate: current[current.length - 1]!.date,
|
||||
dominantType: dominantType(current),
|
||||
})
|
||||
current = []
|
||||
}
|
||||
|
||||
for (const day of inHorizon) {
|
||||
if (current.length === 0) {
|
||||
current.push(day)
|
||||
continue
|
||||
}
|
||||
const prev = toUtcDate(current[current.length - 1]!.date)
|
||||
const next = toUtcDate(day.date)
|
||||
const gapDays = Math.round((next.getTime() - prev.getTime()) / 86_400_000)
|
||||
const crossesYear = prev.getUTCFullYear() !== next.getUTCFullYear()
|
||||
if (gapDays > GAP_DAYS || crossesYear) {
|
||||
closeCurrent()
|
||||
}
|
||||
current.push(day)
|
||||
}
|
||||
closeCurrent()
|
||||
|
||||
return bands
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per calendar year from the child's birth to their 4th birthday,
|
||||
* with a colored band per contiguous planned-leave period ("what do the
|
||||
* next four years look like"). Clicking a band navigates to the calendar at
|
||||
* that date — the reason this view exists alongside (not instead of) the
|
||||
* per-parent day bars above it.
|
||||
*/
|
||||
export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: YearTimelineProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const start = toUtcDate(startDate)
|
||||
const end = toUtcDate(endDate)
|
||||
const startYear = start.getUTCFullYear()
|
||||
const endYear = end.getUTCFullYear()
|
||||
const years = startYear <= endYear
|
||||
? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i)
|
||||
: []
|
||||
|
||||
const bands = buildBands(plannedDays, startDate, endDate)
|
||||
|
||||
return (
|
||||
<div data-testid="year-timeline" className="year-timeline">
|
||||
<h3 className="year-timeline__title">{t('yearTimeline.title')}</h3>
|
||||
{years.map((year) => {
|
||||
const yearLength = daysInYear(year)
|
||||
const bandsForYear = bands.filter((b) => toUtcDate(b.startDate).getUTCFullYear() === year)
|
||||
return (
|
||||
<div key={year} className="year-timeline__row" data-testid={`year-timeline-row-${year}`}>
|
||||
<span className="year-timeline__year-label">{year}</span>
|
||||
<div className="year-timeline__track">
|
||||
{bandsForYear.map((band, idx) => {
|
||||
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
|
||||
const width = ((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
|
||||
const label = t('yearTimeline.bandLabel', {
|
||||
dayType: getLabelForType(band.dominantType, t),
|
||||
startDate: band.startDate,
|
||||
endDate: band.endDate,
|
||||
})
|
||||
return (
|
||||
<button
|
||||
key={`${year}-${idx}`}
|
||||
type="button"
|
||||
className="year-timeline__band"
|
||||
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: getColorForType(band.dominantType) }}
|
||||
onClick={() => onBandClick(band.startDate)}
|
||||
data-testid={`year-timeline-band-${year}-${idx}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export const dashboard = {
|
||||
'age4Bars.expiringStat': '{expiring} days expiring',
|
||||
'age4Bars.savableStat': '{left} of {cap} days can be saved',
|
||||
'age4Bars.title': 'Days left at age 4, per parent',
|
||||
'dashboard.addChildButton': 'Add child',
|
||||
'dashboard.addChildIntro': 'You have no children registered yet. Add your first child to get started.',
|
||||
'dashboard.addChildTitle': 'Add child',
|
||||
@@ -13,11 +16,16 @@ export const dashboard = {
|
||||
'dashboard.expiringAtB4ParentFallback': 'Parent {number}',
|
||||
'dashboard.fetchFromFk': 'Fetch from Försäkringskassan',
|
||||
'dashboard.fillAllFields': 'Fill in all fields',
|
||||
'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).',
|
||||
'dashboard.hero.noPlanMessage': 'You have {days} days to plan. Without a plan, {atRisk} of them will disappear when {childName} turns 4.',
|
||||
'dashboard.loading': 'Loading...',
|
||||
'dashboard.moreDetailsSummary': 'More details',
|
||||
'dashboard.namePlaceholder': 'Name',
|
||||
'dashboard.overviewTitle': 'Overview',
|
||||
'dashboard.planFold.noWishesHint': 'Add wishes under Planning to see a proposal here.',
|
||||
'dashboard.planFold.summary': 'Plan proposal for the years ahead',
|
||||
'dashboard.planFold.unknownParent': 'Unknown parent',
|
||||
'dashboard.plannedDaysTitle': 'Planned days',
|
||||
'dashboard.remainingUntilB4Title': 'Remaining until age 4',
|
||||
'dashboard.saveManualFetchError': 'Could not save the details',
|
||||
'dashboard.usedDays': 'Used days',
|
||||
'daysBreakdown.daysHeader': 'Days',
|
||||
@@ -35,7 +43,6 @@ export const dashboard = {
|
||||
'parentalLeaveBreakdown.totalHeader': 'Total',
|
||||
'parentalLeaveBreakdown.transferableDays': 'Transferable days',
|
||||
'parentalLeaveBreakdown.typeHeader': 'Type',
|
||||
'progressChart.age': 'Age: {age}',
|
||||
'progressChart.title': 'Parental leave — progress',
|
||||
'progressChart.weeksStat': '{current} / {target} weeks',
|
||||
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate}',
|
||||
'yearTimeline.title': 'The years ahead',
|
||||
} as const
|
||||
|
||||
@@ -52,12 +52,13 @@ export const plan = {
|
||||
'planResults.previewBanner': 'This is a suggestion — nothing is saved automatically. You can adjust your wishes above and suggest a new plan as many times as you like.',
|
||||
'planResults.projectedLeftHeader': 'Projected left',
|
||||
'planResults.rejectionCollisionsNeedReplace': '{count} day(s) already planned differently; pass replace=true to overwrite',
|
||||
'planResults.rejectionConflictingDuplicate': 'Conflicting duplicate entries for {date} (child {child_id}, parent {parent_personal_data_uuid}) in the same batch',
|
||||
'planResults.rejectionConflictingDuplicate': 'Conflicting duplicate entries for {date} (child {child_id}, parent {parent_name}) in the same batch',
|
||||
'planResults.rejectionDubbeldagBatchQuotaExceeded': 'This batch would result in {projected_total} double days, the max is {max_dubbeldagar}',
|
||||
'planResults.rejectionDubbeldagInvalid': 'The double day for {date} could not be approved',
|
||||
'planResults.rejectionDubbeldagQuotaReached': 'The double-days quota ({max_dubbeldagar} days) has already been reached',
|
||||
'planResults.rejectionDubbeldagWindowExceeded': 'Double days are only allowed within the child\'s first {months_limit} months (deadline {deadline})',
|
||||
'planResults.rejectionInsufficientBalance': 'This batch needs {needed} more "{tier}" day(s) but only {remaining} remain',
|
||||
'planResults.rejectionInsufficientVacationBalance': '{parent_name} would need {needed} more vacation day(s) in {year}, but only {remaining} remain',
|
||||
'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"',
|
||||
'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})',
|
||||
'planResults.rejectionsClause': '. Rejected: {rejections}',
|
||||
@@ -88,6 +89,7 @@ export const plan = {
|
||||
'planResults.surplusAdviceUseLessVacation': 'Save fewer vacation days',
|
||||
'planResults.surplusAdviceWishMoreLeave': 'Wish for more leave',
|
||||
'planResults.totalAvailableHeader': 'Total available',
|
||||
'planResults.unknownParent': 'Unknown parent',
|
||||
'planResults.usedHeader': 'Used',
|
||||
'planResults.vacationAssumedBadge': 'Assumed',
|
||||
'planResults.vacationAssumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit it in Settings.',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const dashboard = {
|
||||
'age4Bars.expiringStat': '{expiring} dagar förfaller',
|
||||
'age4Bars.savableStat': '{left} av {cap} dagar kan sparas',
|
||||
'age4Bars.title': 'Dagar kvar vid 4 års ålder, per förälder',
|
||||
'dashboard.addChildButton': 'Lägg till barn',
|
||||
'dashboard.addChildIntro': 'Du har inga barn registrerade ännu. Lägg till ditt första barn för att börja.',
|
||||
'dashboard.addChildTitle': 'Lägg till barn',
|
||||
@@ -13,11 +16,16 @@ export const dashboard = {
|
||||
'dashboard.expiringAtB4ParentFallback': 'Förälder {number}',
|
||||
'dashboard.fetchFromFk': 'Hämta från Försäkringskassan',
|
||||
'dashboard.fillAllFields': 'Fyll i alla fält',
|
||||
'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).',
|
||||
'dashboard.hero.noPlanMessage': 'Du har {days} dagar att planera. Utan en plan försvinner {atRisk} av dem när {childName} fyller 4.',
|
||||
'dashboard.loading': 'Laddar...',
|
||||
'dashboard.moreDetailsSummary': 'Fler detaljer',
|
||||
'dashboard.namePlaceholder': 'Namn',
|
||||
'dashboard.overviewTitle': 'Översikt',
|
||||
'dashboard.planFold.noWishesHint': 'Lägg till önskemål under Planering för att se ett förslag här.',
|
||||
'dashboard.planFold.summary': 'Planförslag för de kommande åren',
|
||||
'dashboard.planFold.unknownParent': 'Okänd förälder',
|
||||
'dashboard.plannedDaysTitle': 'Planerade dagar',
|
||||
'dashboard.remainingUntilB4Title': 'Återstående till 4 års ålder',
|
||||
'dashboard.saveManualFetchError': 'Kunde inte spara uppgifterna',
|
||||
'dashboard.usedDays': 'Använda dagar',
|
||||
'daysBreakdown.daysHeader': 'Dagar',
|
||||
@@ -35,7 +43,6 @@ export const dashboard = {
|
||||
'parentalLeaveBreakdown.totalHeader': 'Totalt',
|
||||
'parentalLeaveBreakdown.transferableDays': 'Överlåtbara dagar',
|
||||
'parentalLeaveBreakdown.typeHeader': 'Typ',
|
||||
'progressChart.age': 'Ålder: {age}',
|
||||
'progressChart.title': 'Föräldraledighet – förlopp',
|
||||
'progressChart.weeksStat': '{current} / {target} veckor',
|
||||
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate}',
|
||||
'yearTimeline.title': 'De kommande åren',
|
||||
} as const
|
||||
@@ -52,12 +52,13 @@ export const plan = {
|
||||
'planResults.previewBanner': 'Detta är ett förslag — inget sparas automatiskt. Du kan justera dina önskemål ovan och föreslå en ny plan så många gånger du vill.',
|
||||
'planResults.projectedLeftHeader': 'Beräknat kvar',
|
||||
'planResults.rejectionCollisionsNeedReplace': '{count} dag(ar) är redan planerade på annat sätt; skicka replace=true för att skriva över',
|
||||
'planResults.rejectionConflictingDuplicate': 'Motstridiga dubbletter för {date} (barn {child_id}, förälder {parent_personal_data_uuid}) i samma omgång',
|
||||
'planResults.rejectionConflictingDuplicate': 'Motstridiga dubbletter för {date} (barn {child_id}, förälder {parent_name}) i samma omgång',
|
||||
'planResults.rejectionDubbeldagBatchQuotaExceeded': 'Omgången skulle resultera i {projected_total} dubbeldagar, max är {max_dubbeldagar}',
|
||||
'planResults.rejectionDubbeldagInvalid': 'Dubbeldag för {date} kunde inte godkännas',
|
||||
'planResults.rejectionDubbeldagQuotaReached': 'Kvoten för dubbeldagar ({max_dubbeldagar} dagar) är redan nådd',
|
||||
'planResults.rejectionDubbeldagWindowExceeded': 'Dubbeldagar är endast tillåtna inom barnets första {months_limit} månader (senast {deadline})',
|
||||
'planResults.rejectionInsufficientBalance': 'Omgången behöver {needed} fler "{tier}"-dagar men endast {remaining} finns kvar',
|
||||
'planResults.rejectionInsufficientVacationBalance': '{parent_name} skulle behöva {needed} fler semesterdag(ar) {year}, men endast {remaining} finns kvar',
|
||||
'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"',
|
||||
'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})',
|
||||
'planResults.rejectionsClause': '. Avslag: {rejections}',
|
||||
@@ -88,6 +89,7 @@ export const plan = {
|
||||
'planResults.surplusAdviceUseLessVacation': 'Spara färre semesterdagar',
|
||||
'planResults.surplusAdviceWishMoreLeave': 'Önska mer ledighet',
|
||||
'planResults.totalAvailableHeader': 'Totalt tillgängliga',
|
||||
'planResults.unknownParent': 'Okänd förälder',
|
||||
'planResults.usedHeader': 'Använda',
|
||||
'planResults.vacationAssumedBadge': 'Antaget',
|
||||
'planResults.vacationAssumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra i Inställningar.',
|
||||
|
||||
@@ -14,11 +14,22 @@ import {
|
||||
makeScraperFetchResult,
|
||||
makeWorkspaceMembersResult,
|
||||
makePlannedLeaveDay,
|
||||
makeOptimizationTipsResult,
|
||||
makeLeaveWishesResult,
|
||||
makePlanProposalResult,
|
||||
makePlanApplyResult,
|
||||
makeTip,
|
||||
} from '../../tests/fixtures'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
|
||||
vi.mock('@/hooks')
|
||||
vi.mock('@/context')
|
||||
vi.mock('@/services/api')
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return { ...actual, useNavigate: () => mockNavigate }
|
||||
})
|
||||
|
||||
// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree
|
||||
// to work. Dashboard.tsx only cares that its content renders inside Layout, so
|
||||
@@ -103,6 +114,10 @@ describe('Dashboard', () => {
|
||||
],
|
||||
})
|
||||
)
|
||||
vi.mocked(hooks.useOptimizationTips).mockReturnValue(makeOptimizationTipsResult())
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult())
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
|
||||
vi.mocked(hooks.usePlanApply).mockReturnValue(makePlanApplyResult())
|
||||
})
|
||||
|
||||
it('renders loading spinner initially', () => {
|
||||
@@ -137,8 +152,120 @@ describe('Dashboard', () => {
|
||||
expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows overview metrics when children exist', () => {
|
||||
describe('Hero', () => {
|
||||
it('uses the forward-looking framing when there are no planned days yet', () => {
|
||||
vi.mocked(hooks.usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
|
||||
'Du har 270 dagar att planera. Utan en plan försvinner 100 av dem när Child 1 fyller 4.',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the warning framing once days are planned, with the 4th-birthday date', () => {
|
||||
// selectedChild birth_date is 2022-01-01 and there is no parent
|
||||
// projection in this test's default mock, so the 4th birthday is
|
||||
// derived client-side as plain date arithmetic (not a business rule).
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
|
||||
'100 dagar riskerar att försvinna när Child 1 fyller 4 (1 januari 2026).',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the backend-provided 4th birthday when a parent projection is available', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||||
makeAnalyticsResult({
|
||||
data: {
|
||||
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 },
|
||||
],
|
||||
combinedDaysUsed: 30,
|
||||
combinedDaysLeft: 210,
|
||||
combinedExpiringAtB4: 60,
|
||||
},
|
||||
})
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('1 juni 2029')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Age4Bars', () => {
|
||||
it('renders each parent bar using the backend-resolved savableCapAtB4, never re-derived', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||||
makeAnalyticsResult({
|
||||
data: {
|
||||
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 },
|
||||
],
|
||||
combinedDaysUsed: 31,
|
||||
combinedDaysLeft: 279,
|
||||
combinedExpiringAtB4: 191,
|
||||
},
|
||||
})
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('40 av 48 dagar kan sparas')
|
||||
expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Alex')
|
||||
expect(screen.getByTestId('age4-bar-expiring-pd-2')).toHaveTextContent('191 dagar förfaller')
|
||||
expect(screen.getByTestId('age4-bar-pd-2')).toHaveTextContent('Sam')
|
||||
})
|
||||
|
||||
it('is not rendered when there are no parent projections yet', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.queryByTestId('age4-bars')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('YearTimeline', () => {
|
||||
it('renders one row per year from the child\'s birth to the 4th birthday', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
for (const year of [2022, 2023, 2024, 2025, 2026]) {
|
||||
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('navigates to the calendar at the clicked band\'s date', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(hooks.usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: '1', date: '2024-06-15', dayType: 'vacation' })],
|
||||
})
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
await user.click(screen.getByTestId('year-timeline-band-2024-0'))
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/calendar?date=2024-06-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tips', () => {
|
||||
it('is hidden entirely when there are no tips', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.queryByTestId('dashboard-tips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tip cards when tips exist', () => {
|
||||
vi.mocked(hooks.useOptimizationTips).mockReturnValue(
|
||||
makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] })
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('tip-card')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the folded plan-proposal section', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('plan-fold')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows overview metrics inside the collapsed "more details" section', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('dashboard-more-details')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Använda dagar/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -185,16 +312,10 @@ describe('Dashboard', () => {
|
||||
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows progress chart with real data', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('progress-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Swedish section headings', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/)
|
||||
expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument()
|
||||
expect(screen.getByText(/Återstående till 4 års ålder/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The combined expiring-at-4 figure reflects the currently saved plan, while
|
||||
@@ -215,8 +336,8 @@ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
],
|
||||
combinedDaysUsed: 50,
|
||||
combinedDaysLeft: 190,
|
||||
@@ -515,7 +636,7 @@ 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 },
|
||||
{ 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 },
|
||||
],
|
||||
combinedDaysUsed: 30,
|
||||
combinedDaysLeft: 210,
|
||||
@@ -536,8 +657,8 @@ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
],
|
||||
combinedDaysUsed: 50,
|
||||
combinedDaysLeft: 190,
|
||||
@@ -570,8 +691,8 @@ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
],
|
||||
combinedDaysUsed: 50,
|
||||
combinedDaysLeft: 190,
|
||||
@@ -592,8 +713,8 @@ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
],
|
||||
combinedDaysUsed: 50,
|
||||
combinedDaysLeft: 190,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { useAnalytics, usePlannedDays, useScraperFetch, useParentalLeave, useWorkspaceMembers } from '@/hooks'
|
||||
import {
|
||||
useAnalytics,
|
||||
usePlannedDays,
|
||||
useScraperFetch,
|
||||
useParentalLeave,
|
||||
useWorkspaceMembers,
|
||||
useOptimizationTips,
|
||||
} from '@/hooks'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import * as api from '@/services/api'
|
||||
import { getLabelForType } from '@/utils/leaveTypes'
|
||||
@@ -10,14 +18,28 @@ import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
|
||||
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
|
||||
import { ProgressChart } from '@/components/dashboard/ProgressChart'
|
||||
import { Hero } from '@/components/dashboard/Hero'
|
||||
import { Age4Bars } from '@/components/dashboard/Age4Bars'
|
||||
import { YearTimeline } from '@/components/dashboard/YearTimeline'
|
||||
import { PlanFold } from '@/components/dashboard/PlanFold'
|
||||
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
import { TipCard } from '@/components/inputs/TipCard'
|
||||
import { QRCodeModal } from '@/components/modals/QRCodeModal'
|
||||
import { ManualFetchModal } from '@/components/modals/ManualFetchModal'
|
||||
|
||||
/** Fallback horizon end when the analytics projection isn't available yet
|
||||
* (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving
|
||||
* it here does not duplicate the age-4 savable-cap logic the backend owns. */
|
||||
function computeFourthBirthday(birthDateIso: string): string {
|
||||
const d = new Date(birthDateIso + 'T00:00:00Z')
|
||||
d.setUTCFullYear(d.getUTCFullYear() + 4)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function Dashboard(): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
|
||||
const { data: analytics, isLoading: analyticsLoading, error: analyticsError, refresh: refreshAnalytics } = useAnalytics(
|
||||
workspace?.id ?? null,
|
||||
@@ -26,6 +48,7 @@ export function Dashboard(): ReactNode {
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
|
||||
const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null)
|
||||
const { data: members, isLoading: membersLoading } = useWorkspaceMembers(workspace?.id ?? null)
|
||||
const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null)
|
||||
const scraper = useScraperFetch()
|
||||
|
||||
const [childName, setChildName] = useState('')
|
||||
@@ -120,15 +143,16 @@ export function Dashboard(): ReactNode {
|
||||
await Promise.all([refreshAnalytics(), refreshParentalLeave()])
|
||||
}
|
||||
|
||||
const handleBandClick = (date: string) => {
|
||||
navigate(`/calendar?date=${date}`)
|
||||
}
|
||||
|
||||
const breakdown = [
|
||||
{ type: getLabelForType('vacation', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'vacation').length ?? 0 },
|
||||
{ type: getLabelForType('sjukpenning', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'sjukpenning').length ?? 0 },
|
||||
{ type: getLabelForType('lagsta', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'lagsta').length ?? 0 },
|
||||
]
|
||||
|
||||
const currentWeeks = Math.floor((analytics?.combinedDaysUsed ?? 0) / 5)
|
||||
const targetWeeks = Math.floor(((analytics?.combinedDaysUsed ?? 0) + (analytics?.combinedDaysLeft ?? 0)) / 5)
|
||||
|
||||
// Per-parent expiring-day figures are only informative once there's more
|
||||
// than one parent to compare — with a single parent they'd just repeat
|
||||
// the combined total below, so skip the breakdown in that case.
|
||||
@@ -206,6 +230,18 @@ export function Dashboard(): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
const hasPlan = (plannedDays?.length ?? 0) > 0
|
||||
const fourthBirthday =
|
||||
parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '')
|
||||
const age4Bars = parentProjections.map((projection, index) => ({
|
||||
personalDataUuid: projection.personalDataUuid,
|
||||
name: parentDisplayName(projection.personalDataUuid, index),
|
||||
projectedLeftAtB4: projection.projectedLeftAtB4,
|
||||
savableCapAtB4: projection.savableCapAtB4,
|
||||
expiringAtB4: projection.expiringAtB4,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div data-testid="dashboard" className="dashboard">
|
||||
@@ -215,6 +251,18 @@ export function Dashboard(): ReactNode {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analytics && selectedChild && (
|
||||
<Hero
|
||||
hasPlan={hasPlan}
|
||||
daysLeft={analytics.combinedDaysLeft}
|
||||
daysAtRisk={analytics.combinedExpiringAtB4}
|
||||
childName={selectedChild.name}
|
||||
fourthBirthday={fourthBirthday}
|
||||
/>
|
||||
)}
|
||||
|
||||
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
|
||||
|
||||
<section className="dashboard__section">
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
@@ -234,28 +282,46 @@ export function Dashboard(): ReactNode {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
{analytics && (
|
||||
<OverviewMetrics
|
||||
title={t('dashboard.overviewTitle')}
|
||||
metrics={overviewMetrics}
|
||||
/>
|
||||
)}
|
||||
<ParentalLeaveBreakdown data={parentalLeave} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>{t('dashboard.plannedDaysTitle')}</h2>
|
||||
<DaysBreakdown breakdown={breakdown} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>{t('dashboard.remainingUntilB4Title')}</h2>
|
||||
<ProgressChart
|
||||
currentWeeks={currentWeeks}
|
||||
targetWeeks={targetWeeks}
|
||||
{selectedChild && (
|
||||
<YearTimeline
|
||||
plannedDays={plannedDays ?? []}
|
||||
startDate={selectedChild.birth_date}
|
||||
endDate={fourthBirthday}
|
||||
onBandClick={handleBandClick}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tips && tips.length > 0 && (
|
||||
<section className="dashboard__section" data-testid="dashboard-tips">
|
||||
<h2>{t('tips.title')}</h2>
|
||||
<div className="dashboard__tips-list">
|
||||
{tips.map((tip) => (
|
||||
<TipCard key={tip.id} tip={tip} onDismiss={() => dismissTip(tip.id)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{workspace && <PlanFold workspaceId={workspace.id} members={members} />}
|
||||
|
||||
<details className="dashboard__more-details" data-testid="dashboard-more-details">
|
||||
<summary>{t('dashboard.moreDetailsSummary')}</summary>
|
||||
|
||||
<section className="dashboard__section">
|
||||
{analytics && (
|
||||
<OverviewMetrics
|
||||
title={t('dashboard.overviewTitle')}
|
||||
metrics={overviewMetrics}
|
||||
/>
|
||||
)}
|
||||
<ParentalLeaveBreakdown data={parentalLeave} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>{t('dashboard.plannedDaysTitle')}</h2>
|
||||
<DaysBreakdown breakdown={breakdown} />
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<QRCodeModal
|
||||
isOpen={showModal}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -157,55 +157,141 @@
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
/* Progress Chart */
|
||||
.progress-chart {
|
||||
/* Hero */
|
||||
.dashboard-hero {
|
||||
padding: var(--sp-5);
|
||||
border-radius: var(--radius);
|
||||
border-left: 4px solid var(--c-accent);
|
||||
background-color: var(--c-accent-weak);
|
||||
}
|
||||
|
||||
.dashboard-hero--warning {
|
||||
border-left-color: var(--c-warning);
|
||||
background-color: var(--c-danger-weak);
|
||||
}
|
||||
|
||||
.dashboard-hero__message {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Age4 Bars (per-parent days left at the 4th birthday) */
|
||||
.age4-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.progress-chart__header {
|
||||
.age4-bars__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin: 0 0 var(--sp-3) 0;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.progress-chart__header h3 {
|
||||
margin: 0;
|
||||
.age4-bars__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-chart__age {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.progress-chart__bar {
|
||||
.age4-bars__track {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
height: 20px;
|
||||
background-color: var(--c-bg);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.progress-chart__fill {
|
||||
.age4-bars__fill {
|
||||
height: 100%;
|
||||
background-color: var(--c-accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-chart__stats {
|
||||
.age4-bars__stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.progress-chart__percentage {
|
||||
.age4-bars__expiring {
|
||||
color: var(--c-danger);
|
||||
font-weight: 600;
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
/* Year Timeline */
|
||||
.year-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.year-timeline__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.year-timeline__year-label {
|
||||
width: 3rem;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.year-timeline__track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
background-color: var(--c-bg);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--c-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.year-timeline__band {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.year-timeline__band:hover,
|
||||
.year-timeline__band:focus-visible {
|
||||
outline: 2px solid var(--c-text);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Plan Fold (collapsed plan-proposal summary) */
|
||||
.plan-fold,
|
||||
.dashboard__more-details {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-4);
|
||||
background-color: var(--c-surface);
|
||||
}
|
||||
|
||||
.plan-fold__summary,
|
||||
.dashboard__more-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-fold__section {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.plan-fold__error {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.plan-fold__success {
|
||||
color: var(--c-success);
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
|
||||
@@ -79,6 +79,10 @@ export interface LeaveWishCreateRequest {
|
||||
windowEndMonth: number
|
||||
windowEndDay: number
|
||||
targetWeeks: number
|
||||
// A calendar-drawn period (dragging a range on the calendar) is not a
|
||||
// whole number of weeks — omitted/`undefined` falls back to
|
||||
// `targetWeeks * 7` server-side. See `LeaveWish.targetDays`.
|
||||
targetDays?: number | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
firstYear: number
|
||||
lastYear?: number | null
|
||||
@@ -152,6 +156,14 @@ export interface PlanApplyResponse {
|
||||
createdDays: object[]
|
||||
}
|
||||
|
||||
// POST /api/plan/sync — the direct-manipulation calendar's one call:
|
||||
// recompute + persist in one step, reporting exactly what changed.
|
||||
export interface PlanSyncResponse {
|
||||
proposal: import('./entities').PlanProposal
|
||||
applyResult: PlanApplyResponse
|
||||
changes: import('./entities').PlanChange[]
|
||||
}
|
||||
|
||||
// Day overrides (calendar exceptions)
|
||||
export interface DayOverrideCreateRequest {
|
||||
workspaceId: string
|
||||
|
||||
@@ -43,6 +43,15 @@ export interface LeaveWish {
|
||||
windowEndMonth: number
|
||||
windowEndDay: number
|
||||
targetWeeks: number
|
||||
// A calendar-drawn period (dragging a band on the calendar UI) is not a
|
||||
// whole number of weeks. `null`/absent (every wish specified in whole
|
||||
// weeks, e.g. via a preset) falls back to `targetWeeks * 7` on the
|
||||
// backend — see `LeaveWish.target_span_days()`
|
||||
// (backend/app/entities/leave_wish.py). Optional (not just nullable) so
|
||||
// an existing hand-rolled `LeaveWish` object literal elsewhere in the
|
||||
// codebase (predating this field) still type-checks without every call
|
||||
// site needing an update.
|
||||
targetDays?: number | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
firstYear: number
|
||||
lastYear: number | null
|
||||
@@ -57,6 +66,33 @@ export interface LeaveWish {
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** One (child, parent, date) slot's delta from a `POST /api/plan/sync` call
|
||||
* — see `PlanChange` (backend/app/services/planning_service.py) for the
|
||||
* `kind` vocabulary:
|
||||
*
|
||||
* - 'added' — no row existed for (child, parent, date); one does now.
|
||||
* - 'removed' — a row existed and no longer does (old_* populated, new_*
|
||||
* all `null`).
|
||||
* - 'retyped' — the row survived but `dayType`/`isDubbeldag` changed.
|
||||
* - 'reattributed' — same day and type, but `wishId` changed (the day now
|
||||
* belongs to a different period).
|
||||
*
|
||||
* Structured data only, house style — the frontend composes the toast copy
|
||||
* ("Sommar 2028 flyttades 2 dagar") itself rather than rendering backend prose.
|
||||
*/
|
||||
export interface PlanChange {
|
||||
kind: 'added' | 'removed' | 'retyped' | 'reattributed'
|
||||
date: string // ISO-8601 date
|
||||
parentPersonalDataUuid: string
|
||||
childId: string
|
||||
oldDayType: 'sjukpenning' | 'lagsta' | 'vacation' | null
|
||||
newDayType: 'sjukpenning' | 'lagsta' | 'vacation' | null
|
||||
oldIsDubbeldag: boolean | null
|
||||
newIsDubbeldag: boolean | null
|
||||
oldWishId: string | null
|
||||
newWishId: string | null
|
||||
}
|
||||
|
||||
export interface TierData {
|
||||
sjukpenningTotal: number
|
||||
sjukpenningUsed: number
|
||||
@@ -159,6 +195,11 @@ export interface Age4Projection {
|
||||
projectedLeftAtB4: number
|
||||
savableAfterB4: number
|
||||
expiringAtB4: number
|
||||
// The cap actually applied to `savableAfterB4`/`expiringAtB4` — 96 (per
|
||||
// child) normally, or 48 (per parent) for a `couple` workspace with two
|
||||
// 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
|
||||
}
|
||||
|
||||
export interface WorkspaceOverview {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderRejection } from './rejectionMessages'
|
||||
import { translate } from '@/i18n'
|
||||
import type { PersonalData, Rejection } from '@/types'
|
||||
|
||||
const t = (key: Parameters<typeof translate>[1], params?: Parameters<typeof translate>[2]) =>
|
||||
translate('sv', key, params)
|
||||
|
||||
const member = (overrides: Partial<PersonalData> = {}): PersonalData => ({
|
||||
uuid: 'pd-a',
|
||||
workspaceId: 'ws-1',
|
||||
name: 'Alex',
|
||||
salaryType: 'monthly',
|
||||
employmentStatus: 'employed',
|
||||
createdAt: '2026-01-01',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const rejection = (overrides: Partial<Rejection> = {}): Rejection => ({
|
||||
messageKey: 'planApply.rejection.insufficientBalance',
|
||||
data: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('renderRejection', () => {
|
||||
it('renders every known message_key to a real (non-raw-key) string', () => {
|
||||
const knownKeys = [
|
||||
'planApply.rejection.invalidDayType',
|
||||
'planApply.rejection.conflictingDuplicate',
|
||||
'planApply.rejection.collisionsNeedReplace',
|
||||
'planApply.rejection.dubbeldagWindowExceeded',
|
||||
'planApply.rejection.dubbeldagQuotaReached',
|
||||
'planApply.rejection.dubbeldagInvalid',
|
||||
'planApply.rejection.dubbeldagBatchQuotaExceeded',
|
||||
'planApply.rejection.outsideEligibleWindow',
|
||||
'planApply.rejection.insufficientBalance',
|
||||
'planApply.rejection.insufficientVacationBalance',
|
||||
]
|
||||
for (const messageKey of knownKeys) {
|
||||
const rendered = renderRejection(rejection({ messageKey }), t, [])
|
||||
expect(rendered).not.toBe(messageKey)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the raw message_key when the backend sends one this build does not recognise', () => {
|
||||
expect(renderRejection(rejection({ messageKey: 'planApply.rejection.somethingNew' }), t, [])).toBe(
|
||||
'planApply.rejection.somethingNew',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves insufficientVacationBalance with a real parent name, never a raw uuid', () => {
|
||||
const rendered = renderRejection(
|
||||
rejection({
|
||||
messageKey: 'planApply.rejection.insufficientVacationBalance',
|
||||
data: { parent_personal_data_uuid: 'pd-a', year: 2026, needed: 3, remaining: 1 },
|
||||
}),
|
||||
t,
|
||||
[member({ uuid: 'pd-a', name: 'Alex' })],
|
||||
)
|
||||
expect(rendered).toContain('Alex')
|
||||
expect(rendered).not.toContain('pd-a')
|
||||
expect(rendered).toContain('3')
|
||||
expect(rendered).toContain('2026')
|
||||
expect(rendered).toContain('1')
|
||||
})
|
||||
|
||||
it('falls back to a generic label, never the raw uuid, when no member matches', () => {
|
||||
const rendered = renderRejection(
|
||||
rejection({
|
||||
messageKey: 'planApply.rejection.insufficientVacationBalance',
|
||||
data: { parent_personal_data_uuid: 'pd-unknown', year: 2026, needed: 3, remaining: 1 },
|
||||
}),
|
||||
t,
|
||||
[member({ uuid: 'pd-a', name: 'Alex' })],
|
||||
)
|
||||
expect(rendered).not.toContain('pd-unknown')
|
||||
expect(rendered).toContain('Okänd förälder')
|
||||
})
|
||||
|
||||
it('resolves the parent uuid in conflictingDuplicate to a name, not the raw uuid', () => {
|
||||
const rendered = renderRejection(
|
||||
rejection({
|
||||
messageKey: 'planApply.rejection.conflictingDuplicate',
|
||||
data: { date: '2026-06-15', child_id: 'child-1', parent_personal_data_uuid: 'pd-a' },
|
||||
}),
|
||||
t,
|
||||
[member({ uuid: 'pd-a', name: 'Alex' })],
|
||||
)
|
||||
expect(rendered).toContain('Alex')
|
||||
expect(rendered).not.toContain('pd-a')
|
||||
})
|
||||
|
||||
it('leaves data with no parent uuid untouched', () => {
|
||||
const rendered = renderRejection(
|
||||
rejection({
|
||||
messageKey: 'planApply.rejection.insufficientBalance',
|
||||
data: { needed: 2, tier: 'sjukpenning', remaining: 1 },
|
||||
}),
|
||||
t,
|
||||
[],
|
||||
)
|
||||
expect(rendered).toContain('2')
|
||||
expect(rendered).toContain('sjukpenning')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Single source of truth for rendering a `Rejection` (§3.5 — a backend
|
||||
* `message_key` + opaque `data` bag, same idiom as `Tip`/`PlanWarning`) in
|
||||
* the caller's active locale.
|
||||
*
|
||||
* Every rejection `message_key` `POST /api/plan/apply` and
|
||||
* `POST /api/plan/sync` (`app/services/planning_service.py`) can return,
|
||||
* including `insufficientVacationBalance`
|
||||
* (`data: {parent_personal_data_uuid, year, needed, remaining}`).
|
||||
*
|
||||
* Previously duplicated between `PlanFold.tsx` and
|
||||
* `frontend/src/components/planning/PlanResults.tsx` (the latter scheduled
|
||||
* for deletion, and missing `insufficientVacationBalance` entirely). Moved
|
||||
* here so there is exactly one copy, and so the `/api/plan/sync` toast
|
||||
* (`Calendar.tsx`) can render rejections too:
|
||||
*
|
||||
* import { renderRejection } from '@/utils/rejectionMessages'
|
||||
* renderRejection(rejection, t, members)
|
||||
*
|
||||
* where `t` is `useTranslation()`'s translate function and `members` is the
|
||||
* workspace's `PersonalData[]` (e.g. from `useWorkspaceMembers`).
|
||||
*/
|
||||
import type { PersonalData, Rejection } from '@/types'
|
||||
import { toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
|
||||
import { nameResolver } from '@/utils/parentNames'
|
||||
|
||||
export type Translate = (key: TranslationKey, params?: TranslationParams) => string
|
||||
|
||||
const REJECTION_KEYS: Record<string, TranslationKey> = {
|
||||
'planApply.rejection.invalidDayType': 'planResults.rejectionInvalidDayType',
|
||||
'planApply.rejection.conflictingDuplicate': 'planResults.rejectionConflictingDuplicate',
|
||||
'planApply.rejection.collisionsNeedReplace': 'planResults.rejectionCollisionsNeedReplace',
|
||||
'planApply.rejection.dubbeldagWindowExceeded': 'planResults.rejectionDubbeldagWindowExceeded',
|
||||
'planApply.rejection.dubbeldagQuotaReached': 'planResults.rejectionDubbeldagQuotaReached',
|
||||
'planApply.rejection.dubbeldagInvalid': 'planResults.rejectionDubbeldagInvalid',
|
||||
'planApply.rejection.dubbeldagBatchQuotaExceeded': 'planResults.rejectionDubbeldagBatchQuotaExceeded',
|
||||
'planApply.rejection.outsideEligibleWindow': 'planResults.rejectionOutsideEligibleWindow',
|
||||
'planApply.rejection.insufficientBalance': 'planResults.rejectionInsufficientBalance',
|
||||
'planApply.rejection.insufficientVacationBalance': 'planResults.rejectionInsufficientVacationBalance',
|
||||
}
|
||||
|
||||
/** Renders one rejection, never leaking `data.parent_personal_data_uuid` as
|
||||
* a raw uuid: whenever that field is present, it's resolved to a display
|
||||
* name via `members` (falling back to `planResults.unknownParent` rather
|
||||
* than the uuid itself, e.g. while members are still loading or for a
|
||||
* removed member) and added to the template params as `parent_name`. An
|
||||
* unrecognised `messageKey` falls back to showing the raw key, same idiom
|
||||
* as `TipCard.tsx`'s `TIP_TRANSLATION_KEYS`.
|
||||
*/
|
||||
export function renderRejection(rejection: Rejection, t: Translate, members: PersonalData[]): string {
|
||||
const key = REJECTION_KEYS[rejection.messageKey]
|
||||
if (!key) return rejection.messageKey
|
||||
const params = toTranslationParams(rejection.data)
|
||||
const uuid = rejection.data.parent_personal_data_uuid
|
||||
if (typeof uuid === 'string') {
|
||||
const resolve = nameResolver(members)
|
||||
const name = resolve(uuid)
|
||||
params.parent_name = name === uuid ? t('planResults.unknownParent') : name
|
||||
}
|
||||
return t(key, params)
|
||||
}
|
||||
Vendored
+1
@@ -14,6 +14,7 @@ export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age
|
||||
projectedLeftAtB4: 200,
|
||||
savableAfterB4: 96,
|
||||
expiringAtB4: 60,
|
||||
savableCapAtB4: 96,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+13
@@ -22,6 +22,7 @@ import type { useDayOverrides } from '@/hooks/useDayOverrides'
|
||||
import type { useLeaveWishes } from '@/hooks/useLeaveWishes'
|
||||
import type { usePlanProposal } from '@/hooks/usePlanProposal'
|
||||
import type { usePlanApply } from '@/hooks/usePlanApply'
|
||||
import type { usePlanSync } from '@/hooks/usePlanSync'
|
||||
import type { useVacationDays } from '@/hooks/useVacationDays'
|
||||
import type { useChildSettings } from '@/hooks/useChildSettings'
|
||||
import type { useCalendarDays } from '@/hooks/useCalendarDays'
|
||||
@@ -209,6 +210,18 @@ export function makePlanApplyResult(
|
||||
}
|
||||
}
|
||||
|
||||
export function makePlanSyncResult(
|
||||
overrides: Partial<ReturnType<typeof usePlanSync>> = {},
|
||||
): ReturnType<typeof usePlanSync> {
|
||||
return {
|
||||
isSyncing: false,
|
||||
error: null,
|
||||
lastChanges: null,
|
||||
sync: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeVacationDaysResult(
|
||||
overrides: Partial<ReturnType<typeof useVacationDays>> = {},
|
||||
): ReturnType<typeof useVacationDays> {
|
||||
|
||||
Vendored
+1
@@ -140,6 +140,7 @@ export function makeLeaveWish(overrides: Partial<LeaveWish> = {}): LeaveWish {
|
||||
windowEndMonth: 7,
|
||||
windowEndDay: 26,
|
||||
targetWeeks: 6,
|
||||
targetDays: null,
|
||||
recurrence: 'yearly',
|
||||
firstYear: 2026,
|
||||
lastYear: null,
|
||||
|
||||
Vendored
+29
@@ -8,7 +8,9 @@ import type {
|
||||
DayCollision,
|
||||
PlanAge4Projection,
|
||||
PlanApplyResponse,
|
||||
PlanChange,
|
||||
PlanProposal,
|
||||
PlanSyncResponse,
|
||||
PlanWarning,
|
||||
PoolBalance,
|
||||
ProposedDay,
|
||||
@@ -180,3 +182,30 @@ export function makePlanApplyResponse(overrides: Partial<PlanApplyResponse> = {}
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** `POST /api/plan/sync` — one (child, parent, date) slot's delta. See
|
||||
* `PlanChange`'s docstring (src/types/entities.ts) for the `kind` vocabulary. */
|
||||
export function makePlanChange(overrides: Partial<PlanChange> = {}): PlanChange {
|
||||
return {
|
||||
kind: 'added',
|
||||
date: '2028-06-20',
|
||||
parentPersonalDataUuid: 'pd-a',
|
||||
childId: 'child-1',
|
||||
oldDayType: null,
|
||||
newDayType: 'vacation',
|
||||
oldIsDubbeldag: null,
|
||||
newIsDubbeldag: false,
|
||||
oldWishId: null,
|
||||
newWishId: 'lw-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function makePlanSyncResponse(overrides: Partial<PlanSyncResponse> = {}): PlanSyncResponse {
|
||||
return {
|
||||
proposal: makePlanProposal(),
|
||||
applyResult: makePlanApplyResponse(),
|
||||
changes: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user