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:
+254
-62
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user