From f685b739f80697b3ee834d3fd3404c7b4bb5ee5b Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 14 Sep 2026 18:54:43 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- .../components/planning/WishEditor.test.tsx | 286 ++++++++++++-- .../src/components/planning/WishEditor.tsx | 352 ++++++++++++++---- frontend/src/pages/plan.css | 24 ++ 3 files changed, 572 insertions(+), 90 deletions(-) diff --git a/frontend/src/components/planning/WishEditor.test.tsx b/frontend/src/components/planning/WishEditor.test.tsx index 083fab0..17573cd 100644 --- a/frontend/src/components/planning/WishEditor.test.tsx +++ b/frontend/src/components/planning/WishEditor.test.tsx @@ -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[0]> = {}) { const onAdd = vi.fn() const onUpdate = vi.fn() const onRemove = vi.fn() - render( + const utils = render( [0]> = {}) { {...overrides} />, ) - return { onAdd, onUpdate, onRemove } + const rerender = (nextOverrides: Partial[0]> = {}) => + utils.rerender( + , + ) + 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() }) }) }) diff --git a/frontend/src/components/planning/WishEditor.tsx b/frontend/src/components/planning/WishEditor.tsx index 85749ee..ea5204b 100644 --- a/frontend/src/components/planning/WishEditor.tsx +++ b/frontend/src/components/planning/WishEditor.tsx @@ -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 + * ` - 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) => ( + + ), + )} + @@ -485,19 +690,30 @@ export function WishEditor({ ))} - 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) => ( + + ), + )} + + {wraps && ( +

+ {t('wishEditor.wrapsNote')} +

+ )} +