Free days, vacation forfeiture, and global auto-replanning
Free days (Sat/Sun/red days, subject to DayOverride) can never be vacation, and can only be parental leave when the workspace opts in via take_pl_on_red_days. The allocator already honoured this; the hand-pin path bypassed it entirely. - PlanningService gains optional workspace_repo/day_override_repo deps and _reject_illegal_free_day, enforced in add_planned_day and update. Reuses allocation_service.is_free_day rather than re-deriving it, and no-ops when the deps are unset so existing callers are unaffected. - update_planned_day had no ValidationError handler at all, so a validation failure there 500'd instead of 422'ing. Fixed. - Calendar filters the pin write loop by the day's isFree; the modal explains free days stay off, and that pinning opts a day out of automatic re-planning. Vacation days at risk of forfeiture were computed and sent over the wire but never rendered: an at-risk column with the age-out/over-cap split, and a dashboard key figure. PlanProposal.warnings was likewise parsed but rendered nowhere, for all three kinds. Adds PlanWarningCard in a Risks section above the tips, with parent names resolved and dates formatted. POST /api/plan/sync now fires from the workspace layer on every plan-input change rather than only from Calendar, so a Settings edit or a pinned-day save re-plans day types instead of merely refetching. Overlapping bumps coalesce into one trailing run; a rejection cannot wedge the in-flight guard. eviction_preview was silently dropped by the frontend despite always being on the wire, and is now surfaced through context. Verified: backend 890 passed (91.49% coverage), frontend 1297 passed, ruff/eslint/tsc clean. Browser-checked in Swedish and English. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
This commit is contained in:
@@ -3,22 +3,24 @@ import { render, screen } from '@testing-library/react'
|
||||
import { KeyFigures } from './KeyFigures'
|
||||
|
||||
describe('KeyFigures', () => {
|
||||
it('renders the three combined figures with their labels', () => {
|
||||
render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
|
||||
it('renders the four combined figures with their labels', () => {
|
||||
render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} vacationAtRisk={12} />)
|
||||
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270')
|
||||
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('Dagar kvar')
|
||||
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30')
|
||||
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('Använda')
|
||||
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100')
|
||||
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('Förfaller vid 4 år')
|
||||
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('12')
|
||||
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('Semester som riskerar att gå förlorad')
|
||||
})
|
||||
|
||||
it('wears danger ink on the expiring figure only when it is non-zero', () => {
|
||||
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
|
||||
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} vacationAtRisk={0} />)
|
||||
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).toHaveClass(
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} />)
|
||||
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={0} />)
|
||||
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).not.toHaveClass(
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
@@ -26,4 +28,15 @@ describe('KeyFigures', () => {
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
})
|
||||
|
||||
it('wears danger ink on the vacation-at-risk figure only when it is non-zero', () => {
|
||||
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={12} />)
|
||||
expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).toHaveClass(
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={0} />)
|
||||
expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).not.toHaveClass(
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,19 +8,31 @@ export interface KeyFiguresProps {
|
||||
daysUsed: number
|
||||
/** `combinedExpiringAtB4` — projected to be lost at the 4th birthday. */
|
||||
expiringAtB4: number
|
||||
/** Sum of every parent/year's `VacationYearBalance.expiring` from the
|
||||
* current year onward — vacation days forecast to be forfeited (age-out
|
||||
* or over the carry-forward cap), the reason the planner may shift
|
||||
* placements toward spending vacation sooner. */
|
||||
vacationAtRisk: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The three headline figures under the hero — large tabular numerals with
|
||||
* a 14px label each, no tiles. The expiring figure wears danger ink only
|
||||
* when it is non-zero: a zero is good news and should read as quiet.
|
||||
* The four headline figures under the hero — large tabular numerals with
|
||||
* a 14px label each, no tiles. The expiring and vacation-at-risk figures
|
||||
* wear danger ink only when non-zero: a zero is good news and should read
|
||||
* as quiet.
|
||||
*/
|
||||
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4 }: KeyFiguresProps): ReactNode {
|
||||
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4, vacationAtRisk }: KeyFiguresProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const figures = [
|
||||
{ key: 'left', label: t('dashboard.figureLeft'), value: daysLeft, danger: false },
|
||||
{ key: 'used', label: t('dashboard.figureUsed'), value: daysUsed, danger: false },
|
||||
{ key: 'expiring', label: t('dashboard.figureExpiring'), value: expiringAtB4, danger: expiringAtB4 > 0 },
|
||||
{
|
||||
key: 'vacationAtRisk',
|
||||
label: t('dashboard.figureVacationAtRisk'),
|
||||
value: vacationAtRisk,
|
||||
danger: vacationAtRisk > 0,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<dl className="key-figures" data-testid="key-figures">
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PlanWarningCard } from './PlanWarningCard'
|
||||
import { makePlanWarning } from '../../../tests/fixtures'
|
||||
|
||||
const nameFor = (uuid: string) => (uuid === 'pd-b' ? 'Sam' : 'Okänd förälder')
|
||||
|
||||
describe('PlanWarningCard', () => {
|
||||
it('renders a sgi_risk warning with the parent name and a formatted (non-ISO) week date', () => {
|
||||
render(
|
||||
<PlanWarningCard
|
||||
index={0}
|
||||
nameFor={nameFor}
|
||||
warning={makePlanWarning({
|
||||
kind: 'sgi_risk',
|
||||
messageKey: 'planWarnings.sgi_risk',
|
||||
data: {
|
||||
parent_personal_data_uuid: 'pd-b',
|
||||
week_start: '2027-03-08',
|
||||
days_planned: 1,
|
||||
min_days_per_week: 3,
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const warning = screen.getByTestId('dashboard-plan-warning-0')
|
||||
expect(warning).toHaveClass('tip-card', 'tip-card--danger')
|
||||
expect(warning).toHaveTextContent('Risk för SGI-skyddet')
|
||||
expect(warning).toHaveTextContent('Sam är planerad för endast 1 av minst 3 ledighetsdagar veckan som börjar 8 mars 2027')
|
||||
expect(warning).not.toHaveTextContent('pd-b')
|
||||
expect(warning).not.toHaveTextContent('2027-03-08')
|
||||
})
|
||||
|
||||
it('renders a days_lost_at_age_4 warning with the parent name and a formatted (non-ISO) birthday', () => {
|
||||
render(
|
||||
<PlanWarningCard
|
||||
index={1}
|
||||
nameFor={nameFor}
|
||||
warning={makePlanWarning({
|
||||
kind: 'days_lost_at_age_4',
|
||||
messageKey: 'planWarnings.days_lost_at_age_4',
|
||||
data: {
|
||||
parent_personal_data_uuid: 'pd-b',
|
||||
child_id: 'child-1',
|
||||
expiring_days: 15,
|
||||
projected_left_at_b4: 40,
|
||||
fourth_birthday: '2029-06-01',
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const warning = screen.getByTestId('dashboard-plan-warning-1')
|
||||
expect(warning).toHaveTextContent('Dagar riskerar att gå förlorade vid 4 år')
|
||||
expect(warning).toHaveTextContent('Sam riskerar att förlora 15 föräldrapenningdagar vid barnets 4-årsdag (1 juni 2029)')
|
||||
expect(warning).toHaveTextContent('40 dagar beräknas då finnas kvar totalt')
|
||||
expect(warning).not.toHaveTextContent('2029-06-01')
|
||||
})
|
||||
|
||||
it('renders a vacation_expiring warning with the parent name and its figures', () => {
|
||||
render(
|
||||
<PlanWarningCard
|
||||
index={2}
|
||||
nameFor={nameFor}
|
||||
warning={makePlanWarning({
|
||||
kind: 'vacation_expiring',
|
||||
messageKey: 'planWarnings.vacation_expiring',
|
||||
data: { parent_personal_data_uuid: 'pd-b', year: 2027, expiring_days: 4 },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const warning = screen.getByTestId('dashboard-plan-warning-2')
|
||||
expect(warning).toHaveTextContent('Semesterdagar riskerar att gå förlorade')
|
||||
expect(warning).toHaveTextContent('4 semesterdagar riskerar att gå förlorade 2027')
|
||||
})
|
||||
|
||||
it('falls back to the raw kind and messageKey for an unrecognised warning', () => {
|
||||
render(
|
||||
<PlanWarningCard
|
||||
index={3}
|
||||
nameFor={nameFor}
|
||||
warning={makePlanWarning({ kind: 'brand_new_kind', messageKey: 'planWarnings.brand_new_kind', data: {} })}
|
||||
/>,
|
||||
)
|
||||
const warning = screen.getByTestId('dashboard-plan-warning-3')
|
||||
expect(warning).toHaveTextContent('brand_new_kind')
|
||||
expect(warning).toHaveTextContent('planWarnings.brand_new_kind')
|
||||
})
|
||||
|
||||
it('has no dismiss control', () => {
|
||||
render(<PlanWarningCard index={0} nameFor={nameFor} warning={makePlanWarning()} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { PlanWarning } from '@/types'
|
||||
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
|
||||
// The three `PlanWarning` kinds the backend can emit
|
||||
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
|
||||
// `_vacation_expiry_warnings`). `kind` -> title, `messageKey` -> the body
|
||||
// template. The backend's own `message_key` is already `f"planWarnings.
|
||||
// {kind}"`, i.e. a real translation key — routed through a lookup anyway
|
||||
// (like `SurplusAdviceTip`'s `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised
|
||||
// kind (a newer backend rule this catalogue hasn't caught up with) falls
|
||||
// back to the raw value instead of rendering a missing-key placeholder.
|
||||
const PLAN_WARNING_TITLE_KEYS: Record<string, TranslationKey> = {
|
||||
sgi_risk: 'planWarnings.sgiRiskTitle',
|
||||
days_lost_at_age_4: 'planWarnings.daysLostAtAge4Title',
|
||||
vacation_expiring: 'planWarnings.vacationExpiringTitle',
|
||||
}
|
||||
|
||||
const PLAN_WARNING_BODY_KEYS: Record<string, TranslationKey> = {
|
||||
'planWarnings.sgi_risk': 'planWarnings.sgi_risk',
|
||||
'planWarnings.days_lost_at_age_4': 'planWarnings.days_lost_at_age_4',
|
||||
'planWarnings.vacation_expiring': 'planWarnings.vacation_expiring',
|
||||
}
|
||||
|
||||
// `data` fields that carry an ISO-8601 date and must go through
|
||||
// `formatDateForDisplay` before interpolation — a raw ISO string is never
|
||||
// shown to the user.
|
||||
const DATE_FIELDS_BY_KIND: Record<string, readonly string[]> = {
|
||||
sgi_risk: ['week_start'],
|
||||
days_lost_at_age_4: ['fourth_birthday'],
|
||||
vacation_expiring: [],
|
||||
}
|
||||
|
||||
export interface PlanWarningCardProps {
|
||||
warning: PlanWarning
|
||||
/** Resolves a parent uuid to a display name (never a raw uuid) —
|
||||
* `Dashboard.tsx`'s `nameForAdvice`, which already falls back to
|
||||
* `planResults.unknownParent`. */
|
||||
nameFor: (uuid: string) => string
|
||||
/** Position in the warnings list — only used for the test id. */
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One plan-proposal warning rendered in the same visual as a backend
|
||||
* `TipCard` (same `tip-card` classes) so the dashboard reads as one system,
|
||||
* but with the danger-ink dot (`var(--c-danger)`, the same colour
|
||||
* `KeyFigures`/`Age4Bars`/`YearRows` already use for at-risk figures) —
|
||||
* warnings are risks, not suggestions. No dismiss: it is derived from the
|
||||
* plan and goes away when the plan changes.
|
||||
*/
|
||||
export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const titleKey = PLAN_WARNING_TITLE_KEYS[warning.kind]
|
||||
const bodyKey = PLAN_WARNING_BODY_KEYS[warning.messageKey]
|
||||
const title = titleKey ? t(titleKey) : warning.kind
|
||||
|
||||
let body = warning.messageKey
|
||||
if (bodyKey) {
|
||||
const params = toTranslationParams(warning.data)
|
||||
const uuid = warning.data.parent_personal_data_uuid
|
||||
if (typeof uuid === 'string') {
|
||||
params.parent_name = nameFor(uuid)
|
||||
}
|
||||
for (const field of DATE_FIELDS_BY_KIND[warning.kind] ?? []) {
|
||||
const raw = warning.data[field]
|
||||
if (typeof raw === 'string') {
|
||||
params[field] = formatDateForDisplay(raw, locale)
|
||||
}
|
||||
}
|
||||
body = t(bodyKey, params)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tip-card tip-card--danger"
|
||||
role="article"
|
||||
aria-label={t('tip.aria', { title })}
|
||||
data-testid={`dashboard-plan-warning-${index}`}
|
||||
>
|
||||
<div className="tip-card__header">
|
||||
<h4 className="tip-card__title">{title}</h4>
|
||||
</div>
|
||||
<p className="tip-card__body">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -701,6 +701,80 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('free-day hand-pin guard notices (§ free days never consume vacation)', () => {
|
||||
it('warns that free days stay off when pinning vacation over a selection that includes one', async () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
isRangeMode
|
||||
dayCount={2}
|
||||
freeDatesInSelection={['2026-06-13']}
|
||||
/>,
|
||||
)
|
||||
await pickType('vacation')
|
||||
expect(screen.getByTestId('free-days-stay-off-notice')).toHaveTextContent(
|
||||
'Lediga dagar (helg/röd dag) i urvalet lämnas lediga',
|
||||
)
|
||||
})
|
||||
|
||||
it('warns for a PL type on a free day when takePlOnRedDays is off', async () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
isRangeMode
|
||||
dayCount={2}
|
||||
freeDatesInSelection={['2026-06-13']}
|
||||
takePlOnRedDays={false}
|
||||
/>,
|
||||
)
|
||||
await pickType('sjukpenning')
|
||||
expect(screen.getByTestId('free-days-stay-off-notice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not warn for a PL type on a free day once takePlOnRedDays is on', async () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
isRangeMode
|
||||
dayCount={2}
|
||||
freeDatesInSelection={['2026-06-13']}
|
||||
takePlOnRedDays={true}
|
||||
/>,
|
||||
)
|
||||
await pickType('sjukpenning')
|
||||
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not warn when the selection has no free days', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} isRangeMode dayCount={2} freeDatesInSelection={[]} />)
|
||||
await pickType('vacation')
|
||||
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('notes that pinning excludes the day from automatic re-planning', async () => {
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await pickType('vacation')
|
||||
expect(screen.getByTestId('pin-exclusion-notice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not duplicate the pin-exclusion notice when the day is already pinned (the pinned notice already says so)', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
|
||||
expect(screen.getByTestId('pinned-notice')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows neither notice while the type select is still on "let the planner choose"', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} freeDatesInSelection={['2026-06-13']} />)
|
||||
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removed days (PlannedDayExclusion) — the way back', () => {
|
||||
it('names the parent whose day was removed and offers to restore it', async () => {
|
||||
const onRestoreExcluded = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -68,6 +68,14 @@ export interface PlannedDayModalProps {
|
||||
isRangeMode?: boolean | undefined
|
||||
dayCount?: number | undefined
|
||||
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
|
||||
/** Dates in the current selection that are free days (weekend/red day,
|
||||
* per workspace `DayOverride`) — a `vacation` pin always leaves these off,
|
||||
* and a `sjukpenning`/`lagsta` pin does too unless `takePlOnRedDays`.
|
||||
* Drives the "will stay off" notice below the type select. */
|
||||
freeDatesInSelection?: string[] | undefined
|
||||
/** Mirrors `Workspace.takePlOnRedDays` (see Settings) — whether this
|
||||
* workspace opted in to funding free days with parental leave. */
|
||||
takePlOnRedDays?: boolean | undefined
|
||||
}
|
||||
|
||||
export function PlannedDayModal({
|
||||
@@ -88,6 +96,8 @@ export function PlannedDayModal({
|
||||
isRangeMode = false,
|
||||
dayCount = 1,
|
||||
defaultDayType,
|
||||
freeDatesInSelection = [],
|
||||
takePlOnRedDays = false,
|
||||
}: PlannedDayModalProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const hasMultipleParents = parents.length > 1
|
||||
@@ -118,6 +128,23 @@ export function PlannedDayModal({
|
||||
const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen)
|
||||
const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen
|
||||
|
||||
// A concrete type is about to be pinned (not the 'auto' planner-decides
|
||||
// choice) — the two notices below only make sense once that's true.
|
||||
const isPinningConcreteType = pinnedPath && dayType !== 'auto'
|
||||
// The chosen type would leave every free day in the selection (weekend/red
|
||||
// day) as a day off: always true for `vacation`, and true for a PL type
|
||||
// unless the workspace opted in via `takePlOnRedDays` — mirrors
|
||||
// `Calendar.tsx`'s `handleSavePinnedDay` filter exactly.
|
||||
const freeDaysWillStayOff =
|
||||
isPinningConcreteType &&
|
||||
freeDatesInSelection.length > 0 &&
|
||||
(dayType === 'vacation' || !takePlOnRedDays)
|
||||
// Pinning excludes the day from `sync_plan`'s automatic re-planning —
|
||||
// worth saying prospectively (about to pin) even when the day isn't
|
||||
// already pinned; `existingDay?.isPinned` below already covers the
|
||||
// already-pinned case, so this doesn't repeat there.
|
||||
const showPinExclusionNotice = isPinningConcreteType && !existingDay?.isPinned
|
||||
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||
|
||||
@@ -359,6 +386,18 @@ export function PlannedDayModal({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{freeDaysWillStayOff && (
|
||||
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
|
||||
{t('plannedDayModal.freeDaysStayOffNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showPinExclusionNotice && (
|
||||
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
|
||||
{t('plannedDayModal.pinExcludesFromSyncNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMultipleParents && (
|
||||
<div className="field">
|
||||
<label htmlFor="parent-select" className="field__label">
|
||||
|
||||
@@ -3,9 +3,9 @@ import { render, fireEvent, screen, act } from '@testing-library/react'
|
||||
import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable'
|
||||
|
||||
const ROWS: VacationDaysTableRow[] = [
|
||||
{ year: 2026, totalDays: 10, savedDaysIn: 2 },
|
||||
{ year: 2027, totalDays: 25, savedDaysIn: 0 },
|
||||
{ year: 2028, totalDays: 25, savedDaysIn: 0 },
|
||||
{ year: 2026, totalDays: 10, savedDaysIn: 2, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
||||
{ year: 2027, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
||||
{ year: 2028, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
||||
]
|
||||
|
||||
function renderTable(
|
||||
@@ -184,10 +184,43 @@ describe('VacationDaysTable', () => {
|
||||
})
|
||||
|
||||
it('shows an assumed badge for a year with no entered data (§3.2)', () => {
|
||||
const rows = [...ROWS, { year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true }]
|
||||
const rows = [
|
||||
...ROWS,
|
||||
{ year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
||||
]
|
||||
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the at-risk figure in neutral ink when nothing is expiring', () => {
|
||||
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
||||
const atRisk = screen.getByTestId('vacation-at-risk-2026')
|
||||
expect(atRisk).toHaveTextContent('0')
|
||||
expect(atRisk).not.toHaveClass('vacation-rows__at-risk-value--danger')
|
||||
})
|
||||
|
||||
it('shows the at-risk figure in danger ink when days are expiring, with a tooltip splitting the cause', () => {
|
||||
const rows = [
|
||||
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 5, expiringAge: 2, expiringOverCap: 3 },
|
||||
...ROWS.slice(1),
|
||||
]
|
||||
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
||||
const atRisk = screen.getByTestId('vacation-at-risk-2026')
|
||||
expect(atRisk).toHaveTextContent('5')
|
||||
expect(atRisk).toHaveClass('vacation-rows__at-risk-value--danger')
|
||||
expect(atRisk.title).toContain('2')
|
||||
expect(atRisk.title).toContain('3')
|
||||
})
|
||||
|
||||
it('shows only the relevant cause in the at-risk tooltip when the other is zero', () => {
|
||||
const rows = [
|
||||
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 4, expiringAge: 4, expiringOverCap: 0 },
|
||||
...ROWS.slice(1),
|
||||
]
|
||||
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
||||
const atRisk = screen.getByTestId('vacation-at-risk-2026')
|
||||
expect(atRisk.title).toBe('4 dagar har passerat 5-årsgränsen för att spara semester')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,14 @@ export interface VacationDaysTableRow {
|
||||
// shown is the statutory default (25 days) rather than something the user
|
||||
// entered — §3.2. Prompts the user to confirm/override.
|
||||
isAssumed?: boolean
|
||||
// Every vacation day forecast lost at this year's end — the sum of
|
||||
// `expiringAge` + `expiringOverCap` (see `VacationYearBalance` in
|
||||
// `types/entities.ts`). 0 when nothing is at risk.
|
||||
expiring: number
|
||||
// Subset of `expiring` that hit the 5-year carry-forward age-out.
|
||||
expiringAge: number
|
||||
// Subset of `expiring` above the savable carry-forward cap.
|
||||
expiringOverCap: number
|
||||
}
|
||||
|
||||
export interface VacationDaysTableProps {
|
||||
@@ -134,6 +142,18 @@ function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode {
|
||||
})
|
||||
}
|
||||
|
||||
// Splits the single `expiring` figure back into its two causes for the
|
||||
// tooltip — the column itself stays a bare number so it never fights the
|
||||
// narrow flex field for width in either language.
|
||||
const atRiskDetailParts: string[] = []
|
||||
if (row.expiringAge > 0) {
|
||||
atRiskDetailParts.push(t('vacationDaysTable.atRiskAgeDetail', { days: row.expiringAge }))
|
||||
}
|
||||
if (row.expiringOverCap > 0) {
|
||||
atRiskDetailParts.push(t('vacationDaysTable.atRiskOverCapDetail', { days: row.expiringOverCap }))
|
||||
}
|
||||
const atRiskDetail = atRiskDetailParts.join(' · ')
|
||||
|
||||
return (
|
||||
<div className="vacation-rows__row" data-testid={`vacation-year-row-${row.year}`}>
|
||||
<div className="vacation-rows__year">
|
||||
@@ -177,6 +197,21 @@ function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="field vacation-rows__field vacation-rows__at-risk">
|
||||
<span className="field__label">{t('vacationDaysTable.atRiskHeader')}</span>
|
||||
<span
|
||||
className={
|
||||
row.expiring > 0
|
||||
? 'vacation-rows__at-risk-value vacation-rows__at-risk-value--danger tnum'
|
||||
: 'vacation-rows__at-risk-value tnum'
|
||||
}
|
||||
data-testid={`vacation-at-risk-${row.year}`}
|
||||
title={atRiskDetail || undefined}
|
||||
>
|
||||
{row.expiring}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`vacation-rows__status vacation-rows__status--${status}`}
|
||||
data-testid={`vacation-status-${row.year}`}
|
||||
|
||||
@@ -65,6 +65,29 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* At-risk column — a bare tabular number, danger ink only when non-zero
|
||||
* (see `.key-figures__value--danger` / `.year-row__cell--danger`, the same
|
||||
* pattern used elsewhere for this figure). Sized like the other fields so
|
||||
* neither "At risk"/"Riskerar" nor the short two-digit value ever wraps. */
|
||||
.vacation-rows__at-risk {
|
||||
flex: 0 1 90px;
|
||||
min-width: 70px;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.vacation-rows__at-risk-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.vacation-rows__at-risk-value--danger {
|
||||
color: var(--c-danger);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.vacation-rows__empty {
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { PlanSyncProvider, usePlanSyncContext } from './PlanSyncProvider'
|
||||
import { useWorkspace } from './WorkspaceContext'
|
||||
import * as api from '@/services/api'
|
||||
import { ApiRequestError } from '@/services/errors'
|
||||
import {
|
||||
makeWorkspaceContextValue,
|
||||
makePlanSyncResponse,
|
||||
makeEvictionPreview,
|
||||
makeEvictionCandidate,
|
||||
makeResourceShortage,
|
||||
} from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
vi.mock('./WorkspaceContext', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}))
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <PlanSyncProvider>{children}</PlanSyncProvider>
|
||||
}
|
||||
|
||||
function mockWorkspace(overrides: Partial<ReturnType<typeof useWorkspace>> = {}) {
|
||||
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue(overrides))
|
||||
}
|
||||
|
||||
describe('PlanSyncProvider', () => {
|
||||
let bumpPlanVersion: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
bumpPlanVersion = vi.fn()
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 0, bumpPlanVersion })
|
||||
vi.mocked(api.syncPlan).mockResolvedValue(makePlanSyncResponse())
|
||||
})
|
||||
|
||||
it('does not sync when there is no workspace yet', () => {
|
||||
mockWorkspace({ workspaceId: null, planVersion: 0, bumpPlanVersion })
|
||||
renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
expect(api.syncPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs silently once the workspace is known', async () => {
|
||||
renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
|
||||
expect(api.syncPlan).toHaveBeenCalledWith('ws-1')
|
||||
})
|
||||
|
||||
it('a bump from a non-Calendar hook triggers exactly one more sync', async () => {
|
||||
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Simulates e.g. useVacationDays.upsert calling bumpPlanVersion().
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
|
||||
rerender()
|
||||
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
|
||||
expect(api.syncPlan).toHaveBeenLastCalledWith('ws-1')
|
||||
})
|
||||
|
||||
it('coalesces two rapid bumps into a single trailing call, not a stampede', async () => {
|
||||
let resolveFirst: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
|
||||
vi.mocked(api.syncPlan).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Two rapid bumps while the first sync is still in flight.
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
|
||||
rerender()
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 2, bumpPlanVersion })
|
||||
rerender()
|
||||
|
||||
// Neither bump starts an overlapping call while the first is pending.
|
||||
expect(api.syncPlan).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst(makePlanSyncResponse())
|
||||
})
|
||||
|
||||
// Exactly one trailing call coalesces both bumps — not two.
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
|
||||
|
||||
// Settling further ticks raises no additional calls of its own.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(api.syncPlan).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('never calls bumpPlanVersion itself — cycle safety', async () => {
|
||||
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
|
||||
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
|
||||
rerender()
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(bumpPlanVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a failing sync resets the in-flight ref so a later bump still syncs', async () => {
|
||||
vi.mocked(api.syncPlan).mockRejectedValueOnce(new Error('sync exploded'))
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('sync exploded'))
|
||||
|
||||
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse())
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
|
||||
rerender()
|
||||
|
||||
// Not wedged — the next bump's effect still finds `inFlightRef` clear.
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('logs a rejected ApiRequestError through the shared error model, never swallowed', async () => {
|
||||
vi.mocked(api.syncPlan).mockRejectedValueOnce(
|
||||
new ApiRequestError(500, { code: 'server_error', message: 'boom', details: {} }),
|
||||
)
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('500 boom')))
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('logs a non-Error rejection without swallowing it', async () => {
|
||||
vi.mocked(api.syncPlan).mockRejectedValueOnce('network exploded')
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('network exploded')),
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('surfaces a would-evict response through context instead of discarding it', async () => {
|
||||
const preview = makeEvictionPreview({
|
||||
wouldEvict: true,
|
||||
candidates: [makeEvictionCandidate()],
|
||||
shortages: [makeResourceShortage()],
|
||||
})
|
||||
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse({ evictionPreview: preview }))
|
||||
|
||||
const { result } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.pendingEvictionPreview).toEqual(preview))
|
||||
})
|
||||
|
||||
it('leaves pendingEvictionPreview null when nothing would be evicted', async () => {
|
||||
const { result } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
|
||||
expect(result.current.pendingEvictionPreview).toBeNull()
|
||||
})
|
||||
|
||||
it('clears a previously-surfaced eviction preview once a later sync evicts nothing', async () => {
|
||||
const preview = makeEvictionPreview({ wouldEvict: true })
|
||||
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse({ evictionPreview: preview }))
|
||||
|
||||
const { result, rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
await waitFor(() => expect(result.current.pendingEvictionPreview).toEqual(preview))
|
||||
|
||||
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse())
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
|
||||
rerender()
|
||||
|
||||
await waitFor(() => expect(result.current.pendingEvictionPreview).toBeNull())
|
||||
})
|
||||
|
||||
it('clearPendingEvictionPreview resets it to null on demand', async () => {
|
||||
const preview = makeEvictionPreview({ wouldEvict: true })
|
||||
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse({ evictionPreview: preview }))
|
||||
|
||||
const { result } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
await waitFor(() => expect(result.current.pendingEvictionPreview).toEqual(preview))
|
||||
|
||||
act(() => {
|
||||
result.current.clearPendingEvictionPreview()
|
||||
})
|
||||
|
||||
expect(result.current.pendingEvictionPreview).toBeNull()
|
||||
})
|
||||
|
||||
it('throws when used outside a PlanSyncProvider', () => {
|
||||
expect(() => renderHook(() => usePlanSyncContext())).toThrow(
|
||||
'usePlanSyncContext must be used inside <PlanSyncProvider>',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useWorkspace } from './WorkspaceContext'
|
||||
import { isApiError } from '@/services/errors'
|
||||
import type { EvictionPreview } from '@/types'
|
||||
|
||||
interface PlanSyncContextValue {
|
||||
/** Set when the workspace's most recent background sync came back with
|
||||
* `evictionPreview.wouldEvict === true` — that sync wrote NOTHING at all
|
||||
* (see `EvictionPreview`'s doc comment in `src/types/entities.ts`). A
|
||||
* consumer such as Calendar can read this — e.g. on its next mount/visit
|
||||
* — to prompt the user for an eviction choice instead of the change
|
||||
* silently vanishing. Reset to `null` by a later sync that resolves
|
||||
* without an eviction, or by `clearPendingEvictionPreview`. */
|
||||
pendingEvictionPreview: EvictionPreview | null
|
||||
/** Call once a consumer has shown/handled `pendingEvictionPreview`. */
|
||||
clearPendingEvictionPreview: () => void
|
||||
}
|
||||
|
||||
const PlanSyncContext = createContext<PlanSyncContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Fires `POST /api/plan/sync` silently (no toast, no undo UI) whenever the
|
||||
* workspace's plan inputs change — i.e. on every `planVersion` bump (see
|
||||
* `WorkspaceContext.planVersion`'s doc comment). Today only Calendar.tsx's
|
||||
* own `runSync` re-plans the day *types*; a pinned-day save, a vacation
|
||||
* balance edit, or a Settings change only ever bumped `planVersion` to make
|
||||
* other hooks refetch what's already persisted — none of them re-ran the
|
||||
* allocator. This provider is what makes those writes actually re-type the
|
||||
* rest of the plan's unpinned days, from anywhere in the app, not just from
|
||||
* the calendar page.
|
||||
*
|
||||
* Mounted inside `WorkspaceProvider` (see `WorkspaceContext.tsx`'s return),
|
||||
* so it reads `workspaceId`/`planVersion` from the very same context
|
||||
* instance every plan-input hook already bumps, and runs for the lifetime
|
||||
* of the app, not just while Calendar happens to be mounted.
|
||||
*
|
||||
* **Cycle safety.** This effect must NEVER call `bumpPlanVersion`.
|
||||
* `planVersion` is the shared invalidation signal every plan-input hook's
|
||||
* own fetch effect watches (see `usePlannedDays.ts:84-93`); a sync path
|
||||
* that both reacts to AND writes that counter would retrigger itself
|
||||
* forever. `syncPlan` mutates the plan server-side but is never treated as
|
||||
* a "plan input" write itself.
|
||||
*/
|
||||
export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
|
||||
const { workspaceId, planVersion } = useWorkspace()
|
||||
const [pendingEvictionPreview, setPendingEvictionPreview] = useState<EvictionPreview | null>(null)
|
||||
|
||||
// In-flight/pending refs, not state — flipping them must never itself
|
||||
// cause a re-render (that's exactly the kind of feedback loop the
|
||||
// cycle-safety note above warns about). Read/written synchronously around
|
||||
// the single `await` in `runSyncOnce` below, with no other code able to
|
||||
// interleave between a `finally` and the pending-check right after it.
|
||||
const inFlightRef = useRef(false)
|
||||
const pendingRef = useRef(false)
|
||||
const pendingWorkspaceIdRef = useRef<string | null>(null)
|
||||
|
||||
/** Runs one `syncPlan` call for `id`, then — if one or more further bumps
|
||||
* landed while it was in flight — exactly one trailing run for the
|
||||
* latest workspace id, never one call per bump. This is what coalesces a
|
||||
* burst of rapid bumps (e.g. several quick edits) into a single trailing
|
||||
* sync instead of a stampede of overlapping requests.
|
||||
*
|
||||
* `inFlightRef`/`pendingRef` are always reset in `finally`, so a
|
||||
* rejected call can never wedge this closed — the next bump's effect
|
||||
* always finds `inFlightRef.current === false` and starts a fresh call. */
|
||||
const runSyncOnce = useCallback(async (id: string) => {
|
||||
inFlightRef.current = true
|
||||
try {
|
||||
const result = await api.syncPlan(id)
|
||||
// `evictionPreview.wouldEvict === true` means this call wrote NOTHING
|
||||
// — never discard that into an empty catch/no-op. Surface it so a
|
||||
// consumer (Calendar) can ask the user on its next visit. A sync that
|
||||
// did write (or evicted nothing) clears any earlier pending preview.
|
||||
setPendingEvictionPreview(result.evictionPreview.wouldEvict ? result.evictionPreview : null)
|
||||
} catch (err) {
|
||||
// No toast exists for a background sync — this is the minimum bar
|
||||
// from the module doc comment: never an empty catch, always logged
|
||||
// through the shared API-error model instead of silently dropped.
|
||||
const message = isApiError(err)
|
||||
? `${err.status} ${err.error.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
console.error(`Background plan sync failed for workspace ${id}: ${message}`)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
pendingRef.current = false
|
||||
const nextId = pendingWorkspaceIdRef.current ?? id
|
||||
pendingWorkspaceIdRef.current = null
|
||||
await runSyncOnce(nextId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return
|
||||
if (inFlightRef.current) {
|
||||
// A sync is already running — record that another bump landed and
|
||||
// let that call's own trailing check (in `runSyncOnce`) pick it up,
|
||||
// rather than starting a second overlapping request here.
|
||||
pendingRef.current = true
|
||||
pendingWorkspaceIdRef.current = workspaceId
|
||||
return
|
||||
}
|
||||
void runSyncOnce(workspaceId)
|
||||
}, [workspaceId, planVersion, runSyncOnce])
|
||||
|
||||
const clearPendingEvictionPreview = useCallback(() => {
|
||||
setPendingEvictionPreview(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PlanSyncContext.Provider value={{ pendingEvictionPreview, clearPendingEvictionPreview }}>
|
||||
{children}
|
||||
</PlanSyncContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePlanSyncContext(): PlanSyncContextValue {
|
||||
const ctx = useContext(PlanSyncContext)
|
||||
if (!ctx) throw new Error('usePlanSyncContext must be used inside <PlanSyncProvider>')
|
||||
return ctx
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { PlanSyncProvider } from './PlanSyncProvider'
|
||||
import type { Workspace } from '@/types'
|
||||
|
||||
interface Child {
|
||||
@@ -199,7 +200,11 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
|
||||
bumpPlanVersion,
|
||||
}}
|
||||
>
|
||||
{childrenProp}
|
||||
{/* Mounted inside this provider (not beside it) so it reads
|
||||
* workspaceId/planVersion off this same context instance — see
|
||||
* PlanSyncProvider.tsx's doc comment for what it does and why it
|
||||
* must never call bumpPlanVersion. */}
|
||||
<PlanSyncProvider>{childrenProp}</PlanSyncProvider>
|
||||
</WorkspaceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useAuth } from './AuthContext'
|
||||
export { useWorkspace } from './WorkspaceContext'
|
||||
export { usePlanSyncContext } from './PlanSyncProvider'
|
||||
|
||||
@@ -2,9 +2,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useChildSettings } from './useChildSettings'
|
||||
import * as api from '@/services/api'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { makeWorkspaceContextValue } from '../../tests/fixtures'
|
||||
import type { Child } from '@/types'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
vi.mock('@/context')
|
||||
|
||||
const mockChild: Child = {
|
||||
id: 'child1',
|
||||
@@ -17,8 +20,14 @@ const mockChild: Child = {
|
||||
}
|
||||
|
||||
describe('useChildSettings', () => {
|
||||
let bumpPlanVersion: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
bumpPlanVersion = vi.fn()
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }),
|
||||
)
|
||||
})
|
||||
|
||||
it('initializes with null data and not loading', () => {
|
||||
@@ -85,6 +94,7 @@ describe('useChildSettings', () => {
|
||||
})
|
||||
// refetches after a successful update
|
||||
expect(api.getChild).toHaveBeenCalledWith('child1')
|
||||
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('update() includes preschoolPlanningDaysPerYear when provided', async () => {
|
||||
@@ -111,6 +121,7 @@ describe('useChildSettings', () => {
|
||||
})
|
||||
|
||||
expect(api.updateChildSettings).not.toHaveBeenCalled()
|
||||
expect(bumpPlanVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets isRefreshing (not isLoading) while update() background-refreshes', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import { useWorkspace } from '@/context'
|
||||
import type { Child } from '@/types'
|
||||
|
||||
export interface ChildSettingsUpdatePayload {
|
||||
@@ -33,6 +34,7 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
const { bumpPlanVersion } = useWorkspace()
|
||||
|
||||
const fetch = useCallback(
|
||||
async (options?: { background?: boolean }) => {
|
||||
@@ -73,8 +75,12 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
|
||||
: {}),
|
||||
})
|
||||
await fetch({ background: true })
|
||||
// Birth date / preschool days feed the allocator (age-4 cutoff,
|
||||
// preschool-closure demand) — re-type the rest of the plan's unpinned
|
||||
// days. See `WorkspaceContext.planVersion`.
|
||||
bumpPlanVersion()
|
||||
},
|
||||
[childId, fetch],
|
||||
[childId, fetch, bumpPlanVersion],
|
||||
)
|
||||
|
||||
return { data, isLoading, isRefreshing, error, update }
|
||||
|
||||
@@ -2,10 +2,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { usePreschoolPlanningDays } from './usePreschoolPlanningDays'
|
||||
import * as api from '@/services/api'
|
||||
import { makePreschoolPlanningDays } from '../../tests/fixtures'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { makePreschoolPlanningDays, makeWorkspaceContextValue } from '../../tests/fixtures'
|
||||
import type { PreschoolPlanningDays } from '@/types'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
vi.mock('@/context')
|
||||
|
||||
const mockRow = makePreschoolPlanningDays({
|
||||
id: 'ppd-1',
|
||||
@@ -17,8 +19,14 @@ const mockRow = makePreschoolPlanningDays({
|
||||
})
|
||||
|
||||
describe('usePreschoolPlanningDays', () => {
|
||||
let bumpPlanVersion: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
bumpPlanVersion = vi.fn()
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }),
|
||||
)
|
||||
vi.mocked(api.getPreschoolPlanningDaysYear).mockResolvedValue(mockRow)
|
||||
vi.mocked(api.upsertPreschoolPlanningDaysPlanned).mockResolvedValue(mockRow)
|
||||
vi.mocked(api.recordPreschoolPlanningDaysTaken).mockResolvedValue(mockRow)
|
||||
@@ -94,6 +102,10 @@ describe('usePreschoolPlanningDays', () => {
|
||||
plannedDays: 10,
|
||||
})
|
||||
expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledTimes(2)
|
||||
// Called once per settled `setPlanned` (the hook has no debounce of its
|
||||
// own — Settings.tsx's PreschoolPlanningYearRow debounces before ever
|
||||
// calling this), never per keystroke.
|
||||
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recordTaken() records takenDays (including 0) and refetches', async () => {
|
||||
@@ -108,6 +120,7 @@ describe('usePreschoolPlanningDays', () => {
|
||||
takenDays: 0,
|
||||
})
|
||||
expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledTimes(2)
|
||||
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('setPlanned() and recordTaken() are no-ops when childId is null', async () => {
|
||||
@@ -120,6 +133,7 @@ describe('usePreschoolPlanningDays', () => {
|
||||
|
||||
expect(api.upsertPreschoolPlanningDaysPlanned).not.toHaveBeenCalled()
|
||||
expect(api.recordPreschoolPlanningDaysTaken).not.toHaveBeenCalled()
|
||||
expect(bumpPlanVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets isRefreshing (not isLoading) while a mutation background-refreshes', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import { useWorkspace } from '@/context'
|
||||
import type { PreschoolPlanningDays } from '@/types'
|
||||
|
||||
interface UsePreschoolPlanningDaysResult {
|
||||
@@ -46,6 +47,7 @@ export function usePreschoolPlanningDays(
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
const { bumpPlanVersion } = useWorkspace()
|
||||
|
||||
const fetch = useCallback(
|
||||
async (options?: { background?: boolean }) => {
|
||||
@@ -81,8 +83,13 @@ export function usePreschoolPlanningDays(
|
||||
if (!childId) return
|
||||
await api.upsertPreschoolPlanningDaysPlanned(childId, year, { plannedDays })
|
||||
await fetch({ background: true })
|
||||
// Called only once per `PRESCHOOL_AUTOSAVE_DELAY_MS` debounce settling
|
||||
// (see `PreschoolPlanningYearRow` in Settings.tsx), never per
|
||||
// keystroke. Planning-day demand feeds the allocator — re-type the
|
||||
// rest of the plan's unpinned days. See `WorkspaceContext.planVersion`.
|
||||
bumpPlanVersion()
|
||||
},
|
||||
[childId, year, fetch],
|
||||
[childId, year, fetch, bumpPlanVersion],
|
||||
)
|
||||
|
||||
const recordTaken = useCallback(
|
||||
@@ -90,8 +97,10 @@ export function usePreschoolPlanningDays(
|
||||
if (!childId) return
|
||||
await api.recordPreschoolPlanningDaysTaken(childId, year, { takenDays })
|
||||
await fetch({ background: true })
|
||||
// Same debounce-settled cadence as `setPlanned` above.
|
||||
bumpPlanVersion()
|
||||
},
|
||||
[childId, year, fetch],
|
||||
[childId, year, fetch, bumpPlanVersion],
|
||||
)
|
||||
|
||||
return { data, isLoading, isRefreshing, error, setPlanned, recordTaken }
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { useVacationDays } from './useVacationDays'
|
||||
import * as api from '@/services/api'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { makeWorkspaceContextValue } from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
vi.mock('@/context')
|
||||
|
||||
const mockRow = {
|
||||
id: 'vd-1',
|
||||
@@ -17,8 +20,14 @@ const mockRow = {
|
||||
}
|
||||
|
||||
describe('useVacationDays', () => {
|
||||
let bumpPlanVersion: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
bumpPlanVersion = vi.fn()
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }),
|
||||
)
|
||||
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue([mockRow])
|
||||
vi.mocked(api.upsertVacationDays).mockResolvedValue(mockRow)
|
||||
})
|
||||
@@ -80,6 +89,7 @@ describe('useVacationDays', () => {
|
||||
savedDaysIn: 0,
|
||||
})
|
||||
expect(api.getVacationDaysByWorkspace).toHaveBeenCalledTimes(2)
|
||||
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sets isRefreshing (not isLoading) while a mutation background-refreshes the list', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import { useWorkspace } from '@/context'
|
||||
import type { VacationDays, VacationDaysCreateRequest } from '@/types'
|
||||
|
||||
interface UseVacationDaysResult {
|
||||
@@ -35,6 +36,7 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
const { bumpPlanVersion } = useWorkspace()
|
||||
|
||||
const fetch = useCallback(
|
||||
async (options?: { background?: boolean }) => {
|
||||
@@ -66,8 +68,12 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
|
||||
async (req: VacationDaysCreateRequest) => {
|
||||
await api.upsertVacationDays(req)
|
||||
await fetch({ background: true })
|
||||
// A vacation balance is a plan input — it changes how much vacation
|
||||
// the allocator has to spend, so re-typing the rest of the plan's
|
||||
// unpinned days needs to run. See `WorkspaceContext.planVersion`.
|
||||
bumpPlanVersion()
|
||||
},
|
||||
[fetch],
|
||||
[fetch, bumpPlanVersion],
|
||||
)
|
||||
|
||||
return { data, isLoading, isRefreshing, error, upsert }
|
||||
|
||||
@@ -4,6 +4,8 @@ export const calendar = {
|
||||
'calendar.deleteError': 'Failed to delete the planned day',
|
||||
'calendar.nextMonthAria': 'Next month',
|
||||
'calendar.partner': 'Partner',
|
||||
'calendar.pinnedDayAllFreeError':
|
||||
'None of the selected days can be saved as that type — free days (weekends/red days) can never be vacation, and parental leave on them needs the Settings toggle turned on.',
|
||||
'calendar.prevMonthAria': 'Previous month',
|
||||
'calendar.saveError': 'Failed to save the planned days',
|
||||
'calendar.today': 'Today',
|
||||
@@ -37,6 +39,10 @@ export const calendar = {
|
||||
'plannedDayModal.newTitle': 'Plan a Day',
|
||||
'plannedDayModal.parentLabel': 'Parent',
|
||||
'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
|
||||
'plannedDayModal.pinExcludesFromSyncNotice':
|
||||
'Pinning excludes this day from the planner’s automatic re-planning.',
|
||||
'plannedDayModal.freeDaysStayOffNotice':
|
||||
'Free days (weekends/red days) in your selection are left as days off — vacation can never go there, and parental leave needs "Take parental leave on red days" turned on in Settings.',
|
||||
'plannedDayModal.excludedNotice': 'You removed this day for {parent}.',
|
||||
'plannedDayModal.restoreExcludedButton': 'Put the day back',
|
||||
'plannedDayModal.rangeTitle': 'Plan {count} days',
|
||||
|
||||
@@ -15,6 +15,9 @@ export const dashboard = {
|
||||
'dashboard.figureExpiring': 'Expiring at age 4',
|
||||
'dashboard.figureLeft': 'Days left',
|
||||
'dashboard.figureUsed': 'Used',
|
||||
// Vacation days forecast lost to age-out or the carry-forward cap if the
|
||||
// current plan is followed — see `VacationYearBalance.expiring`.
|
||||
'dashboard.figureVacationAtRisk': 'Vacation at risk of being lost',
|
||||
'dashboard.hero.fkLinkText': 'Försäkringskassan',
|
||||
'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).',
|
||||
'dashboard.hero.noneAtRiskMessage': 'No days will be lost — your plan uses them all before {childName} turns 4 ({date}).',
|
||||
|
||||
@@ -30,4 +30,16 @@ export const plan = {
|
||||
'planResults.surplusAdviceUseLessVacation': 'Save fewer vacation days',
|
||||
'planResults.surplusAdviceWishMoreLeave': 'Wish for more leave',
|
||||
'planResults.unknownParent': 'Unknown parent',
|
||||
// `PlanWarning.messageKey`/`kind` for the backend's three warning kinds
|
||||
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
|
||||
// `_vacation_expiry_warnings`), rendered by `PlanWarningCard.tsx` on the
|
||||
// dashboard. `message_key` is `f"planWarnings.{kind}"` — matches these
|
||||
// body keys exactly.
|
||||
'planWarnings.sectionTitle': 'Risks',
|
||||
'planWarnings.sgiRiskTitle': 'SGI-protection risk',
|
||||
'planWarnings.sgi_risk': '{parent_name} is planned for only {days_planned} of the required {min_days_per_week} leave days in the week starting {week_start} — this can lower their sickness-benefit-qualifying income (SGI).',
|
||||
'planWarnings.daysLostAtAge4Title': 'Days at risk at the 4th birthday',
|
||||
'planWarnings.days_lost_at_age_4': '{parent_name} risks losing {expiring_days} parental-benefit {expiring_days|day|days} at the child\'s 4th birthday ({fourth_birthday}) — {projected_left_at_b4} {projected_left_at_b4|day|days} are projected to remain in total then.',
|
||||
'planWarnings.vacationExpiringTitle': 'Vacation days at risk',
|
||||
'planWarnings.vacation_expiring': '{expiring_days} vacation {expiring_days|day|days} at risk of being lost in {year} unless something is done.',
|
||||
} as const
|
||||
|
||||
@@ -113,6 +113,9 @@ export const settings = {
|
||||
'settings.youBadge': '(You)',
|
||||
'vacationDaysTable.assumedBadge': 'Assumed',
|
||||
'vacationDaysTable.assumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit below.',
|
||||
'vacationDaysTable.atRiskAgeDetail': '{days} days have passed the 5-year limit for saving vacation',
|
||||
'vacationDaysTable.atRiskHeader': 'At risk',
|
||||
'vacationDaysTable.atRiskOverCapDetail': '{days} days exceed how much may be saved for next year',
|
||||
'vacationDaysTable.empty': 'No years to show yet.',
|
||||
'vacationDaysTable.savedDaysHeader': 'Saved days carried in',
|
||||
'vacationDaysTable.savedStatus': 'Saved',
|
||||
|
||||
@@ -4,6 +4,8 @@ export const calendar = {
|
||||
'calendar.deleteError': 'Det gick inte att ta bort den planerade dagen',
|
||||
'calendar.nextMonthAria': 'Nästa månad',
|
||||
'calendar.partner': 'Partner',
|
||||
'calendar.pinnedDayAllFreeError':
|
||||
'Ingen av de valda dagarna kan sparas som den typen — lediga dagar (helg/röd dag) kan aldrig bli semester, och föräldrapenning på dem kräver att inställningen i Inställningar är påslagen.',
|
||||
'calendar.prevMonthAria': 'Föregående månad',
|
||||
'calendar.saveError': 'Det gick inte att spara de planerade dagarna',
|
||||
'calendar.today': 'Idag',
|
||||
@@ -37,6 +39,10 @@ export const calendar = {
|
||||
'plannedDayModal.newTitle': 'Planera en dag',
|
||||
'plannedDayModal.parentLabel': 'Förälder',
|
||||
'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
|
||||
'plannedDayModal.pinExcludesFromSyncNotice':
|
||||
'Att låsa en dag utesluter den från planerarens automatiska omplanering.',
|
||||
'plannedDayModal.freeDaysStayOffNotice':
|
||||
'Lediga dagar (helg/röd dag) i urvalet lämnas lediga — semester går aldrig att lägga dit, och föräldrapenning kräver att "Ta ut föräldrapenning på röda dagar" är påslaget i Inställningar.',
|
||||
'plannedDayModal.excludedNotice': 'Du har tagit bort den här dagen för {parent}.',
|
||||
'plannedDayModal.restoreExcludedButton': 'Lägg tillbaka dagen',
|
||||
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
|
||||
|
||||
@@ -15,6 +15,9 @@ export const dashboard = {
|
||||
'dashboard.figureExpiring': 'Förfaller vid 4 år',
|
||||
'dashboard.figureLeft': 'Dagar kvar',
|
||||
'dashboard.figureUsed': 'Använda',
|
||||
// Vacation days forecast lost to age-out or the carry-forward cap if the
|
||||
// current plan is followed — see `VacationYearBalance.expiring`.
|
||||
'dashboard.figureVacationAtRisk': 'Semester som riskerar att gå förlorad',
|
||||
'dashboard.hero.fkLinkText': 'Försäkringskassan',
|
||||
'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).',
|
||||
'dashboard.hero.noneAtRiskMessage': 'Inga dagar går förlorade — er plan hinner använda alla innan {childName} fyller 4 ({date}).',
|
||||
|
||||
@@ -30,4 +30,16 @@ export const plan = {
|
||||
'planResults.surplusAdviceUseLessVacation': 'Spara färre semesterdagar',
|
||||
'planResults.surplusAdviceWishMoreLeave': 'Önska mer ledighet',
|
||||
'planResults.unknownParent': 'Okänd förälder',
|
||||
// `PlanWarning.messageKey`/`kind` for the backend's three warning kinds
|
||||
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
|
||||
// `_vacation_expiry_warnings`), rendered by `PlanWarningCard.tsx` on the
|
||||
// dashboard. `message_key` is `f"planWarnings.{kind}"` — matches these
|
||||
// body keys exactly.
|
||||
'planWarnings.sectionTitle': 'Risker',
|
||||
'planWarnings.sgiRiskTitle': 'Risk för SGI-skyddet',
|
||||
'planWarnings.sgi_risk': '{parent_name} är planerad för endast {days_planned} av minst {min_days_per_week} ledighetsdagar veckan som börjar {week_start} — det kan sänka den sjukpenninggrundande inkomsten (SGI).',
|
||||
'planWarnings.daysLostAtAge4Title': 'Dagar riskerar att gå förlorade vid 4 år',
|
||||
'planWarnings.days_lost_at_age_4': '{parent_name} riskerar att förlora {expiring_days} {expiring_days|föräldrapenningdag|föräldrapenningdagar} vid barnets 4-årsdag ({fourth_birthday}) — {projected_left_at_b4} {projected_left_at_b4|dag|dagar} beräknas då finnas kvar totalt.',
|
||||
'planWarnings.vacationExpiringTitle': 'Semesterdagar riskerar att gå förlorade',
|
||||
'planWarnings.vacation_expiring': '{expiring_days} {expiring_days|semesterdag|semesterdagar} riskerar att gå förlorade {year} om inget görs.',
|
||||
} as const
|
||||
|
||||
@@ -113,6 +113,9 @@ export const settings = {
|
||||
'settings.youBadge': '(Du)',
|
||||
'vacationDaysTable.assumedBadge': 'Antaget',
|
||||
'vacationDaysTable.assumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra nedan.',
|
||||
'vacationDaysTable.atRiskAgeDetail': '{days} dagar har passerat 5-årsgränsen för att spara semester',
|
||||
'vacationDaysTable.atRiskHeader': 'Riskerar',
|
||||
'vacationDaysTable.atRiskOverCapDetail': '{days} dagar överstiger hur mycket som får sparas till nästa år',
|
||||
'vacationDaysTable.empty': 'Inga år att visa ännu.',
|
||||
'vacationDaysTable.savedDaysHeader': 'Sparade dagar in',
|
||||
'vacationDaysTable.savedStatus': 'Sparat',
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
makePlanApplyResponse,
|
||||
makePlannedDayExclusionsResult,
|
||||
makePlannedDayExclusion,
|
||||
makeWorkspace,
|
||||
} from '../../tests/fixtures'
|
||||
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
|
||||
|
||||
@@ -992,9 +993,11 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
|
||||
// Sync was still called once, from the initial auto-sync on mount —
|
||||
// but not a second time for this ad-hoc delete (it isn't wish-driven).
|
||||
expect(sync).toHaveBeenCalledTimes(1)
|
||||
// An ad-hoc delete removes the planned day directly and never syncs
|
||||
// (it isn't wish-driven) — Calendar itself performs no auto-sync of
|
||||
// its own any more (see the "§Task7 auto-sync moved to
|
||||
// PlanSyncProvider" describe block below).
|
||||
expect(sync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the popover on close/cancel', () => {
|
||||
@@ -1358,10 +1361,8 @@ describe('Calendar', () => {
|
||||
const today = todayDateString()
|
||||
const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
// Every call (including the initial auto-sync on mount, which is not
|
||||
// under test here) reports the same non-empty diff — the point of
|
||||
// this test is the UNDO's re-sync, not what any particular sync call
|
||||
// returns.
|
||||
// Every call reports the same non-empty diff — the point of this test
|
||||
// is the UNDO's re-sync, not what any particular sync call returns.
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
@@ -1372,8 +1373,6 @@ describe('Calendar', () => {
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
// Let the initial auto-sync (unrelated to this test) settle first.
|
||||
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-save'))
|
||||
@@ -1478,17 +1477,14 @@ describe('Calendar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('§Task7 initial auto-sync', () => {
|
||||
it('syncs once per workspace on mount without user interaction', async () => {
|
||||
describe('§Task7 auto-sync moved to PlanSyncProvider', () => {
|
||||
it('performs no sync of its own on mount — that is now PlanSyncProvider, mounted globally inside WorkspaceProvider', () => {
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
const { rerender } = render(<Calendar />)
|
||||
await waitFor(() => expect(sync).toHaveBeenCalledWith('ws-1'))
|
||||
render(<Calendar />)
|
||||
|
||||
rerender(<Calendar />)
|
||||
// No extra auto-sync from a plain re-render of the same workspace.
|
||||
expect(sync).toHaveBeenCalledTimes(1)
|
||||
expect(sync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not crash when the workspace id is not yet available', () => {
|
||||
@@ -1613,10 +1609,14 @@ describe('Calendar', () => {
|
||||
})
|
||||
|
||||
it('shows a generic error toast for a non-Error rejection while syncing', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockRejectedValue('boom')
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('preset-click'))
|
||||
|
||||
await waitFor(() => {
|
||||
const toastEl = screen.getByTestId('calendar-toast')
|
||||
@@ -1704,11 +1704,15 @@ describe('Calendar', () => {
|
||||
await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
|
||||
})
|
||||
|
||||
it('shows the Error message directly when the initial sync rejects with an Error', async () => {
|
||||
it('shows the Error message directly when a sync rejects with an Error', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockRejectedValue(new Error('workspace not found'))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('preset-click'))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('workspace not found'))
|
||||
})
|
||||
@@ -1716,12 +1720,11 @@ describe('Calendar', () => {
|
||||
it('does not navigate or flash a band when the preset is created but the follow-up sync fails', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockResolvedValueOnce(makePlanSyncResponse()).mockRejectedValueOnce(new Error('sync failed'))
|
||||
const sync = vi.fn().mockRejectedValue(new Error('sync failed'))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
|
||||
|
||||
const yearBefore = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
|
||||
|
||||
@@ -1835,6 +1838,97 @@ describe('Calendar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hand-pin path never lets a free day become vacation (§ free days never consume vacation)', () => {
|
||||
// June 2026: the 1st is a Monday, so 06-01..06-07 is a clean Mon–Sun
|
||||
// week with 06-06/06-07 as the only free (weekend) days in it.
|
||||
const MON = '2026-06-01'
|
||||
const SUN = '2026-06-07'
|
||||
const SAT_DATE = '2026-06-06'
|
||||
const SUN_DATE = '2026-06-07'
|
||||
|
||||
function mockFreeWeekend() {
|
||||
vi.mocked(useCalendarDays).mockReturnValue(
|
||||
makeCalendarDaysResult({
|
||||
data: makeCalendarWindow({
|
||||
days: [
|
||||
makeCalendarDayEntry({ date: SAT_DATE, dayType: 'weekend', isFree: true }),
|
||||
makeCalendarDayEntry({ date: SUN_DATE, dayType: 'weekend', isFree: true }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function selectMonToSun() {
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${MON}`), mockSetSearchParams])
|
||||
render(<Calendar />)
|
||||
const days = screen.getAllByTestId('calendar-day')
|
||||
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(MON))!
|
||||
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN))!
|
||||
fireEvent.pointerDown(startDay)
|
||||
fireEvent.pointerEnter(endDay)
|
||||
fireEvent.pointerUp(window)
|
||||
}
|
||||
|
||||
it('pinning a Mon–Sun range as vacation creates 5 rows, skipping the weekend', async () => {
|
||||
mockFreeWeekend()
|
||||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
|
||||
|
||||
selectMonToSun()
|
||||
const modal = await screen.findByTestId('modal')
|
||||
expect(modal).toHaveAttribute('data-day-count', '7')
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(5))
|
||||
const pinnedDates = add.mock.calls.map((call) => (call[0] as { date: string }).date)
|
||||
expect(pinnedDates).not.toContain(SAT_DATE)
|
||||
expect(pinnedDates).not.toContain(SUN_DATE)
|
||||
})
|
||||
|
||||
it('pinning the same range as sjukpenning with takePlOnRedDays on creates all 7 rows', async () => {
|
||||
mockFreeWeekend()
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
makeWorkspaceContextValue({
|
||||
selectedChildId: 'child-1',
|
||||
personalDataUuid: 'person-1',
|
||||
workspace: makeWorkspace({ takePlOnRedDays: true }),
|
||||
}),
|
||||
)
|
||||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
|
||||
|
||||
selectMonToSun()
|
||||
const modal = await screen.findByTestId('modal')
|
||||
expect(modal).toHaveAttribute('data-day-count', '7')
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned-sjukpenning'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(7))
|
||||
})
|
||||
|
||||
it('shows an error and saves nothing when every selected date is free and ineligible', async () => {
|
||||
mockFreeWeekend()
|
||||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
|
||||
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${SAT_DATE}`), mockSetSearchParams])
|
||||
render(<Calendar />)
|
||||
const days = screen.getAllByTestId('calendar-day')
|
||||
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SAT_DATE))!
|
||||
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN_DATE))!
|
||||
fireEvent.pointerDown(startDay)
|
||||
fireEvent.pointerEnter(endDay)
|
||||
fireEvent.pointerUp(window)
|
||||
|
||||
const modal = await screen.findByTestId('modal')
|
||||
expect(modal).toHaveAttribute('data-day-count', '2')
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('modal')).toBeInTheDocument())
|
||||
expect(add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid layout: uniform cell size and row height regardless of content', () => {
|
||||
it('keeps every week row on the same shared grid definition even when one week contains a long holiday name', () => {
|
||||
// Regression guard for the "31 October" bug: a long holiday name
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useWorkspace } from '@/context'
|
||||
import {
|
||||
@@ -421,15 +421,13 @@ export function Calendar(): ReactNode {
|
||||
[syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast],
|
||||
)
|
||||
|
||||
// Sync once per workspace as soon as it's known, so wishes seeded/created
|
||||
// elsewhere show up as bands without requiring a manual edit first.
|
||||
const didAutoSyncRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!workspace?.id) return
|
||||
if (didAutoSyncRef.current === workspace.id) return
|
||||
didAutoSyncRef.current = workspace.id
|
||||
void runSync(workspace.id, { silent: true })
|
||||
}, [workspace?.id, runSync])
|
||||
// The one-time-per-workspace auto-sync that used to live here (so wishes
|
||||
// seeded/created elsewhere showed up as bands without a manual edit
|
||||
// first) is now `PlanSyncProvider`'s job — it runs globally, for every
|
||||
// plan-input bump, from the moment the workspace is known, not only
|
||||
// while Calendar happens to be mounted. See `WorkspaceContext.tsx`/
|
||||
// `PlanSyncProvider.tsx`. Calendar keeps only the `runSync` calls below
|
||||
// that need their own toast/undo UI.
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => {
|
||||
@@ -596,13 +594,31 @@ export function Calendar(): ReactNode {
|
||||
}) => {
|
||||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
|
||||
|
||||
// A free day (Saturday, Sunday, or Swedish red day, subject to a
|
||||
// workspace DayOverride) can never be `vacation`; it may be
|
||||
// `sjukpenning`/`lagsta` only when the workspace opted in via
|
||||
// `takePlOnRedDays`. Filtering here keeps the request from ever
|
||||
// reaching the backend's authoritative guard
|
||||
// (`PlanningService._reject_illegal_free_day`) in the common case.
|
||||
const eligibleDates = Array.from(selectedDates).filter((date) => {
|
||||
const isFree = calendarDaysByDate.get(date)?.isFree ?? false
|
||||
if (!isFree) return true
|
||||
if (payload.dayType === 'vacation') return false
|
||||
return workspace.takePlOnRedDays === true
|
||||
})
|
||||
|
||||
if (eligibleDates.length === 0) {
|
||||
setModalError(t('calendar.pinnedDayAllFreeError'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setModalError(null)
|
||||
|
||||
setLastLeaveType(payload.dayType)
|
||||
|
||||
for (const date of selectedDates) {
|
||||
for (const date of eligibleDates) {
|
||||
const existingDay = getExistingDay(date, payload.parentId)
|
||||
|
||||
if (existingDay) {
|
||||
@@ -1087,6 +1103,12 @@ export function Calendar(): ReactNode {
|
||||
const defaultLeaveType = getLastLeaveType() ?? 'vacation'
|
||||
const firstSelectedDate = Array.from(selectedDates).sort()[0]
|
||||
|
||||
// Free dates (weekend/red day, per workspace DayOverride) within the
|
||||
// current selection — drives PlannedDayModal's "will stay off" notice.
|
||||
const freeDatesInSelection = Array.from(selectedDates).filter(
|
||||
(date) => calendarDaysByDate.get(date)?.isFree ?? false,
|
||||
)
|
||||
|
||||
// Dates to highlight in the grid. While dragging, show a live preview of the
|
||||
// range under the cursor; otherwise show the committed selection.
|
||||
const highlightedDates =
|
||||
@@ -1278,6 +1300,8 @@ export function Calendar(): ReactNode {
|
||||
defaultDayType={defaultLeaveType}
|
||||
isRangeMode={selectedDates.size > 1}
|
||||
dayCount={selectedDates.size}
|
||||
freeDatesInSelection={freeDatesInSelection}
|
||||
takePlOnRedDays={workspace?.takePlOnRedDays === true}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
makePreschoolPlanningDays,
|
||||
makePlanProposal,
|
||||
makeSurplusAdvice,
|
||||
makePlanWarning,
|
||||
makeWindowOutcome,
|
||||
makeParentalLeaveFetch,
|
||||
makeParentYearBalance,
|
||||
@@ -79,6 +80,8 @@ const workspaceValue = (overrides: Partial<ReturnType<typeof useWorkspace>> = {}
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
|
||||
const alex = makePersonalData({ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex' })
|
||||
const sam = makePersonalData({ uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam' })
|
||||
|
||||
@@ -318,11 +321,52 @@ describe('Dashboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the three key figures from the combined analytics', () => {
|
||||
it('renders the four key figures from the combined analytics', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270')
|
||||
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30')
|
||||
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100')
|
||||
// Default mock has no parent projections, so nothing is at risk yet.
|
||||
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('sums vacation days at risk across every parent and current/future year, ignoring past years', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||||
coupleAnalytics({
|
||||
parentProjections: [
|
||||
projection('pd-1', {
|
||||
yearBalances: [
|
||||
makeParentYearBalance({
|
||||
personalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
year: 2020,
|
||||
vacation: makeVacationYearBalance({ year: 2020, expiring: 99 }),
|
||||
}),
|
||||
makeParentYearBalance({
|
||||
personalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
year: CURRENT_YEAR,
|
||||
vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 3 }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
projection('pd-2', {
|
||||
yearBalances: [
|
||||
makeParentYearBalance({
|
||||
personalDataUuid: 'pd-2',
|
||||
childId: 'c-1',
|
||||
year: CURRENT_YEAR,
|
||||
vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 4 }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
const tile = screen.getByTestId('dashboard-figure-vacationAtRisk')
|
||||
expect(tile).toHaveTextContent('7')
|
||||
expect(tile.querySelector('dd')).toHaveClass('key-figures__value--danger')
|
||||
})
|
||||
|
||||
describe('"Fram till 4 år" section', () => {
|
||||
@@ -460,6 +504,63 @@ describe('Dashboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Warnings', () => {
|
||||
it('is hidden entirely when the proposal has no warnings', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.queryByTestId('dashboard-warnings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders each warning above the tips section, with the parent name resolved', () => {
|
||||
vi.mocked(hooks.useOptimizationTips).mockReturnValue(
|
||||
makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] }),
|
||||
)
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
warnings: [
|
||||
makePlanWarning({
|
||||
kind: 'days_lost_at_age_4',
|
||||
messageKey: 'planWarnings.days_lost_at_age_4',
|
||||
data: {
|
||||
parent_personal_data_uuid: 'pd-2',
|
||||
child_id: 'child-1',
|
||||
expiring_days: 15,
|
||||
projected_left_at_b4: 40,
|
||||
fourth_birthday: '2029-06-01',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
const warningsSection = screen.getByTestId('dashboard-warnings')
|
||||
const tipsSection = screen.getByTestId('dashboard-tips')
|
||||
expect(warningsSection).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dashboard-plan-warning-0')).toHaveTextContent('Sam')
|
||||
expect(screen.getByTestId('dashboard-plan-warning-0')).toHaveTextContent('15 föräldrapenningdagar vid barnets 4-årsdag (1 juni 2029)')
|
||||
|
||||
// Warnings render above the tips section — DOCUMENT_POSITION_FOLLOWING
|
||||
// set on `tipsSection` means it comes after `warningsSection`.
|
||||
const position = warningsSection.compareDocumentPosition(tipsSection)
|
||||
expect(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
|
||||
})
|
||||
|
||||
it('degrades gracefully for an unrecognised warning kind', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
warnings: [makePlanWarning({ kind: 'brand_new_warning', messageKey: 'planWarnings.brand_new_warning', data: {} })],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
const warning = screen.getByTestId('dashboard-plan-warning-0')
|
||||
expect(warning).toHaveTextContent('brand_new_warning')
|
||||
expect(warning).toHaveTextContent('planWarnings.brand_new_warning')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tips', () => {
|
||||
it('is hidden entirely when there are neither tips nor surplus advice', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
|
||||
@@ -24,6 +24,7 @@ import { KeyFigures } from '@/components/dashboard/KeyFigures'
|
||||
import { Age4Bars } from '@/components/dashboard/Age4Bars'
|
||||
import { YearRows } from '@/components/dashboard/YearRows'
|
||||
import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
|
||||
import { PlanWarningCard } from '@/components/dashboard/PlanWarningCard'
|
||||
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
|
||||
import { TipCard } from '@/components/inputs/TipCard'
|
||||
import { Onboarding } from '@/pages/Onboarding'
|
||||
@@ -133,6 +134,20 @@ export function Dashboard(): ReactNode {
|
||||
// the combined total, so skip the breakdown in that case.
|
||||
const showPerParentExpiring = parentProjections.length > 1
|
||||
|
||||
// Vacation days forecast lost (age-out or over the carry-forward cap),
|
||||
// across every parent, from this year onward — a past year's loss already
|
||||
// happened and is no longer actionable (same guard `YearRows` uses for
|
||||
// its own per-year expiring figure).
|
||||
const currentYear = new Date().getFullYear()
|
||||
const vacationAtRisk = parentProjections.reduce(
|
||||
(sum, projection) =>
|
||||
sum +
|
||||
projection.yearBalances
|
||||
.filter((balance) => balance.year >= currentYear)
|
||||
.reduce((yearSum, balance) => yearSum + balance.vacation.expiring, 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const parentDisplayName = (uuid: string, index: number): string => {
|
||||
const resolved = membersLoading ? uuid : nameFor(uuid)
|
||||
return resolved === uuid
|
||||
@@ -217,6 +232,7 @@ export function Dashboard(): ReactNode {
|
||||
}))
|
||||
|
||||
const surplusAdvice = proposal?.surplusAdvice ?? []
|
||||
const warnings = proposal?.warnings ?? []
|
||||
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
|
||||
const heroRemedy = deriveHeroRemedy(tips)
|
||||
|
||||
@@ -254,6 +270,7 @@ export function Dashboard(): ReactNode {
|
||||
daysLeft={analytics.combinedDaysLeft}
|
||||
daysUsed={analytics.combinedDaysUsed}
|
||||
expiringAtB4={analytics.combinedExpiringAtB4}
|
||||
vacationAtRisk={vacationAtRisk}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -278,6 +295,17 @@ export function Dashboard(): ReactNode {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<section className="dashboard__section" data-testid="dashboard-warnings">
|
||||
<h2>{t('planWarnings.sectionTitle')}</h2>
|
||||
<div className="dashboard__tips-list">
|
||||
{warnings.map((warning, index) => (
|
||||
<PlanWarningCard key={`${warning.kind}-${index}`} warning={warning} nameFor={nameForAdvice} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasTips && (
|
||||
<section className="dashboard__section" data-testid="dashboard-tips">
|
||||
<h2>{t('tips.title')}</h2>
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
makeDayOverridesResult,
|
||||
makeCalendarDaysResult,
|
||||
makePreschoolPlanningDaysResult,
|
||||
makeAnalyticsResult,
|
||||
makeAge4Projection,
|
||||
makeParentYearBalance,
|
||||
makeVacationYearBalance,
|
||||
} from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
@@ -128,6 +132,7 @@ describe('Settings', () => {
|
||||
let mockUpsertDayOverride: ReturnType<typeof vi.fn>
|
||||
let mockRemoveDayOverride: ReturnType<typeof vi.fn>
|
||||
let mockRefreshMembers: ReturnType<typeof vi.fn>
|
||||
let mockBumpPlanVersion: ReturnType<typeof vi.fn>
|
||||
|
||||
const mockCalendarWindow = {
|
||||
startYear: CURRENT_YEAR,
|
||||
@@ -147,6 +152,7 @@ describe('Settings', () => {
|
||||
mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined)
|
||||
mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined)
|
||||
mockRefreshMembers = vi.fn().mockResolvedValue(undefined)
|
||||
mockBumpPlanVersion = vi.fn()
|
||||
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
makeWorkspaceContextValue({
|
||||
@@ -160,6 +166,7 @@ describe('Settings', () => {
|
||||
selectedChildId: 'c-1',
|
||||
personalDataUuid: 'pd-1',
|
||||
parentName: 'Test Parent',
|
||||
bumpPlanVersion: mockBumpPlanVersion,
|
||||
})
|
||||
)
|
||||
// Default: solo workspace (only the logged-in user) — the add-partner
|
||||
@@ -176,6 +183,10 @@ describe('Settings', () => {
|
||||
upsert: mockUpsertVacationDays,
|
||||
})
|
||||
)
|
||||
// Default: no analytics data (matches a fresh/no-child workspace) — the
|
||||
// vacation table's "at risk" column then shows 0, not blocking on it.
|
||||
// Tests exercising the at-risk column override this.
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue(makeAnalyticsResult({ data: null }))
|
||||
vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, vi.fn()))
|
||||
vi.mocked(hooks.useChildSettings).mockReturnValue(
|
||||
makeChildSettingsResult({
|
||||
@@ -277,6 +288,39 @@ describe('Settings', () => {
|
||||
expect(totalInput.value).toBe('25')
|
||||
})
|
||||
|
||||
it('surfaces the analytics-computed at-risk figure for a year, joined by member and year', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||||
makeAnalyticsResult({
|
||||
data: {
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
parentProjections: [
|
||||
makeAge4Projection({
|
||||
personalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
yearBalances: [
|
||||
makeParentYearBalance({
|
||||
personalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
year: CURRENT_YEAR,
|
||||
vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 6, expiringAge: 6, expiringOverCap: 0 }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
combinedDaysUsed: 0,
|
||||
combinedDaysLeft: 0,
|
||||
combinedExpiringAtB4: 0,
|
||||
yearTotals: [],
|
||||
},
|
||||
})
|
||||
)
|
||||
renderSettings()
|
||||
expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR}`)).toHaveTextContent('6')
|
||||
// A year with no matching yearBalance falls back to 0, not crashing.
|
||||
expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR + 1}`)).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('autosaves an edited year with its total days, with no Save button', async () => {
|
||||
renderSettings()
|
||||
const year = CURRENT_YEAR + 1
|
||||
@@ -397,6 +441,10 @@ describe('Settings', () => {
|
||||
employment_status: 'employed',
|
||||
}),
|
||||
)
|
||||
// Salary type feeds the allocator — the parent-settings save must
|
||||
// re-plan, not just refetch. (Child-details bumps separately via
|
||||
// useChildSettings.update, exercised by useChildSettings.test.ts.)
|
||||
await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('shows an error when the combined family save fails', async () => {
|
||||
@@ -404,6 +452,7 @@ describe('Settings', () => {
|
||||
renderSettings()
|
||||
fireEvent.click(screen.getByTestId('save-family-button'))
|
||||
expect(await screen.findByTestId('save-message')).toHaveTextContent('save failed')
|
||||
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -732,6 +781,7 @@ describe('Settings', () => {
|
||||
personalDataUuid: 'pd-1',
|
||||
parentName: 'Test Parent',
|
||||
refreshContext: mockRefreshContext,
|
||||
bumpPlanVersion: mockBumpPlanVersion,
|
||||
})
|
||||
)
|
||||
renderSettings()
|
||||
@@ -742,6 +792,9 @@ describe('Settings', () => {
|
||||
expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { takePlOnRedDays: true })
|
||||
)
|
||||
expect(mockRefreshContext).toHaveBeenCalled()
|
||||
// Whether PL may be taken on red days changes what the allocator can
|
||||
// place leave on — the toggle must re-plan, not just refresh context.
|
||||
expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows an error message when saving fails', async () => {
|
||||
@@ -751,6 +804,7 @@ describe('Settings', () => {
|
||||
fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox'))
|
||||
|
||||
expect(await screen.findByTestId('save-message')).toHaveTextContent('network down')
|
||||
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWorkspaceMembers,
|
||||
usePreschoolPlanningDays,
|
||||
useDebouncedCallback,
|
||||
useAnalytics,
|
||||
} from '@/hooks'
|
||||
import * as api from '@/services/api'
|
||||
import { isApiError } from '@/services/errors'
|
||||
@@ -221,7 +222,7 @@ function PreschoolPlanningYearRow({
|
||||
|
||||
export function Settings(): ReactNode {
|
||||
const { t, explicitLocale, setLocale } = useTranslation()
|
||||
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
|
||||
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext, bumpPlanVersion } = useWorkspace()
|
||||
const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '')
|
||||
const {
|
||||
data: members,
|
||||
@@ -268,6 +269,14 @@ export function Settings(): ReactNode {
|
||||
// a child is chosen/created elsewhere in the app.
|
||||
const balanceChildId = selectedChildId ?? children[0]?.id ?? null
|
||||
|
||||
// Supplies the vacation-days table's "at risk" column (already computed by
|
||||
// the same analytics endpoint Dashboard.tsx uses — see
|
||||
// `AnalyticsService._vacation_year_series`). Deliberately not folded into
|
||||
// `isLoading` below: it's an annotation on the editable table, not data
|
||||
// the table needs to render at all, and a new/unselected-child workspace
|
||||
// (no `balanceChildId` yet) must not block the rest of the page on it.
|
||||
const { data: analytics } = useAnalytics(workspace?.id ?? null, balanceChildId ?? undefined)
|
||||
|
||||
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
|
||||
|
||||
useEffect(() => {
|
||||
@@ -303,9 +312,14 @@ export function Settings(): ReactNode {
|
||||
// `vacDays`, showing (and letting you overwrite) the wrong parent's data.
|
||||
const vacationRowsByMember: VacationDaysTableRow[][] = useMemo(
|
||||
() =>
|
||||
orderedMembers.map((member) =>
|
||||
vacationYears.map((year) => {
|
||||
orderedMembers.map((member) => {
|
||||
const projection = analytics?.parentProjections.find((p) => p.personalDataUuid === member.uuid)
|
||||
return vacationYears.map((year) => {
|
||||
const existing = vacDays.find((v: VacationDays) => v.personalDataUuid === member.uuid && v.year === year)
|
||||
// Analytics may not have loaded yet (or there's no child selected)
|
||||
// — the at-risk figures then default to 0 rather than block the
|
||||
// rest of this editable row on a supplementary fetch.
|
||||
const vacationBalance = projection?.yearBalances.find((b) => b.year === year)?.vacation
|
||||
return {
|
||||
year,
|
||||
totalDays: existing?.totalDays ?? DEFAULT_VACATION_DAYS,
|
||||
@@ -314,10 +328,13 @@ export function Settings(): ReactNode {
|
||||
// default is shown, not an entered value. Mirrors the backend's
|
||||
// `VacationBalanceResponse.is_assumed` for the propose-time table.
|
||||
isAssumed: existing === undefined,
|
||||
expiring: vacationBalance?.expiring ?? 0,
|
||||
expiringAge: vacationBalance?.expiringAge ?? 0,
|
||||
expiringOverCap: vacationBalance?.expiringOverCap ?? 0,
|
||||
}
|
||||
}),
|
||||
),
|
||||
[orderedMembers, vacationYears, vacDays],
|
||||
})
|
||||
}),
|
||||
[orderedMembers, vacationYears, vacDays, analytics],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
@@ -354,6 +371,11 @@ export function Settings(): ReactNode {
|
||||
salary_type: parentData.salaryType,
|
||||
employment_status: parentData.employmentStatus,
|
||||
})
|
||||
// Salary type feeds the allocator's cost calculations (sjukpenning
|
||||
// vs lägstanivå tradeoffs) — re-type the rest of the plan's
|
||||
// unpinned days. See `WorkspaceContext.planVersion`. (Child-details
|
||||
// saves above already bump via `useChildSettings.update`.)
|
||||
bumpPlanVersion()
|
||||
}
|
||||
showMessage('success', t('settings.saveFamilySuccess'))
|
||||
} catch (err) {
|
||||
@@ -499,6 +521,10 @@ export function Settings(): ReactNode {
|
||||
setIsSavingRedDayPref(true)
|
||||
await api.updateWorkspace(workspace.id, { takePlOnRedDays: checked })
|
||||
await refreshContext()
|
||||
// Whether PL may be taken on red days changes which dates the
|
||||
// allocator can place leave on — re-type the rest of the plan's
|
||||
// unpinned days. See `WorkspaceContext.planVersion`.
|
||||
bumpPlanVersion()
|
||||
} catch (err) {
|
||||
showMessage('error', err instanceof Error ? err.message : t('settings.takePlOnRedDaysError'))
|
||||
} finally {
|
||||
|
||||
@@ -1749,6 +1749,11 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
new_wish_id: 'lw1',
|
||||
},
|
||||
],
|
||||
eviction_preview: {
|
||||
would_evict: false,
|
||||
candidates: [],
|
||||
shortages: [],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1798,6 +1803,92 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
oldWishId: null,
|
||||
newWishId: 'lw1',
|
||||
})
|
||||
expect(result.evictionPreview).toEqual({ wouldEvict: false, candidates: [], shortages: [] })
|
||||
})
|
||||
|
||||
it('syncPlan maps a would-evict eviction_preview with candidates and shortages', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
proposal: {
|
||||
days: [],
|
||||
outcomes: [],
|
||||
warnings: [],
|
||||
pool_balances: [],
|
||||
vacation_balances: [],
|
||||
surplus_advice: [],
|
||||
age4_projections: [],
|
||||
},
|
||||
apply_result: {
|
||||
accepted: false,
|
||||
created_count: 0,
|
||||
skipped_identical_count: 0,
|
||||
skipped_free_count: 0,
|
||||
replaced_count: 0,
|
||||
collisions: [],
|
||||
rejections: [
|
||||
{
|
||||
message_key: 'planSync.rejection.evictionChoiceRequired',
|
||||
data: { count: 1 },
|
||||
},
|
||||
],
|
||||
created_days: [],
|
||||
},
|
||||
changes: [],
|
||||
eviction_preview: {
|
||||
would_evict: true,
|
||||
candidates: [
|
||||
{
|
||||
date: '2028-06-20',
|
||||
parent_personal_data_uuid: 'pd1',
|
||||
child_id: 'child1',
|
||||
day_type: 'vacation',
|
||||
is_dubbeldag: false,
|
||||
wish_id: 'lw1',
|
||||
wish_name: 'Sommar',
|
||||
},
|
||||
],
|
||||
shortages: [
|
||||
{
|
||||
resource_kind: 'vacation',
|
||||
parent_personal_data_uuid: 'pd1',
|
||||
tier: null,
|
||||
child_id: null,
|
||||
year: 2028,
|
||||
days_evicted: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await api.syncPlan('ws1')
|
||||
|
||||
expect(result.evictionPreview).toEqual({
|
||||
wouldEvict: true,
|
||||
candidates: [
|
||||
{
|
||||
date: '2028-06-20',
|
||||
parentPersonalDataUuid: 'pd1',
|
||||
childId: 'child1',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
wishId: 'lw1',
|
||||
wishName: 'Sommar',
|
||||
},
|
||||
],
|
||||
shortages: [
|
||||
{
|
||||
resourceKind: 'vacation',
|
||||
parentPersonalDataUuid: 'pd1',
|
||||
tier: null,
|
||||
childId: null,
|
||||
year: 2028,
|
||||
daysEvicted: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
|
||||
|
||||
@@ -1121,10 +1121,59 @@ function toPlanChange(raw: RawPlanChange): PlanChange {
|
||||
}
|
||||
}
|
||||
|
||||
interface RawEvictionCandidate {
|
||||
date: string
|
||||
parent_personal_data_uuid: string
|
||||
child_id: string
|
||||
day_type: string
|
||||
is_dubbeldag: boolean
|
||||
wish_id: string
|
||||
wish_name: string | null
|
||||
}
|
||||
|
||||
interface RawResourceShortage {
|
||||
resource_kind: string
|
||||
parent_personal_data_uuid: string
|
||||
tier: string | null
|
||||
child_id: string | null
|
||||
year: number | null
|
||||
days_evicted: number
|
||||
}
|
||||
|
||||
interface RawEvictionPreview {
|
||||
would_evict: boolean
|
||||
candidates: RawEvictionCandidate[]
|
||||
shortages: RawResourceShortage[]
|
||||
}
|
||||
|
||||
function toEvictionPreview(raw: RawEvictionPreview): import('@/types').EvictionPreview {
|
||||
return {
|
||||
wouldEvict: raw.would_evict,
|
||||
candidates: raw.candidates.map((c) => ({
|
||||
date: c.date,
|
||||
parentPersonalDataUuid: c.parent_personal_data_uuid,
|
||||
childId: c.child_id,
|
||||
dayType: c.day_type as import('@/types').EvictionCandidate['dayType'],
|
||||
isDubbeldag: c.is_dubbeldag,
|
||||
wishId: c.wish_id,
|
||||
wishName: c.wish_name,
|
||||
})),
|
||||
shortages: raw.shortages.map((s) => ({
|
||||
resourceKind: s.resource_kind,
|
||||
parentPersonalDataUuid: s.parent_personal_data_uuid,
|
||||
tier: s.tier,
|
||||
childId: s.child_id,
|
||||
year: s.year,
|
||||
daysEvicted: s.days_evicted,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
interface RawPlanSyncResponse {
|
||||
proposal: RawPlanProposal
|
||||
apply_result: RawPlanApplyResponse
|
||||
changes: RawPlanChange[]
|
||||
eviction_preview: RawEvictionPreview
|
||||
}
|
||||
|
||||
/** POST /api/plan/sync — the direct-manipulation calendar's one call:
|
||||
@@ -1143,6 +1192,7 @@ export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
|
||||
proposal: toPlanProposal(raw.proposal),
|
||||
applyResult: toPlanApplyResponse(raw.apply_result),
|
||||
changes: raw.changes.map(toPlanChange),
|
||||
evictionPreview: toEvictionPreview(raw.eviction_preview),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -474,6 +474,10 @@
|
||||
background-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.tip-card--danger::before {
|
||||
background-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.tip-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -197,6 +197,10 @@ export interface PlanSyncResponse {
|
||||
proposal: import('./entities').PlanProposal
|
||||
applyResult: PlanApplyResponse
|
||||
changes: import('./entities').PlanChange[]
|
||||
// Always present — `evictionPreview.wouldEvict === true` means this call
|
||||
// wrote NOTHING (see `EvictionPreview`'s doc comment in entities.ts). A
|
||||
// caller must check this, not just `applyResult.accepted`/`changes`.
|
||||
evictionPreview: import('./entities').EvictionPreview
|
||||
}
|
||||
|
||||
// Day overrides (calendar exceptions)
|
||||
|
||||
@@ -113,6 +113,45 @@ export interface PlanChange {
|
||||
newWishId: string | null
|
||||
}
|
||||
|
||||
/** One day a `POST /api/plan/sync` eviction preview would remove — see
|
||||
* `EvictionPreview`'s docstring. Structured data only, same house style as
|
||||
* `PlanChange`: the frontend composes the warning copy itself. */
|
||||
export interface EvictionCandidate {
|
||||
date: string // ISO-8601 date
|
||||
parentPersonalDataUuid: string
|
||||
childId: string
|
||||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
isDubbeldag: boolean
|
||||
wishId: string
|
||||
wishName: string | null
|
||||
}
|
||||
|
||||
/** One exhausted resource behind an eviction preview.
|
||||
* `resourceKind === 'parental_leave'` carries `tier`/`childId` (`year` is
|
||||
* `null`); `resourceKind === 'vacation'` carries `year` instead (`tier`/
|
||||
* `childId` are `null`). */
|
||||
export interface ResourceShortage {
|
||||
resourceKind: string
|
||||
parentPersonalDataUuid: string
|
||||
tier: string | null
|
||||
childId: string | null
|
||||
year: number | null
|
||||
daysEvicted: number
|
||||
}
|
||||
|
||||
/** Always present on `PlanSyncResponse` — `wouldEvict: false` with empty
|
||||
* lists in the common (fits-within-budget) case (see
|
||||
* `backend/app/services/planning_service.py`'s `EvictionPreview`). When
|
||||
* `wouldEvict` is `true`, `sync_plan` wrote NOTHING at all (unless the
|
||||
* caller passed `eviction_choice`, which this app's frontend never does
|
||||
* yet) — a caller receiving this must surface it for a user decision, never
|
||||
* silently discard it and assume the plan was written. */
|
||||
export interface EvictionPreview {
|
||||
wouldEvict: boolean
|
||||
candidates: EvictionCandidate[]
|
||||
shortages: ResourceShortage[]
|
||||
}
|
||||
|
||||
export interface TierData {
|
||||
sjukpenningTotal: number
|
||||
sjukpenningUsed: number
|
||||
|
||||
Reference in New Issue
Block a user