Let the planner fund a hand-drawn period, and remove one at a time

Three changes to calendar editing, too interleaved in the same files to
land separately.

Giving the same days to the second parent rewrote the first parent's
days instead of adding their own. The save looked the existing day up
against whoever was logged in rather than the parent being saved for, so
parent A's leave turned into parent B's vacation. The parent choice now
lives with the lookup, and two parents on the same dates produce two
rows and two bands.

Drawing a period by hand meant naming a day type up front. It now asks
who is off and lets the planner fund it, as a one-off period the sync
places, with double days offered when both parents are off and priced
honestly in the hint. Pinning an exact day type is still there, moved
behind Advanced, and editing a pinned day goes straight back to it.

Removing a period offered only the two extremes. There are now three
choices: this day, this year's period, or every year. Taking one year
out of a repeating period narrows it, or splits it in two when the year
is in the middle; where a scope cannot be expressed it is disabled with
a reason rather than doing something surprising.

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 15:14:24 +02:00
parent 11f2a17b43
commit 35e479a005
17 changed files with 1514 additions and 750 deletions
+254 -62
View File
@@ -26,7 +26,7 @@ import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
import { getColorForType } from '@/utils/leaveTypes'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
@@ -101,6 +101,7 @@ function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
wantsDubbeldag: w.wantsDubbeldag,
}
}
@@ -121,6 +122,7 @@ function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
wantsDubbeldag: w.wantsDubbeldag,
}
}
@@ -251,6 +253,14 @@ export function Calendar(): ReactNode {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
// §todo14 — which parent the day popover's "Advanced" (pin exactly) path
// targets. Lifted here (rather than kept as PlannedDayModal-local state)
// so `existingDay`/save/delete all resolve against the SAME parent the
// modal is currently showing — see `getExistingDay`'s doc comment for the
// bug this fixes. Reset to the logged-in parent whenever a fresh
// date/range is selected; updated live via the modal's `onParentChange`
// while it stays open.
const [advancedParentId, setAdvancedParentId] = useState(personalDataUuid ?? '')
const [activeBand, setActiveBand] = useState<BandClickInfo | null>(null)
const [isPeriodOpen, setIsPeriodOpen] = useState(false)
@@ -364,9 +374,12 @@ export function Calendar(): ReactNode {
if (dates.length > 0) {
// §Task7 revision — a drag (single- or multi-day) always opens the
// day popover on the exact dragged dates; it never creates a
// LeaveWish/synced period on its own. The user decides the leave
// type/parent for every one of those days in the popover.
// LeaveWish/synced period on its own by itself — the default path
// now creates a fixed one-off wish and lets the planner fund it
// (§todo10/11); the "Advanced" disclosure still writes exact
// pinned days, same as before.
setSelectedDates(new Set(dates))
setAdvancedParentId(personalDataUuid ?? '')
setIsModalOpen(true)
}
}
@@ -379,7 +392,7 @@ export function Calendar(): ReactNode {
window.addEventListener('pointerup', handlePointerUp)
return () => window.removeEventListener('pointerup', handlePointerUp)
}
}, [isDragging, dragAnchor, dragCurrent])
}, [isDragging, dragAnchor, dragCurrent, personalDataUuid])
if (isLoading) {
return <LoadingSpinner message={t('calendar.loading')} />
@@ -489,21 +502,31 @@ export function Calendar(): ReactNode {
const handleDayClick = (date: string) => {
setSelectedDates(new Set([date]))
setAdvancedParentId(personalDataUuid ?? '')
setIsModalOpen(true)
}
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
// §todo14 fix — takes the parent explicitly rather than assuming the
// logged-in one. The old version always resolved against
// `personalDataUuid`, so saving parent B's day on a date where parent A
// already had one found (and silently overwrote) A's row instead of
// creating B's. Every caller below now passes the parent the save/delete
// is actually FOR — the modal-selected one, never the logged-in default.
const getExistingDay = (date: string, parentId: string): PlannedLeaveDay | undefined => {
return (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === parentId,
)
}
const handleSavePlannedDay = async (payload: {
// The "Advanced" escape hatch — pins an exact day type for an exact
// parent, writing `planned_leave_day` rows directly (today's pre-existing
// behaviour, unchanged except for the §todo14 lookup fix above).
const handleSavePinnedDay = async (payload: {
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
parentId: string
isDubbeldag: boolean
}) => {
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id || !personalDataUuid) return
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
try {
setIsSaving(true)
@@ -512,9 +535,7 @@ export function Calendar(): ReactNode {
setLastLeaveType(payload.dayType)
for (const date of selectedDates) {
const existingDay = (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
)
const existingDay = getExistingDay(date, payload.parentId)
if (existingDay) {
// §Task7 — a hand-edit through the day popover pins the day so a
@@ -546,6 +567,70 @@ export function Calendar(): ReactNode {
}
}
// §todo10/11 default path — the planner decides funding. Builds a fixed,
// one-off `LeaveWish` spanning every selected date and lets the existing
// sync machinery place it, exactly like `handleSelectPreset` below (same
// add-then-sync-then-undo shape).
const handleSaveWish = async (payload: {
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
wantsDubbeldag: boolean
}) => {
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
const orderedDates = Array.from(selectedDates).sort()
const first = orderedDates[0]
const last = orderedDates[orderedDates.length - 1]
if (!first || !last) return
const start = splitISODate(first)
const end = splitISODate(last)
const dayCount = orderedDates.length
try {
setIsSaving(true)
setModalError(null)
const created = await wishesHook.add({
workspaceId: workspace.id,
childId: selectedChildId,
name: t('wishEditor.manualPeriodName'),
nameMessageKey: 'wishEditor.manualPeriodName',
windowStartMonth: start.month,
windowStartDay: start.day,
windowEndMonth: end.month,
windowEndDay: end.day,
targetWeeks: Math.max(1, Math.ceil(dayCount / 7)),
targetDays: dayCount,
recurrence: 'once',
firstYear: start.year,
priority: wishesHook.data.length + 1,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
windowMode: 'fixed',
wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false,
})
setIsModalOpen(false)
setSelectedDates(new Set())
await runSync(workspace.id, {
watchWishId: created.id,
watchWish: created,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.remove(created.id)
await runSync(workspace.id, { silent: true })
},
},
})
} catch (err) {
setModalError(err instanceof Error ? err.message : t('calendar.saveError'))
} finally {
setIsSaving(false)
}
}
const handleDeletePlannedDay = async () => {
if (selectedDates.size !== 1) return
@@ -557,7 +642,7 @@ export function Calendar(): ReactNode {
const date = dates[0]
if (!date) return
const existingDay = getExistingDay(date)
const existingDay = getExistingDay(date, advancedParentId)
if (existingDay) {
await plannedDaysHook.remove(existingDay.id)
}
@@ -619,6 +704,7 @@ export function Calendar(): ReactNode {
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
wantsDubbeldag: payload.wantsDubbeldag,
})
closePeriodPopover()
await runSync(workspace.id, {
@@ -638,61 +724,165 @@ export function Calendar(): ReactNode {
}
}
const handleDeletePeriod = async () => {
// §todo9 — the whole-wish delete (unchanged behaviour), factored out so
// both the "every year" scope and the "narrow down to nothing" edges of
// the "this year" scope (below) can fall back to it.
const deleteWholeWish = async (wishId: string, workspaceId: string) => {
const previousWish = wishesHook.data.find((w) => w.id === wishId)
await wishesHook.remove(wishId)
closePeriodPopover()
await runSync(workspaceId, {
undo: previousWish
? {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.add(createRequestFromWish(previousWish))
await runSync(workspaceId, { silent: true })
},
}
: null,
})
}
// §todo9 — the ad-hoc "this period" delete (unchanged behaviour, and also
// what ad-hoc's "this day" falls back to for a single-day band).
const deleteAdhocRange = async (start: string, end: string, parentUuid: string) => {
const bandDates = new Set(datesInRange(start, end))
const toDelete = (plannedDaysHook.data ?? []).filter(
(pd) => pd.parentPersonalDataUuid === parentUuid && pd.wishId === null && bandDates.has(pd.date),
)
const snapshot = toDelete.map((pd) => ({ ...pd }))
for (const pd of toDelete) {
await plannedDaysHook.remove(pd.id)
}
closePeriodPopover()
if (snapshot.length > 0) {
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
label: t('calendar.undoButton'),
run: async () => {
for (const pd of snapshot) {
await plannedDaysHook.add({
workspace_id: pd.workspaceId,
child_id: pd.childId,
parent_personal_data_uuid: pd.parentPersonalDataUuid,
date: pd.date,
day_type: pd.dayType,
is_dubbeldag: pd.isDubbeldag,
is_pinned: pd.isPinned,
})
}
},
})
}
}
// §todo9 "this period" (this year only) on a recurring wish — the option
// with no existing mechanism. Narrows `firstYear`/`lastYear` to exclude
// just the clicked occurrence's year, or splits the wish in two around a
// middle year, using only fields the wish already has (no backend
// change). Degenerates to the whole-wish delete when narrowing would
// leave no valid year. A `recurrence: 'once'` wish has exactly one
// occurrence, so "this year" and "every year" coincide — handled by the
// same fallback.
const narrowWishByOneYear = async (wish: LeaveWish, clickedYear: number, workspaceId: string) => {
if (wish.recurrence === 'once') {
await deleteWholeWish(wish.id, workspaceId)
return
}
const first = wish.firstYear
const last = wish.lastYear
const finishWithUpdate = async (patch: LeaveWishUpdateRequest) => {
await wishesHook.update(wish.id, patch)
closePeriodPopover()
await runSync(workspaceId, {
watchWishId: wish.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.update(wish.id, patchFromWish(wish))
await runSync(workspaceId, { silent: true })
},
},
})
}
if (clickedYear === first) {
const newFirstYear = first + 1
if (last !== null && newFirstYear > last) {
await deleteWholeWish(wish.id, workspaceId)
return
}
await finishWithUpdate({ firstYear: newFirstYear })
return
}
if (last !== null && clickedYear === last) {
const newLastYear = last - 1
if (newLastYear < first) {
await deleteWholeWish(wish.id, workspaceId)
return
}
await finishWithUpdate({ lastYear: newLastYear })
return
}
// A middle year (strictly between `first` and a defined `last`, or past
// `first` with `last` still open-ended): split into two wishes around
// the excluded year, each keeping the original's other fields.
const created = await wishesHook.add({
...createRequestFromWish(wish),
firstYear: clickedYear + 1,
lastYear: wish.lastYear,
})
await wishesHook.update(wish.id, { lastYear: clickedYear - 1 })
closePeriodPopover()
await runSync(workspaceId, {
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.update(wish.id, patchFromWish(wish))
await wishesHook.remove(created.id)
await runSync(workspaceId, { silent: true })
},
},
})
}
const handleDeletePeriod = async (scope: PeriodDeleteScope) => {
if (!workspace?.id || !activeBand) return
try {
setIsPeriodSaving(true)
setPeriodError(null)
if (scope === 'allYears') {
if (activeBand.wishId) await deleteWholeWish(activeBand.wishId, workspace.id)
else closePeriodPopover() // disabled in the UI for ad-hoc bands
return
}
if (scope === 'day') {
if (activeBand.wishId) {
// Disabled in the UI (would just reappear on the next sync).
closePeriodPopover()
return
}
await deleteAdhocRange(activeBand.startDate, activeBand.startDate, activeBand.parentPersonalDataUuid)
return
}
// scope === 'year'
if (activeBand.wishId) {
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
await wishesHook.remove(activeBand.wishId)
closePeriodPopover()
await runSync(workspace.id, {
undo: previousWish
? {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.add(createRequestFromWish(previousWish))
await runSync(workspace.id, { silent: true })
},
}
: null,
})
if (!previousWish) {
closePeriodPopover()
return
}
await narrowWishByOneYear(previousWish, splitISODate(activeBand.startDate).year, workspace.id)
} else {
// Ad-hoc band (no wishId): delete the underlying planned days
// directly — there is no wish to resync from.
const bandDates = new Set(datesInRange(activeBand.startDate, activeBand.endDate))
const toDelete = (plannedDaysHook.data ?? []).filter(
(pd) =>
pd.parentPersonalDataUuid === activeBand.parentPersonalDataUuid &&
pd.wishId === null &&
bandDates.has(pd.date),
)
const snapshot = toDelete.map((pd) => ({ ...pd }))
for (const pd of toDelete) {
await plannedDaysHook.remove(pd.id)
}
closePeriodPopover()
if (snapshot.length > 0) {
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
label: t('calendar.undoButton'),
run: async () => {
for (const pd of snapshot) {
await plannedDaysHook.add({
workspace_id: pd.workspaceId,
child_id: pd.childId,
parent_personal_data_uuid: pd.parentPersonalDataUuid,
date: pd.date,
day_type: pd.dayType,
is_dubbeldag: pd.isDubbeldag,
is_pinned: pd.isPinned,
})
}
},
})
}
await deleteAdhocRange(activeBand.startDate, activeBand.endDate, activeBand.parentPersonalDataUuid)
}
} catch (err) {
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
@@ -971,11 +1161,13 @@ export function Calendar(): ReactNode {
<PlannedDayModal
date={firstSelectedDate}
isOpen={isModalOpen}
existingDay={getExistingDay(firstSelectedDate) ?? null}
existingDay={getExistingDay(firstSelectedDate, advancedParentId) ?? null}
parents={parentsForModals}
selectedParentId={personalDataUuid ?? ''}
onSave={handleSavePlannedDay}
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
selectedParentId={advancedParentId}
onParentChange={setAdvancedParentId}
onSaveWish={handleSaveWish}
onSavePinned={handleSavePinnedDay}
onDelete={getExistingDay(firstSelectedDate, advancedParentId) ? handleDeletePlannedDay : undefined}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())