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
@@ -1,19 +1,43 @@
import { ReactNode, useState, useEffect } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import './planned-day-modal.css'
/** The default path — todo.md #10/#11: the planner decides which day type
* funds each date; the user only says who it's for (and, when both parents
* are involved, whether dubbeldagar are allowed). Builds a fixed, one-off
* `LeaveWish` spanning the selected dates — see `Calendar.tsx`'s
* `handleSaveWish`. */
export interface PlannedDayModalSaveWishPayload {
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
wantsDubbeldag: boolean
}
/** The escape hatch (today's pre-existing behaviour, kept verbatim) — pins
* an exact day type for an exact parent, writing `planned_leave_day` rows
* directly. */
export interface PlannedDayModalSavePinnedPayload {
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
parentId: string
isDubbeldag: boolean
}
export interface PlannedDayModalProps {
date: string
isOpen: boolean
existingDay?: PlannedLeaveDay | null | undefined
parents: Array<{ id: string; name: string }>
/** The parent the "Advanced" section's pinned edit currently targets.
* Lifted into Calendar.tsx (not local state here) so a save/delete keyed
* on this same value never fights a resolution keyed on the logged-in
* parent instead — see todo.md #14's fix. Changing the advanced parent
* select calls `onParentChange`, which re-resolves `existingDay` for the
* newly selected parent in the caller and flows back down here. */
selectedParentId: string
onSave: (payload: {
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
parentId: string
isDubbeldag: boolean
}) => Promise<void>
onParentChange?: ((parentId: string) => void) | undefined
onSaveWish: (payload: PlannedDayModalSaveWishPayload) => Promise<void>
onSavePinned: (payload: PlannedDayModalSavePinnedPayload) => Promise<void>
onDelete?: (() => Promise<void>) | undefined
onClose: () => void
isSaving?: boolean | undefined
@@ -29,7 +53,9 @@ export function PlannedDayModal({
existingDay,
parents,
selectedParentId,
onSave,
onParentChange,
onSaveWish,
onSavePinned,
onDelete,
onClose,
isSaving = false,
@@ -39,40 +65,79 @@ export function PlannedDayModal({
defaultDayType,
}: PlannedDayModalProps): ReactNode {
const { t, locale } = useTranslation()
const hasMultipleParents = parents.length > 1
// Editing an existing pinned day always reopens the advanced/pinned path —
// it must never be silently reinterpreted as a new planner-funded wish.
const forcedAdvanced = !isRangeMode && !!existingDay
const [isAdvancedOpen, setIsAdvancedOpen] = useState(forcedAdvanced)
const advancedOpen = forcedAdvanced || isAdvancedOpen
// --- Default ("planner decides") path state -----------------------------
const [parentMode, setParentMode] = useState<'either' | 'both' | 'specific'>(
hasMultipleParents ? 'both' : 'either',
)
const [specificParentId, setSpecificParentId] = useState(selectedParentId)
const [wantsDubbeldag, setWantsDubbeldag] = useState(false)
// --- Advanced ("pin exactly") path state ---------------------------------
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
const [parentId, setParentId] = useState(selectedParentId)
const [isDubbeldag, setIsDubbeldag] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
useEffect(() => {
setIsAdvancedOpen(forcedAdvanced)
setParentMode(hasMultipleParents ? 'both' : 'either')
setSpecificParentId(selectedParentId)
setWantsDubbeldag(false)
setLocalError(null)
setIsConfirmingDelete(false)
// Deliberately keyed on `isOpen`/`date` (a fresh "session"), not on
// `selectedParentId` — that prop changes live while the advanced parent
// select is used (see `onParentChange`), and re-running this on every
// such change would stomp the parent-mode selector back to its default.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, date, isRangeMode])
useEffect(() => {
if (existingDay) {
setDayType(existingDay.dayType as 'sjukpenning' | 'lagsta' | 'vacation')
setParentId(existingDay.parentPersonalDataUuid)
setIsDubbeldag(existingDay.isDubbeldag)
} else {
setDayType(defaultDayType ?? 'vacation')
setParentId(selectedParentId)
setIsDubbeldag(false)
}
setLocalError(null)
setIsConfirmingDelete(false)
}, [isOpen, existingDay, selectedParentId, defaultDayType])
// Deliberately NOT a separate "if dayType === 'vacation', clear
// isDubbeldag" effect keyed on `[dayType]` — a second effect racing this
// one on the same mount/update (both scheduled from the same commit,
// each still closing over ITS OWN render's pre-update `dayType`) can
// clobber the `isDubbeldag: true` this effect just set, if the initial
// `dayType` state happened to be 'vacation' the moment this fires. The
// vacation/dubbeldag rule is instead enforced right where the user
// actually changes the type — see the advanced day-type `<select>`'s
// `onChange` below.
}, [isOpen, existingDay, defaultDayType])
useEffect(() => {
if (dayType === 'vacation') {
setIsDubbeldag(false)
if (parentMode !== 'both') {
setWantsDubbeldag(false)
}
}, [dayType])
}, [parentMode])
const handleSave = async () => {
try {
setLocalError(null)
await onSave({
dayType,
parentId,
isDubbeldag,
})
if (advancedOpen) {
await onSavePinned({ dayType, parentId: selectedParentId, isDubbeldag })
} else {
await onSaveWish({
parentMode,
parentPersonalDataUuid: parentMode === 'specific' ? specificParentId : null,
wantsDubbeldag,
})
}
} catch (err) {
setLocalError(err instanceof Error ? err.message : t('plannedDayModal.saveError'))
}
@@ -99,6 +164,19 @@ export function PlannedDayModal({
}
}
const handleParentModeChange = (value: string) => {
if (value === 'either' || value === 'both') {
setParentMode(value)
} else {
setParentMode('specific')
setSpecificParentId(value)
}
}
const handleAdvancedParentChange = (parentId: string) => {
onParentChange?.(parentId)
}
if (!isOpen) return null
const displayDate = new Date(date + 'T00:00:00Z').toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
@@ -114,6 +192,8 @@ export function PlannedDayModal({
? t('plannedDayModal.editTitle')
: t('plannedDayModal.newTitle')
const parentModeSelectValue = parentMode === 'specific' ? specificParentId : parentMode
return (
<div className="modal__overlay" onClick={onClose} data-testid="planned-day-modal__overlay" role="dialog">
<div
@@ -138,37 +218,21 @@ export function PlannedDayModal({
)}
<div className="stack">
<div className="field">
<label htmlFor="day-type" className="field__label">
{t('plannedDayModal.leaveTypeLabel')}
</label>
<select
id="day-type"
value={dayType}
onChange={(e) => setDayType(e.target.value as 'sjukpenning' | 'lagsta' | 'vacation')}
className="field__control"
disabled={isSaving}
data-testid="day-type-select"
>
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
</select>
</div>
{parents.length > 1 && (
{!forcedAdvanced && hasMultipleParents && (
<div className="field">
<label htmlFor="parent-select" className="field__label">
{t('plannedDayModal.parentLabel')}
<label htmlFor="parent-mode-select" className="field__label">
{t('plannedDayModal.parentModeLabel')}
</label>
<select
id="parent-select"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
id="parent-mode-select"
value={parentModeSelectValue}
onChange={(e) => handleParentModeChange(e.target.value)}
className="field__control"
disabled={isSaving}
data-testid="parent-select"
data-testid="parent-mode-select"
>
<option value="either">{t('wishEditor.eitherParent')}</option>
<option value="both">{t('wishEditor.bothParents')}</option>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -178,28 +242,109 @@ export function PlannedDayModal({
</div>
)}
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
{!forcedAdvanced && hasMultipleParents && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={wantsDubbeldag}
onChange={(e) => setWantsDubbeldag(e.target.checked)}
disabled={isSaving || parentMode !== 'both'}
data-testid="wants-dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.wantsDubbeldagLabel')}
</label>
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
{parentMode === 'both'
? t('plannedDayModal.wantsDubbeldagHint')
: t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
</p>
)}
</div>
</div>
)}
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
📌 {t('plannedDayModal.pinnedNotice')}
</p>
{!forcedAdvanced && (
<button
type="button"
className="btn btn--ghost planned-day-modal__advanced-toggle"
onClick={() => setIsAdvancedOpen((open) => !open)}
disabled={isSaving}
data-testid="advanced-toggle"
aria-expanded={isAdvancedOpen}
>
{isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
</button>
)}
{advancedOpen && (
<div className="planned-day-modal__advanced" data-testid="advanced-section">
<div className="field">
<label htmlFor="day-type" className="field__label">
{t('plannedDayModal.leaveTypeLabel')}
</label>
<select
id="day-type"
value={dayType}
onChange={(e) => {
const value = e.target.value as 'sjukpenning' | 'lagsta' | 'vacation'
setDayType(value)
if (value === 'vacation') setIsDubbeldag(false)
}}
className="field__control"
disabled={isSaving}
data-testid="day-type-select"
>
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
</select>
</div>
{hasMultipleParents && (
<div className="field">
<label htmlFor="parent-select" className="field__label">
{t('plannedDayModal.parentLabel')}
</label>
<select
id="parent-select"
value={selectedParentId}
onChange={(e) => handleAdvancedParentChange(e.target.value)}
className="field__control"
disabled={isSaving}
data-testid="parent-select"
>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
📌 {t('plannedDayModal.pinnedNotice')}
</p>
)}
</div>
)}
</div>
</div>
@@ -255,7 +400,7 @@ export function PlannedDayModal({
<button
type="button"
className="btn btn--primary"
onClick={handleSave}
onClick={() => void handleSave()}
disabled={isSaving}
data-testid="save-button"
>