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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user