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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user