Let a day removed from a period stay removed

Taking one day out of a period the planner placed did nothing that
lasted: the period still asked for a full week, so the next sync put the
day straight back. The option was disabled rather than lying about it.

A removed day is now recorded as its own thing — no leave for this
parent, this child, this date — and the planner honours it on every
later sync. It is kept apart from the working-day overrides, which
answer a different question about the calendar rather than about the
plan.

The day belongs to the parent whose band was clicked, so removing one
parent's day leaves their partner's alone; where both parents were to be
off on one funding arrangement, the partner is simply funded on their
own instead. A removed day shortens what the period is asking for rather
than counting as a day the plan failed to find, so the advice about
closing a shortfall does not start arguing for a day nobody wants.

Nothing is removed without a way back: the toast undoes it, and the day
itself is marked in the calendar and offers to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 16:05:17 +02:00
parent 6d92b87bf3
commit 8cfb9c82c6
39 changed files with 2362 additions and 31 deletions
+105 -3
View File
@@ -10,6 +10,7 @@ import {
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
} from '@/hooks'
import type {
PlannedLeaveDay,
@@ -308,6 +309,9 @@ export function Calendar(): ReactNode {
const wishesHook = useLeaveWishes(workspace?.id ?? null)
const membersHook = useWorkspaceMembers(workspace?.id ?? null)
const syncHook = usePlanSync()
// The days the user removed by hand. Read for the calendar's badges, and
// written by the popover's "this day" remove — see `excludeDay`.
const exclusionsHook = usePlannedDayExclusions(workspace?.id ?? null)
const calendarDaysByDate = useMemo(() => {
const map = new Map<string, CalendarDayEntry>()
@@ -317,6 +321,22 @@ export function Calendar(): ReactNode {
return map
}, [calendarHook.data])
// Any parent's removal marks the date: the mark says "someone deliberately
// took a day out here", which is what stops the hole reading as a bug. Whose
// day it was is shown in the day popover, where there is room to say it.
const excludedDates = useMemo(
() => new Set(exclusionsHook.data.map((e) => e.date)),
[exclusionsHook.data],
)
/** The display name for a band's owner. Used wherever the UI has to say
* WHOSE day something is — above all the "this day" remove, which takes one
* parent's day and leaves the partner's. Falls back the same way
* CalendarWeek's band labels do. */
const resolveParentName = (uuid: string): string =>
membersHook.data.find((m) => m.uuid === uuid)?.name ??
(uuid === personalDataUuid ? (parentName ?? t('calendar.you')) : t('calendar.partner'))
const showToast = useCallback((message: string, undo: CalendarToast['undo']) => {
setToast({ message, undo })
}, [])
@@ -836,6 +856,72 @@ export function Calendar(): ReactNode {
})
}
/** §"removing a single day should stay permanent" — the planner-placed
* counterpart of `deleteAdhocRange`. An auto-placed day cannot simply be
* deleted: the wish behind it still asks for the whole window, so the next
* sync would re-derive it. Recording a `PlannedDayExclusion` is what makes
* the removal outlive the sync.
*
* Keyed on the band's own parent, so a both-parents period loses only the
* band that was clicked and the partner keeps their day — the toast names
* the parent so that choice is never a silent one. Undo deletes the
* exclusion and re-syncs, which puts the day back where it was.
*/
const excludeDay = async (
workspaceId: string,
band: BandClickInfo,
parentLabel: string,
) => {
const childId =
(plannedDaysHook.data ?? []).find(
(pd) => pd.date === band.clickedDate && pd.parentPersonalDataUuid === band.parentPersonalDataUuid,
)?.childId ?? selectedChildId
if (!childId) {
closePeriodPopover()
return
}
const created = await exclusionsHook.exclude({
workspaceId,
childId,
parentPersonalDataUuid: band.parentPersonalDataUuid,
date: band.clickedDate,
})
closePeriodPopover()
await runSync(workspaceId, { silent: true })
const dateLabel = formatDateForDisplay(band.clickedDate, locale)
showToast(t('calendar.toastDayExcluded', { date: dateLabel, parent: parentLabel }), {
label: t('calendar.undoButton'),
run: async () => {
await exclusionsHook.restore(created.id)
await runSync(workspaceId, { silent: true })
showToast(t('calendar.toastDayRestored', { date: dateLabel, parent: parentLabel }), null)
},
})
}
/** The day cell's way back, for when the undo toast is long gone: the day
* popover lists this date's removals (one per parent) with a restore
* button. See `excludeDay` for the other direction. */
const handleRestoreExcluded = async (exclusionId: string) => {
if (!workspace?.id) return
const entry = exclusionsHook.data.find((e) => e.id === exclusionId)
await exclusionsHook.restore(exclusionId)
setIsModalOpen(false)
setSelectedDates(new Set())
await runSync(workspace.id, { silent: true })
if (entry) {
showToast(
t('calendar.toastDayRestored', {
date: formatDateForDisplay(entry.date, locale),
parent: resolveParentName(entry.parentPersonalDataUuid),
}),
null,
)
}
}
const handleDeletePeriod = async (scope: PeriodDeleteScope) => {
if (!workspace?.id || !activeBand) return
@@ -851,11 +937,20 @@ export function Calendar(): ReactNode {
if (scope === 'day') {
if (activeBand.wishId) {
// Disabled in the UI (would just reappear on the next sync).
closePeriodPopover()
// A planner-placed day: deleting the row would not survive the next
// sync, so record the removal instead.
await excludeDay(
workspace.id,
activeBand,
resolveParentName(activeBand.parentPersonalDataUuid),
)
return
}
await deleteAdhocRange(activeBand.startDate, activeBand.startDate, activeBand.parentPersonalDataUuid)
await deleteAdhocRange(
activeBand.clickedDate,
activeBand.clickedDate,
activeBand.parentPersonalDataUuid,
)
return
}
@@ -1100,6 +1195,7 @@ export function Calendar(): ReactNode {
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
excludedDates={excludedDates}
onBandClick={handleBandClick}
flashWishId={flashWishId}
monthMaxLaneCount={monthMaxLaneCount}
@@ -1156,6 +1252,10 @@ export function Calendar(): ReactNode {
onSaveWish={handleSaveWish}
onSavePinned={handleSavePinnedDay}
onDelete={getExistingDay(firstSelectedDate, advancedParentId) ? handleDeletePlannedDay : undefined}
excludedEntries={exclusionsHook.data
.filter((e) => e.date === firstSelectedDate)
.map((e) => ({ id: e.id, parentName: resolveParentName(e.parentPersonalDataUuid) }))}
onRestoreExcluded={handleRestoreExcluded}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())
@@ -1174,6 +1274,8 @@ export function Calendar(): ReactNode {
wish={activeWish}
startDate={activeBand.startDate}
endDate={activeBand.endDate}
removeDayLabel={formatDateForDisplay(activeBand.clickedDate, locale)}
removeDayParentName={resolveParentName(activeBand.parentPersonalDataUuid)}
parents={parentsForModals}
isSaving={isPeriodSaving}
error={periodError}