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