Say something when planning a free day changes nothing

Planning a day through the default "Planera en dag" dialog on a weekend or
red day accepted the save, returned 200, and did nothing visible: the
allocator marks a free day as 'free' before a type is chosen, the shortfall
count excludes it, and planning_service strips it before persisting — so the
sync came back accepted with an empty batch and the toast builder had nothing
to say. The click looked broken.

The modal already had the right words for this; they were only wired to the
advanced/pinned path. They now show in the default path too, so the user is
told before clicking. And an accepted sync that changed nothing now says so
instead of staying silent — opt-in at the one call site that needs it, since
the preset rail deliberately stays quiet on a genuine no-op.

No change to the allocator, to planning_service, or to the policy that free
days stay off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 09:49:22 +02:00
parent 2c46cf762d
commit 06592df87c
6 changed files with 139 additions and 17 deletions
@@ -130,16 +130,24 @@ export function PlannedDayModal({
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.
// choice) — the pin-exclusion notice below only makes 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.
// Would every free day (weekend/red day) in the selection stay OFF under
// the save path this dialog is currently set up to take? Pinning a
// concrete type: always for `vacation`, and for a PL type unless the
// workspace opted in via `takePlOnRedDays` — mirrors `Calendar.tsx`'s
// `handleSavePinnedDay` filter exactly. The default/auto (wish) path:
// `allocation_service.py`'s `is_free_day` branch fires BEFORE any type is
// chosen and marks the day 'free' unless `takePlOnRedDays`, regardless of
// parentMode/dubbeldag — so free days stay off there too, always, unless
// that same opt-in is on. Shown regardless of `isPinningConcreteType` so
// the default path warns just as loudly as the advanced one (see the
// "silent no-op" fix: planning a free day through the default dialog used
// to accept with nothing changed and no explanation).
const freeDaysWillStayOff =
isPinningConcreteType &&
freeDatesInSelection.length > 0 &&
(dayType === 'vacation' || !takePlOnRedDays)
(pinnedPath ? dayType === 'vacation' || !takePlOnRedDays : !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
@@ -348,6 +356,17 @@ export function PlannedDayModal({
</div>
)}
{/* The default path's own copy of the "will stay off" notice —
* `showPinnedControls` is false here (Advanced isn't open), so
* the copy inside that section below never renders for this
* case. Gated on `!showPinnedControls` rather than re-deriving
* "default path" so the two copies can never both render. */}
{!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{!forcedAdvanced && hasMultipleParents && (
<button
type="button"