Add a custom bounding range for flexible wishes, and auto-save edits

Flexible wishes could only be bounded by one of four season presets, a
deliberate v1 scope cut. They can now take any month/day range, including
one that wraps the year end the way the jul preset does. The range stays
a search bound -- the engine still chooses the placement inside it at
propose time.

February is capped at 28 days rather than 29: the backend builds a
concrete date(year, month, day) per anchor year, so a Feb 29 bound would
raise in a non-leap year.

Per-row Save buttons are replaced by a debounced auto-save (800ms), which
is what prompted this: clicking save on each individual field was the
reported annoyance. A draft that has not actually changed never saves, so
a refetch cannot trigger a further save -- the save/refetch/save loop is
guarded explicitly and has a regression test. An invalid draft (bounding
range shorter than the wish) holds the edit locally and shows why rather
than saving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 18:54:43 +02:00
parent 8e14ac6576
commit f685b739f8
3 changed files with 572 additions and 90 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { WishEditor } from './WishEditor'
import type { LeaveWish, PersonalData } from '@/types'
@@ -34,7 +34,7 @@ function setup(overrides: Partial<Parameters<typeof WishEditor>[0]> = {}) {
const onAdd = vi.fn()
const onUpdate = vi.fn()
const onRemove = vi.fn()
render(
const utils = render(
<WishEditor
workspaceId="ws1"
wishes={[]}
@@ -45,10 +45,42 @@ function setup(overrides: Partial<Parameters<typeof WishEditor>[0]> = {}) {
{...overrides}
/>,
)
return { onAdd, onUpdate, onRemove }
const rerender = (nextOverrides: Partial<Parameters<typeof WishEditor>[0]> = {}) =>
utils.rerender(
<WishEditor
workspaceId="ws1"
wishes={[]}
members={mockMembers}
onAdd={onAdd}
onUpdate={onUpdate}
onRemove={onRemove}
{...overrides}
{...nextOverrides}
/>,
)
return { onAdd, onUpdate, onRemove, rerender, unmount: utils.unmount }
}
const AUTOSAVE_MS = 800
function advanceAutosave(ms = AUTOSAVE_MS) {
act(() => {
vi.advanceTimersByTime(ms)
})
}
describe('WishEditor', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
act(() => {
vi.runOnlyPendingTimers()
})
vi.useRealTimers()
})
it('shows the empty state when there are no wishes yet', () => {
setup()
expect(screen.getByTestId('wish-empty')).toBeInTheDocument()
@@ -113,23 +145,26 @@ describe('WishEditor', () => {
expect(screen.getByTestId('wish-weeks-lw1')).toHaveValue(6)
})
it('saves an edited week count', () => {
it('auto-saves an edited week count after the debounce delay', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } })
fireEvent.click(screen.getByTestId('wish-save-lw1'))
expect(onUpdate).not.toHaveBeenCalled()
advanceAutosave()
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith(
'lw1',
expect.objectContaining({ targetWeeks: 8 }),
)
})
it('saves a specific parent selection', () => {
it('auto-saves a specific parent selection', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
fireEvent.change(screen.getByTestId('wish-parent-lw1'), { target: { value: 'pd2' } })
fireEvent.click(screen.getByTestId('wish-save-lw1'))
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith(
'lw1',
@@ -152,20 +187,20 @@ describe('WishEditor', () => {
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
expect(screen.getByTestId('wish-dates-lw1')).toBeInTheDocument()
expect(screen.getByTestId('wish-start-day-lw1')).toHaveValue(15)
expect(screen.getByTestId('wish-start-day-lw1')).toHaveValue('15')
})
it('saves an adjusted end date', () => {
it('auto-saves an adjusted end date', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
fireEvent.change(screen.getByTestId('wish-end-day-lw1'), { target: { value: '20' } })
fireEvent.click(screen.getByTestId('wish-save-lw1'))
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ windowEndDay: 20 }))
})
it('clearing "Tills barnet fyller 4 år" reveals a last-year input and saves it', () => {
it('clearing "Tills barnet fyller 4 år" reveals a last-year input and auto-saves it', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
@@ -173,16 +208,16 @@ describe('WishEditor', () => {
expect(screen.getByTestId('wish-last-year-lw1')).toBeInTheDocument()
fireEvent.change(screen.getByTestId('wish-last-year-lw1'), { target: { value: '2029' } })
fireEvent.click(screen.getByTestId('wish-save-lw1'))
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ lastYear: 2029 }))
})
it('disables actions while isSaving is true', () => {
it('disables preset and remove actions while isSaving is true', () => {
setup({ wishes: [mockWish], isSaving: true })
expect(screen.getByTestId('wish-save-lw1')).toBeDisabled()
expect(screen.getByTestId('wish-remove-lw1')).toBeDisabled()
expect(screen.getByTestId('wish-preset-sommar')).toBeDisabled()
expect(screen.getByTestId('wish-preset-custom-flex')).toBeDisabled()
})
describe('fixed vs "när det passar bäst" mode', () => {
@@ -201,13 +236,13 @@ describe('WishEditor', () => {
expect(fixedRadio).toHaveAttribute('name', flexibleRadio.getAttribute('name'))
})
it('switching to flexible shows the plain-language explainer and saves windowMode', () => {
it('switching to flexible shows the plain-language explainer and auto-saves windowMode', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
fireEvent.click(screen.getByTestId('wish-mode-flexible-lw1'))
expect(screen.getByTestId('wish-mode-explainer-lw1')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('wish-save-lw1'))
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith(
'lw1',
expect.objectContaining({ windowMode: 'flexible' }),
@@ -220,21 +255,22 @@ describe('WishEditor', () => {
expect(screen.getByTestId('wish-toggle-dates-lw1')).toHaveTextContent(/sökintervall/i)
})
it('saves windowMode fixed by default when an unrelated field is edited', () => {
it('auto-saves windowMode fixed by default when an unrelated field is edited', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '3' } })
fireEvent.click(screen.getByTestId('wish-save-lw1'))
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ windowMode: 'fixed' }))
})
})
describe('season quick-picks (flexible wishes)', () => {
it('renders one flexible preset button per season', () => {
it('renders one flexible preset button per season plus a custom bound option', () => {
setup()
expect(screen.getByTestId('wish-preset-season-var')).toBeInTheDocument()
expect(screen.getByTestId('wish-preset-season-sommar')).toBeInTheDocument()
expect(screen.getByTestId('wish-preset-season-host')).toBeInTheDocument()
expect(screen.getByTestId('wish-preset-season-jul')).toBeInTheDocument()
expect(screen.getByTestId('wish-preset-custom-flex')).toBeInTheDocument()
})
it('adds a flexible "vår" wish with the season as the bounding range', () => {
@@ -297,10 +333,53 @@ describe('WishEditor', () => {
}),
)
})
it('adds a fully custom flexible wish via the "Eget sökintervall" button', () => {
const { onAdd } = setup()
fireEvent.click(screen.getByTestId('wish-preset-custom-flex'))
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'ws1',
windowMode: 'flexible',
}),
)
})
it('lets a custom flexible wish be re-bounded to any arbitrary, non-preset month/day range', () => {
const customFlexWish: LeaveWish = {
...mockWish,
id: 'lw2',
windowMode: 'flexible',
targetWeeks: 1,
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
windowEndDay: 14,
}
const { onUpdate } = setup({ wishes: [customFlexWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw2'))
fireEvent.change(screen.getByTestId('wish-start-month-lw2'), { target: { value: '4' } })
fireEvent.change(screen.getByTestId('wish-start-day-lw2'), { target: { value: '9' } })
fireEvent.change(screen.getByTestId('wish-end-month-lw2'), { target: { value: '10' } })
fireEvent.change(screen.getByTestId('wish-end-day-lw2'), { target: { value: '23' } })
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith(
'lw2',
expect.objectContaining({
windowStartMonth: 4,
windowStartDay: 9,
windowEndMonth: 10,
windowEndDay: 23,
}),
)
})
})
describe('flexible bound validation', () => {
it('shows an inline error and blocks save when the bound is shorter than target_weeks * 7', () => {
it('shows an inline error and never fires a save while the bound is shorter than target_weeks * 7', () => {
const shortBoundWish: LeaveWish = {
...mockWish,
windowMode: 'flexible',
@@ -313,10 +392,14 @@ describe('WishEditor', () => {
const { onUpdate } = setup({ wishes: [shortBoundWish] })
expect(screen.getByTestId('wish-bound-error-lw1')).toBeInTheDocument()
expect(screen.getByTestId('wish-save-lw1')).toBeDisabled()
fireEvent.click(screen.getByTestId('wish-save-lw1'))
// Touch an unrelated field — the invalid bound must still block the save.
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '6' } })
advanceAutosave(5000)
expect(onUpdate).not.toHaveBeenCalled()
expect(screen.queryByTestId('wish-save-status-lw1')).not.toBeInTheDocument()
expect(screen.getByTestId('wish-bound-error-lw1')).toBeInTheDocument()
})
it('does not show a bound error for a fixed wish regardless of window length', () => {
@@ -331,7 +414,162 @@ describe('WishEditor', () => {
}
setup({ wishes: [shortFixedWish] })
expect(screen.queryByTestId('wish-bound-error-lw1')).not.toBeInTheDocument()
expect(screen.getByTestId('wish-save-lw1')).not.toBeDisabled()
})
})
describe('day-of-month options respect the selected month', () => {
it('offers only 28 days for February (no 31 February)', () => {
setup({ wishes: [mockWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
fireEvent.change(screen.getByTestId('wish-start-month-lw1'), { target: { value: '2' } })
const daySelect = screen.getByTestId('wish-start-day-lw1') as HTMLSelectElement
const values = Array.from(daySelect.querySelectorAll('option')).map((opt) => opt.value)
expect(values).toEqual(Array.from({ length: 28 }, (_, i) => String(i + 1)))
})
it('offers only 30 days for April', () => {
setup({ wishes: [mockWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
fireEvent.change(screen.getByTestId('wish-end-month-lw1'), { target: { value: '4' } })
const daySelect = screen.getByTestId('wish-end-day-lw1') as HTMLSelectElement
const values = Array.from(daySelect.querySelectorAll('option')).map((opt) => opt.value)
expect(values).toEqual(Array.from({ length: 30 }, (_, i) => String(i + 1)))
})
it('clamps a selected day down when switching to a shorter month', () => {
const janWish: LeaveWish = { ...mockWish, windowStartMonth: 1, windowStartDay: 31 }
setup({ wishes: [janWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
expect(screen.getByTestId('wish-start-day-lw1')).toHaveValue('31')
fireEvent.change(screen.getByTestId('wish-start-month-lw1'), { target: { value: '2' } })
expect(screen.getByTestId('wish-start-day-lw1')).toHaveValue('28')
})
})
describe('wrapping bounding ranges', () => {
it('shows a wrap note for a wrapping custom range, like season-jul', () => {
const wrappingWish: LeaveWish = {
...mockWish,
windowMode: 'flexible',
targetWeeks: 1,
windowStartMonth: 11,
windowStartDay: 10,
windowEndMonth: 2,
windowEndDay: 20,
}
setup({ wishes: [wrappingWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
expect(screen.getByTestId('wish-wrap-note-lw1')).toBeInTheDocument()
})
it('does not show a wrap note for a non-wrapping range', () => {
setup({ wishes: [mockWish] })
fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1'))
expect(screen.queryByTestId('wish-wrap-note-lw1')).not.toBeInTheDocument()
})
})
describe('debounced auto-save', () => {
it('collapses rapid successive edits into a single save', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '7' } })
advanceAutosave(400)
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } })
advanceAutosave(400)
expect(onUpdate).not.toHaveBeenCalled()
advanceAutosave(400)
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ targetWeeks: 8 }))
})
it('does not save on mount, before any edit', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
advanceAutosave(5000)
expect(onUpdate).not.toHaveBeenCalled()
})
it('does not save when a field is focused and blurred without changing its value', () => {
const { onUpdate } = setup({ wishes: [mockWish] })
const input = screen.getByTestId('wish-weeks-lw1')
fireEvent.focus(input)
fireEvent.blur(input)
advanceAutosave(5000)
expect(onUpdate).not.toHaveBeenCalled()
})
it('does not enter a save -> refetch -> save loop when the wishes prop refreshes with the saved value', () => {
const { onUpdate, rerender } = setup({ wishes: [mockWish] })
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } })
advanceAutosave()
expect(onUpdate).toHaveBeenCalledTimes(1)
// Simulate the parent's refetch after a successful save landing with
// a brand-new wishes array reflecting the saved value.
rerender({ wishes: [{ ...mockWish, targetWeeks: 8 }] })
advanceAutosave(5000)
expect(onUpdate).toHaveBeenCalledTimes(1)
})
it('does not re-save when the wishes prop refreshes without any real change (identity-only refresh)', () => {
const { onUpdate, rerender } = setup({ wishes: [mockWish] })
rerender({ wishes: [{ ...mockWish }] })
advanceAutosave(5000)
expect(onUpdate).not.toHaveBeenCalled()
})
it('shows "Sparar…" while a save is in flight and "Sparat" once the wishes prop confirms it landed', () => {
const { rerender } = setup({ wishes: [mockWish], isSaving: false })
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '9' } })
advanceAutosave()
expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Sparar')
rerender({ wishes: [{ ...mockWish, targetWeeks: 9 }], isSaving: false })
expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Sparat')
advanceAutosave(2000)
expect(screen.queryByTestId('wish-save-status-lw1')).not.toBeInTheDocument()
})
it('shows a failure status and keeps the edit when the parent finishes saving without the change landing', () => {
const { onUpdate, rerender } = setup({ wishes: [mockWish], isSaving: false })
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '9' } })
advanceAutosave()
expect(onUpdate).toHaveBeenCalledTimes(1)
// Parent starts, then finishes the (failed) request; wishes never
// change because the underlying update rejected.
rerender({ wishes: [mockWish], isSaving: true })
rerender({ wishes: [mockWish], isSaving: false })
expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent(
'Kunde inte spara',
)
// The edit itself must not be discarded from the input.
expect(screen.getByTestId('wish-weeks-lw1')).toHaveValue(9)
})
it('cleans up a pending debounce timer on unmount so it never fires', () => {
const { onUpdate, unmount } = setup({ wishes: [mockWish] })
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '11' } })
unmount()
advanceAutosave(5000)
expect(onUpdate).not.toHaveBeenCalled()
})
})
})
+286 -66
View File
@@ -13,10 +13,21 @@
* "när det passar bäst" (flexible) toggle. A flexible wish reuses the exact
* same window-start/window-end fields as a BOUNDING RANGE the engine
* searches inside for the best-costing placement, rather than as the final
* dates — v1 offers four season quick-picks (vår/sommar/höst/jul) to fill
* that range with one click; no custom bounding-range picker exists yet.
* dates. v1 offered four season quick-picks (vår/sommar/höst/jul) plus, now,
* a fully custom bounding range: any two month/day pairs, any span,
* including a wrapping range (end sorts before start, e.g. Nov -> Feb) — the
* "Eget sökintervall" button seeds one, immediately editable via "Justera
* sökintervall" like any other flexible wish.
*
* Edits auto-save per row (800ms debounce, collapsing rapid edits into one
* call) instead of requiring an explicit Save click — see `scheduleSave`/
* `performSave` below. There is deliberately no promise/return value coming
* back from `onUpdate` (see `WishEditorProps`), so success/failure of a
* given row's save is *inferred*: a match between what was sent and the
* freshly-arrived `wishes` prop means success; the parent's `isSaving` flag
* dropping back to false without that match means the save failed.
*/
import { ReactNode, useEffect, useState } from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import type {
LeaveWish,
LeaveWishCreateRequest,
@@ -37,6 +48,11 @@ export interface WishEditorProps {
type WindowMode = 'fixed' | 'flexible'
/** Debounce delay for per-row auto-save (§ "Debounced auto-save"). */
const AUTOSAVE_DEBOUNCE_MS = 800
/** How long the "Sparat" confirmation stays visible after a successful save. */
const SAVED_STATUS_TTL_MS = 2000
interface Preset {
key: string
labelKey: TranslationKey
@@ -55,13 +71,15 @@ const PRESETS: Preset[] = [
{ key: 'hostlov', labelKey: 'wishEditor.presetHostlov', startMonth: 10, startDay: 25, endMonth: 11, endDay: 1, defaultWeeks: 1 },
]
// PLANNING_ENGINE_SPEC.md §10.7 — v1 ships season presets ONLY for flexible
// ("när det passar bäst") wishes; no custom bounding-range picker. Picking one
// of these fills the SAME window_start_*/window_end_* fields used by fixed
// wishes, but as the bounding range the engine searches inside (§10.1) — not
// the final placement. "jul" deliberately wraps the year end (Dec -> Jan),
// exactly like the existing Jullov fixed preset above; window_wraps()/
// window_for_year() on the backend handle that without any special-casing.
// PLANNING_ENGINE_SPEC.md §10.7 — v1 ships season presets for flexible
// ("när det passar bäst") wishes, plus a fully custom bounding range (see
// `addCustomFlex` below). Picking one of these fills the SAME window_start_*/
// window_end_* fields used by fixed wishes, but as the bounding range the
// engine searches inside (§10.1) — not the final placement. "jul"
// deliberately wraps the year end (Dec -> Jan), exactly like the existing
// Jullov fixed preset above; window_wraps()/window_for_year() on the backend
// handle that without any special-casing, and so does a custom wrapping
// range entered by hand.
const SEASON_PRESETS: Preset[] = [
{ key: 'season-var', labelKey: 'wishEditor.seasonVar', startMonth: 3, startDay: 1, endMonth: 5, endDay: 31, defaultWeeks: 1 },
{ key: 'season-sommar', labelKey: 'wishEditor.seasonSommar', startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, defaultWeeks: 6 },
@@ -93,6 +111,20 @@ function windowLengthDays(
return Math.round((end - start) / 86_400_000) + 1
}
// Calendar day counts per 1-12 month. February is deliberately capped at 28
// (never 29): `LeaveWish.window_for_year()` builds a concrete
// `date(year, month, day)` per anchor year on the backend, which raises for
// Feb 29 in any non-leap anchor year — a recurring wish must stay valid
// every year it applies to, so Feb 29 is never offered as an option.
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
/** How many day-of-month options a 1-12 month has, for populating the day
* `<select>` below — keeps "31 February" (or "31 April") from ever being
* selectable. */
function daysInMonth(month: number): number {
return DAYS_IN_MONTH[month - 1] ?? 31
}
const MONTH_KEYS: TranslationKey[] = [
'wishEditor.month1', 'wishEditor.month2', 'wishEditor.month3', 'wishEditor.month4',
'wishEditor.month5', 'wishEditor.month6', 'wishEditor.month7', 'wishEditor.month8',
@@ -133,6 +165,67 @@ function draftFromWish(wish: LeaveWish): Draft {
}
}
/** §10.1/§10.7 — mirrors the service-level validation client-side: a
* flexible wish's bounding range must be at least `targetWeeks * 7` days,
* before any horizon clipping. Not applicable to fixed wishes. Pure (no
* component state), so it is safe to call from anywhere, including inside
* effects, without becoming a lint dependency. */
function isBoundTooShort(draft: Draft): boolean {
if (draft.windowMode !== 'flexible') return false
const length = windowLengthDays(
draft.windowStartMonth,
draft.windowStartDay,
draft.windowEndMonth,
draft.windowEndDay,
draft.firstYear,
)
return length < draft.targetWeeks * 7
}
/** Whether `draft` differs from the persisted `wish` in any field auto-save
* would send — the dirty check that stops a reseeded-but-untouched draft
* (mount, or a parent refetch after another row's save) from ever
* scheduling a save. */
function draftEqualsWish(draft: Draft, wish: LeaveWish): boolean {
return (
draft.name === wish.name &&
draft.targetWeeks === wish.targetWeeks &&
draft.priority === wish.priority &&
draft.parentMode === wish.parentMode &&
(draft.parentMode === 'specific' ? draft.parentPersonalDataUuid : null) === wish.parentPersonalDataUuid &&
draft.windowStartMonth === wish.windowStartMonth &&
draft.windowStartDay === wish.windowStartDay &&
draft.windowEndMonth === wish.windowEndMonth &&
draft.windowEndDay === wish.windowEndDay &&
draft.recurrence === wish.recurrence &&
draft.firstYear === wish.firstYear &&
(draft.recurrence === 'yearly' ? draft.lastYear : null) === wish.lastYear &&
draft.windowMode === wish.windowMode
)
}
/** The exact patch `onUpdate` receives for a draft — same shape `saveDraft`
* used to build before auto-save replaced it. */
function buildPatch(draft: Draft): LeaveWishUpdateRequest {
return {
name: draft.name,
targetWeeks: draft.targetWeeks,
priority: draft.priority,
parentMode: draft.parentMode,
parentPersonalDataUuid: draft.parentMode === 'specific' ? draft.parentPersonalDataUuid : null,
windowStartMonth: draft.windowStartMonth,
windowStartDay: draft.windowStartDay,
windowEndMonth: draft.windowEndMonth,
windowEndDay: draft.windowEndDay,
recurrence: draft.recurrence,
firstYear: draft.firstYear,
lastYear: draft.recurrence === 'yearly' ? draft.lastYear : null,
windowMode: draft.windowMode,
}
}
type RowSaveStatus = 'saving' | 'saved' | 'error'
export function WishEditor({
workspaceId,
wishes,
@@ -146,6 +239,25 @@ export function WishEditor({
const MONTH_NAMES = MONTH_KEYS.map((key) => t(key))
const [drafts, setDrafts] = useState<Record<string, Draft>>({})
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [rowStatus, setRowStatus] = useState<Record<string, RowSaveStatus>>({})
// Mirrors `drafts` for read access from the debounce timer's callback,
// which fires outside React's render cycle and would otherwise close over
// whatever `drafts` looked like at the moment the timer was scheduled.
const draftsRef = useRef<Record<string, Draft>>({})
useEffect(() => {
draftsRef.current = drafts
}, [drafts])
// Per-row debounce timers (auto-save) and per-row "Sparat" auto-clear timers.
const timersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const savedTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// The exact draft snapshot sent to `onUpdate` for a row still awaiting a
// result, keyed by wish id — cleared once that row resolves (success or
// failure). Used to detect success by comparing against the next `wishes`
// the parent hands down.
const sentDraftRef = useRef<Record<string, Draft>>({})
const prevIsSavingRef = useRef(isSaving)
useEffect(() => {
setDrafts((prev) => {
@@ -157,6 +269,58 @@ export function WishEditor({
})
}, [wishes])
// Resolves in-flight auto-saves: a row whose sent draft now matches the
// freshly-arrived `wishes` succeeded; a row still unresolved when the
// parent's `isSaving` flag drops back to false failed. Checked in that
// order so a same-tick success always wins over a stale "just finished"
// read of `isSaving`. This is the guard against a save -> refetch -> save
// loop: it only ever *reads* `wishes`/`isSaving` to update `rowStatus` —
// it never touches `drafts`, so a parent refetch can never trigger another
// scheduleSave call.
useEffect(() => {
const wasSaving = prevIsSavingRef.current
prevIsSavingRef.current = isSaving
for (const id of Object.keys(sentDraftRef.current)) {
const sentDraft = sentDraftRef.current[id]
const wish = wishes.find((w) => w.id === id)
const succeeded = wish !== undefined && sentDraft !== undefined && draftEqualsWish(sentDraft, wish)
if (succeeded) {
delete sentDraftRef.current[id]
setRowStatus((prev) => ({ ...prev, [id]: 'saved' }))
const existingSavedTimer = savedTimersRef.current[id]
if (existingSavedTimer) clearTimeout(existingSavedTimer)
savedTimersRef.current[id] = setTimeout(() => {
delete savedTimersRef.current[id]
setRowStatus((prev) => {
if (prev[id] !== 'saved') return prev
const next = { ...prev }
delete next[id]
return next
})
}, SAVED_STATUS_TTL_MS)
} else if (wasSaving && !isSaving) {
delete sentDraftRef.current[id]
setRowStatus((prev) => ({ ...prev, [id]: 'error' }))
}
}
}, [wishes, isSaving])
// Clean up every outstanding timer on unmount so a debounced save (or a
// "Sparat" auto-clear) never fires against an unmounted component. Both
// registries are mutated in place (never reassigned), so capturing the
// object reference here still sees every timer added later, right up to
// unmount.
useEffect(() => {
const timers = timersRef.current
const savedTimers = savedTimersRef.current
return () => {
Object.values(timers).forEach(clearTimeout)
Object.values(savedTimers).forEach(clearTimeout)
}
}, [])
const currentYear = new Date().getFullYear()
const addPreset = (preset: Preset) => {
@@ -213,54 +377,73 @@ export function WishEditor({
})
}
// The custom counterpart to the four season quick-picks: a flexible wish
// with an arbitrary starter bound, immediately editable to any month/day
// pair (including a wrapping range) via "Justera sökintervall".
const addCustomFlex = () => {
onAdd({
workspaceId,
name: t('wishEditor.customFlexName'),
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
windowEndDay: 14,
targetWeeks: 1,
recurrence: 'yearly',
firstYear: currentYear,
priority: wishes.length + 1,
parentMode: 'either',
windowMode: 'flexible',
})
}
/** Fires (at most) `AUTOSAVE_DEBOUNCE_MS` after the last edit to this row.
* Re-validates against the latest draft/wish at fire time rather than
* trusting whatever was true when the timer was scheduled. */
const performSave = (id: string) => {
const wish = wishes.find((w) => w.id === id)
const draft = draftsRef.current[id]
if (!wish || !draft) return
if (isBoundTooShort(draft) || draftEqualsWish(draft, wish)) return
sentDraftRef.current[id] = draft
setRowStatus((prev) => ({ ...prev, [id]: 'saving' }))
onUpdate(id, buildPatch(draft))
}
/** Debounces `performSave` for one row; rapid successive edits keep
* pushing the timer out rather than queueing multiple saves. */
const scheduleSave = (id: string) => {
const existing = timersRef.current[id]
if (existing) clearTimeout(existing)
timersRef.current[id] = setTimeout(() => {
delete timersRef.current[id]
performSave(id)
}, AUTOSAVE_DEBOUNCE_MS)
}
const updateDraft = (id: string, patch: Partial<Draft>) => {
setDrafts((prev) => {
const current = prev[id]
if (!current) return prev
return { ...prev, [id]: { ...current, ...patch } }
const merged = { ...current, ...patch }
// Clamp the day back into range if the month changed underneath it
// (e.g. Jan 31 -> Feb keeps a selectable day instead of an orphaned one).
if ('windowStartMonth' in patch) {
merged.windowStartDay = Math.min(merged.windowStartDay, daysInMonth(merged.windowStartMonth))
}
if ('windowEndMonth' in patch) {
merged.windowEndDay = Math.min(merged.windowEndDay, daysInMonth(merged.windowEndMonth))
}
return { ...prev, [id]: merged }
})
scheduleSave(id)
}
const toggleExpanded = (id: string) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
}
/** §10.1/§10.7 — mirrors the service-level validation client-side: a
* flexible wish's bounding range must be at least `targetWeeks * 7` days,
* before any horizon clipping. Not applicable to fixed wishes. */
const isBoundTooShort = (draft: Draft): boolean => {
if (draft.windowMode !== 'flexible') return false
const length = windowLengthDays(
draft.windowStartMonth,
draft.windowStartDay,
draft.windowEndMonth,
draft.windowEndDay,
draft.firstYear,
)
return length < draft.targetWeeks * 7
}
const saveDraft = (id: string) => {
const draft = drafts[id]
if (!draft) return
if (isBoundTooShort(draft)) return
onUpdate(id, {
name: draft.name,
targetWeeks: draft.targetWeeks,
priority: draft.priority,
parentMode: draft.parentMode,
parentPersonalDataUuid: draft.parentMode === 'specific' ? draft.parentPersonalDataUuid : null,
windowStartMonth: draft.windowStartMonth,
windowStartDay: draft.windowStartDay,
windowEndMonth: draft.windowEndMonth,
windowEndDay: draft.windowEndDay,
recurrence: draft.recurrence,
firstYear: draft.firstYear,
lastYear: draft.recurrence === 'yearly' ? draft.lastYear : null,
windowMode: draft.windowMode,
})
}
return (
<div className="wish-editor" data-testid="wish-editor">
<div className="wish-editor__presets">
@@ -305,6 +488,15 @@ export function WishEditor({
{t('wishEditor.flexBadge')})
</button>
))}
<button
type="button"
className="wish-editor__preset-button wish-editor__preset-button--flexible wish-editor__preset-button--custom"
onClick={addCustomFlex}
disabled={isSaving}
data-testid="wish-preset-custom-flex"
>
{t('wishEditor.customFlexButton')}
</button>
</div>
<p className="wish-editor__flex-explainer">{t('wishEditor.modeFlexibleExplainer')}</p>
</div>
@@ -320,6 +512,14 @@ export function WishEditor({
const isExpanded = expanded[wish.id] ?? false
const parentSelectValue =
draft.parentMode === 'specific' ? draft.parentPersonalDataUuid ?? '' : draft.parentMode
const boundTooShort = isBoundTooShort(draft)
const wraps = windowWraps(
draft.windowStartMonth,
draft.windowStartDay,
draft.windowEndMonth,
draft.windowEndDay,
)
const status = rowStatus[wish.id]
return (
<li key={wish.id} className="wish-editor__card" data-testid={`wish-card-${wish.id}`}>
@@ -452,16 +652,21 @@ export function WishEditor({
</option>
))}
</select>
<input
type="number"
min={1}
max={31}
<select
value={draft.windowStartDay}
onChange={(e) =>
updateDraft(wish.id, { windowStartDay: Number(e.target.value) })
}
data-testid={`wish-start-day-${wish.id}`}
/>
>
{Array.from({ length: daysInMonth(draft.windowStartMonth) }, (_, i) => i + 1).map(
(day) => (
<option key={day} value={day}>
{day}
</option>
),
)}
</select>
</span>
</label>
@@ -485,19 +690,30 @@ export function WishEditor({
</option>
))}
</select>
<input
type="number"
min={1}
max={31}
<select
value={draft.windowEndDay}
onChange={(e) =>
updateDraft(wish.id, { windowEndDay: Number(e.target.value) })
}
data-testid={`wish-end-day-${wish.id}`}
/>
>
{Array.from({ length: daysInMonth(draft.windowEndMonth) }, (_, i) => i + 1).map(
(day) => (
<option key={day} value={day}>
{day}
</option>
),
)}
</select>
</span>
</label>
{wraps && (
<p className="wish-editor__wrap-note" data-testid={`wish-wrap-note-${wish.id}`}>
{t('wishEditor.wrapsNote')}
</p>
)}
<label className="wish-editor__checkbox-label">
<input
type="checkbox"
@@ -558,7 +774,7 @@ export function WishEditor({
</div>
)}
{isBoundTooShort(draft) && (
{boundTooShort && (
<p
className="wish-editor__bound-error"
data-testid={`wish-bound-error-${wish.id}`}
@@ -568,14 +784,18 @@ export function WishEditor({
)}
<div className="wish-editor__actions">
<button
type="button"
onClick={() => saveDraft(wish.id)}
disabled={isSaving || isBoundTooShort(draft)}
data-testid={`wish-save-${wish.id}`}
>
{isSaving ? t('wishEditor.savingButton') : t('wishEditor.saveButton')}
</button>
{status && (
<span
className={`wish-editor__save-status wish-editor__save-status--${status}`}
data-testid={`wish-save-status-${wish.id}`}
role={status === 'error' ? 'alert' : undefined}
aria-live="polite"
>
{status === 'saving' && t('common.saving')}
{status === 'saved' && t('wishEditor.savedStatus')}
{status === 'error' && t('wishEditor.saveFailedStatus')}
</span>
)}
<button
type="button"
className="ghost"
+24
View File
@@ -210,9 +210,33 @@
.wish-editor__actions {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.wish-editor__wrap-note {
flex-basis: 100%;
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin: 0;
}
.wish-editor__save-status {
font-size: var(--fs-sm);
}
.wish-editor__save-status--saving {
color: var(--c-text-muted);
}
.wish-editor__save-status--saved {
color: var(--c-success);
}
.wish-editor__save-status--error {
color: var(--c-danger);
}
/* Plan results */
.plan-results__preview-banner {
background-color: var(--c-accent-weak);