Author leave on the calendar itself

Planning used to mean filling in a wish list on one tab, pressing a button to
see a suggestion, then pressing another to accept it — with nothing on screen
telling you whether a plan was active. Dragging a range on the calendar now
creates the period directly, clicking a band opens it for editing, and each
change is recomputed and saved in one step. A period repeats never or yearly,
matching what the planner can actually express.

Clicking a day edits that day: its funding type, which pins it so the planner
leaves it alone, and whether the day counts as worked or off. That switch used
to be a mode covering the whole grid, which is what made the calendar confusing.

The season presets move to a rail beside the grid. Picking one lets the planner
choose the placement, then jumps there and flashes it, rather than asking the
user to guess dates the engine already knows.

A sync that funds nothing now says so, naming the period and how many days it
fell short, instead of leaving the screen unchanged and looking broken.
This commit is contained in:
2026-09-17 21:31:54 +02:00
parent 5e96c352fb
commit c2e64b504d
19 changed files with 3440 additions and 745 deletions
@@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { PeriodPopover } from './PeriodPopover'
import { makeLeaveWish } from '../../../tests/fixtures'
describe('PeriodPopover', () => {
const mockOnClose = vi.fn()
const mockOnSave = vi.fn()
const mockOnDelete = vi.fn()
const defaultProps = {
isOpen: true,
wish: null,
startDate: '2028-06-15',
endDate: '2028-06-20',
parents: [{ id: 'p1', name: 'Parent 1' }],
onSave: mockOnSave,
onDelete: mockOnDelete,
onClose: mockOnClose,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null when not open', () => {
const { container } = render(<PeriodPopover {...defaultProps} isOpen={false} />)
expect(container.firstChild).toBeNull()
})
describe('ad-hoc band (no wish)', () => {
it('shows the ad-hoc notice and date range instead of editable fields', () => {
render(<PeriodPopover {...defaultProps} />)
expect(screen.getByTestId('period-adhoc-notice')).toBeInTheDocument()
expect(screen.getByText('2028-06-15 – 2028-06-20')).toBeInTheDocument()
expect(screen.queryByTestId('period-name-input')).not.toBeInTheDocument()
})
it('does not render a save button', () => {
render(<PeriodPopover {...defaultProps} />)
expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument()
})
it('shows the ad-hoc delete label', () => {
render(<PeriodPopover {...defaultProps} />)
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar')
})
it('calls onDelete after confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
})
it('does not call onDelete when confirmation is cancelled', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
})
})
describe('wish-linked band', () => {
const wish = makeLeaveWish({
id: 'lw-1',
name: 'Sommar',
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
windowEndDay: 26,
firstYear: 2028,
lastYear: null,
recurrence: 'yearly',
parentMode: 'either',
})
it('prefills name/dates/parent/repeat from the wish', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-name-input')).toHaveValue('Sommar')
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15')
expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26')
expect(screen.getByTestId('period-parent-select')).toHaveValue('either')
expect(screen.getByTestId('period-repeat-yearly')).toBeChecked()
})
it('shows the wrapping window correctly for a Jul-style wish (end year = firstYear + 1)', () => {
const jul = makeLeaveWish({
windowStartMonth: 12,
windowStartDay: 20,
windowEndMonth: 1,
windowEndDay: 16,
firstYear: 2028,
})
render(<PeriodPopover {...defaultProps} wish={jul} />)
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-20')
expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-16')
})
it('shows "until age 4" checked when lastYear is null', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-until-age4')).toBeChecked()
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
})
it('shows an explicit last-year input when lastYear is set', () => {
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2030)
})
it('unchecking "until age 4" reveals a last-year input seeded from the start date\'s year', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-until-age4')).toBeChecked()
fireEvent.click(screen.getByTestId('period-until-age4'))
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2028)
})
it('re-checking "until age 4" hides the last-year input and clears it on save', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
fireEvent.click(screen.getByTestId('period-until-age4'))
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: null })))
})
it('editing the last-year input updates the value sent on save', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
fireEvent.change(screen.getByTestId('period-last-year-input'), { target: { value: '2032' } })
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2032)
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: 2032 })))
})
it('toggles to "Never" repeat and hides the yearly options', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-repeat-never'))
expect(screen.queryByTestId('period-yearly-options')).not.toBeInTheDocument()
})
it('calls onSave with the edited name, dates and recurrence', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.change(screen.getByTestId('period-name-input'), { target: { value: 'Sommarledighet' } })
fireEvent.change(screen.getByTestId('period-end-input'), { target: { value: '2028-08-02' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith({
name: 'Sommarledighet',
startDate: '2028-06-15',
endDate: '2028-08-02',
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'yearly',
lastYear: null,
})
})
})
it('sends lastYear: null when switched to Never even if a last year was set', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
fireEvent.click(screen.getByTestId('period-repeat-never'))
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ recurrence: 'once', lastYear: null }))
})
})
it('switches parentMode to specific when a member is selected', async () => {
mockOnSave.mockResolvedValue(undefined)
render(
<PeriodPopover
{...defaultProps}
wish={wish}
parents={[
{ id: 'p1', name: 'Alex' },
{ id: 'p2', name: 'Sam' },
]}
/>,
)
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'p2' }),
)
})
})
it('switches parentMode back to "both" after picking a specific member', async () => {
mockOnSave.mockResolvedValue(undefined)
render(
<PeriodPopover
{...defaultProps}
wish={wish}
parents={[
{ id: 'p1', name: 'Alex' },
{ id: 'p2', name: 'Sam' },
]}
/>,
)
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'both' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(
expect.objectContaining({ parentMode: 'both', parentPersonalDataUuid: null }),
)
})
})
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
mockOnDelete.mockRejectedValue('boom')
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
})
})
it('calls onDelete for a wish-linked band after confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
})
it('shows the wish-linked delete label', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period')
})
it('displays a local error when onSave rejects', async () => {
mockOnSave.mockRejectedValue(new Error('save exploded'))
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('save exploded')
})
})
it('displays a generic error when onSave rejects with a non-Error value', async () => {
mockOnSave.mockRejectedValue('boom')
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).not.toHaveTextContent('boom')
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
})
})
it('displays the error prop directly', () => {
render(<PeriodPopover {...defaultProps} wish={wish} error="server said no" />)
expect(screen.getByRole('alert')).toHaveTextContent('server said no')
})
it('shows the loading state while saving', () => {
render(<PeriodPopover {...defaultProps} wish={wish} isSaving />)
expect(screen.getByTestId('period-save-button')).toBeDisabled()
expect(screen.getByTestId('period-save-button')).toHaveTextContent('Sparar…')
})
it('resolves a preset-created title through its message key, not the stale stored name', () => {
const presetWish = makeLeaveWish({ name: 'Stale Cached Name', nameMessageKey: 'wishEditor.seasonSommar' })
render(<PeriodPopover {...defaultProps} wish={presetWish} />)
expect(screen.getByTestId('period-popover-title')).toHaveTextContent('Sommar')
})
})
describe('close behaviour', () => {
it('calls onClose on the close button', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByLabelText('Stäng'))
expect(mockOnClose).toHaveBeenCalled()
})
it('calls onClose on overlay click but not on content click', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-popover__content'))
expect(mockOnClose).not.toHaveBeenCalled()
fireEvent.click(screen.getByTestId('period-popover__overlay'))
expect(mockOnClose).toHaveBeenCalled()
})
it('calls onClose on the cancel button', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-cancel-button'))
expect(mockOnClose).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,376 @@
import { ReactNode, useEffect, useState } from 'react'
import type { LeaveWish } from '@/types'
import { useTranslation, type TranslationKey } from '@/i18n'
import { resolveWishName } from '@/utils/wishName'
import './period-popover.css'
export interface PeriodPopoverSavePayload {
name: string
// ISO-8601 dates for the period's first (or only) occurrence. Month/day
// become the wish's recurring anchor; the start date's year becomes
// `firstYear`. See `LeaveWish.window_for_year()` (backend) for how a
// wrapping window (end sorts before start) is inferred from month/day
// alone, not from the actual year gap between these two dates.
startDate: string
endDate: string
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
recurrence: 'yearly' | 'once'
// Meaningful only when `recurrence === 'yearly'`. `null` = "until the
// child turns 4" (no explicit end year).
lastYear: number | null
}
export interface PeriodPopoverProps {
isOpen: boolean
/** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band
* (planned days with no `wishId`) — those can only be deleted, not edited,
* since there is no wish to persist name/parent/repeat changes against. */
wish: LeaveWish | null
/** The clicked band's actual placed dates — always shown, and the only
* source of truth for an ad-hoc band. */
startDate: string
endDate: string
parents: Array<{ id: string; name: string }>
isSaving?: boolean | undefined
error?: string | null | undefined
onSave: (payload: PeriodPopoverSavePayload) => Promise<void>
onDelete: () => Promise<void>
onClose: () => void
}
interface FormState {
name: string
startDate: string
endDate: string
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
recurrence: 'yearly' | 'once'
lastYear: number | null
}
/** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py):
* whether (end_month, end_day) sorts before (start_month, start_day). */
function windowWraps(startMonth: number, startDay: number, endMonth: number, endDay: number): boolean {
return endMonth < startMonth || (endMonth === startMonth && endDay < startDay)
}
function formatISO(year: number, month: number, day: number): string {
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
function parseISO(date: string): { year: number; month: number; day: number } {
const [year, month, day] = date.split('-').map(Number)
return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 }
}
function formStateFromWish(wish: LeaveWish, t: (key: TranslationKey) => string): FormState {
const wraps = windowWraps(wish.windowStartMonth, wish.windowStartDay, wish.windowEndMonth, wish.windowEndDay)
const endYear = wraps ? wish.firstYear + 1 : wish.firstYear
return {
name: resolveWishName(wish, t),
startDate: formatISO(wish.firstYear, wish.windowStartMonth, wish.windowStartDay),
endDate: formatISO(endYear, wish.windowEndMonth, wish.windowEndDay),
parentMode: wish.parentMode,
parentPersonalDataUuid: wish.parentPersonalDataUuid,
recurrence: wish.recurrence,
lastYear: wish.lastYear,
}
}
export function PeriodPopover({
isOpen,
wish,
startDate,
endDate,
parents,
isSaving = false,
error = null,
onSave,
onDelete,
onClose,
}: PeriodPopoverProps): ReactNode {
const { t } = useTranslation()
const [form, setForm] = useState<FormState>({
name: '',
startDate,
endDate,
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'once',
lastYear: null,
})
const [localError, setLocalError] = useState<string | null>(null)
useEffect(() => {
if (wish) {
setForm(formStateFromWish(wish, t))
} else {
setForm({
name: '',
startDate,
endDate,
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'once',
lastYear: null,
})
}
setLocalError(null)
}, [isOpen, wish, startDate, endDate, t])
if (!isOpen) return null
const parentSelectValue = form.parentMode === 'specific' ? (form.parentPersonalDataUuid ?? '') : form.parentMode
const handleSave = async () => {
if (!wish) return
try {
setLocalError(null)
await onSave({
name: form.name,
startDate: form.startDate,
endDate: form.endDate,
parentMode: form.parentMode,
parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null,
recurrence: form.recurrence,
lastYear: form.recurrence === 'yearly' ? form.lastYear : null,
})
} catch (err) {
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
}
}
const handleDelete = async () => {
if (confirm(t('calendar.periodDeleteConfirm'))) {
try {
setLocalError(null)
await onDelete()
} catch (err) {
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
}
}
}
const title = wish ? resolveWishName(wish, t) : t('calendar.periodAdHocTitle')
return (
<div
className="period-popover__overlay"
onClick={onClose}
data-testid="period-popover__overlay"
role="dialog"
>
<div
className="period-popover__content"
onClick={(e) => e.stopPropagation()}
data-testid="period-popover__content"
>
<div className="period-popover__header">
<h2 data-testid="period-popover-title">{title}</h2>
<button
className="period-popover__close"
onClick={onClose}
aria-label={t('calendar.periodCloseAria')}
>
×
</button>
</div>
<div className="period-popover__body">
{(error || localError) && (
<div className="period-popover__error" role="alert">
{error || localError}
</div>
)}
{!wish && (
<>
<p className="period-popover__adhoc-notice" data-testid="period-adhoc-notice">
{t('calendar.periodAdHocNotice')}
</p>
<p className="period-popover__date-range">
{startDate} – {endDate}
</p>
</>
)}
{wish && (
<>
<div className="period-popover__field">
<label htmlFor="period-name" className="period-popover__label">
{t('calendar.periodNameLabel')}
</label>
<input
id="period-name"
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
className="period-popover__input"
disabled={isSaving}
data-testid="period-name-input"
aria-label={t('calendar.periodNameAria')}
/>
</div>
<div className="period-popover__field">
<label htmlFor="period-start" className="period-popover__label">
{t('calendar.periodStartLabel')}
</label>
<input
id="period-start"
type="date"
value={form.startDate}
onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))}
className="period-popover__input"
disabled={isSaving}
data-testid="period-start-input"
/>
</div>
<div className="period-popover__field">
<label htmlFor="period-end" className="period-popover__label">
{t('calendar.periodEndLabel')}
</label>
<input
id="period-end"
type="date"
value={form.endDate}
onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))}
className="period-popover__input"
disabled={isSaving}
data-testid="period-end-input"
/>
</div>
<div className="period-popover__field">
<label htmlFor="period-parent" className="period-popover__label">
{t('wishEditor.parentLabel')}
</label>
<select
id="period-parent"
value={parentSelectValue}
onChange={(e) => {
const value = e.target.value
if (value === 'either' || value === 'both') {
setForm((prev) => ({ ...prev, parentMode: value, parentPersonalDataUuid: null }))
} else {
setForm((prev) => ({ ...prev, parentMode: 'specific', parentPersonalDataUuid: value }))
}
}}
className="period-popover__select"
disabled={isSaving}
data-testid="period-parent-select"
>
<option value="either">{t('wishEditor.eitherParent')}</option>
<option value="both">{t('wishEditor.bothParents')}</option>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<fieldset className="period-popover__fieldset" data-testid="period-repeat-fieldset">
<legend className="period-popover__label">{t('calendar.periodRepeatLabel')}</legend>
<label className="period-popover__radio-label">
<input
type="radio"
name="period-repeat"
checked={form.recurrence === 'once'}
onChange={() => setForm((prev) => ({ ...prev, recurrence: 'once' }))}
disabled={isSaving}
data-testid="period-repeat-never"
/>
{' '}{t('calendar.periodRepeatNever')}
</label>
<label className="period-popover__radio-label">
<input
type="radio"
name="period-repeat"
checked={form.recurrence === 'yearly'}
onChange={() => setForm((prev) => ({ ...prev, recurrence: 'yearly' }))}
disabled={isSaving}
data-testid="period-repeat-yearly"
/>
{' '}{t('calendar.periodRepeatYearly')}
</label>
</fieldset>
{form.recurrence === 'yearly' && (
<div className="period-popover__field" data-testid="period-yearly-options">
<label className="period-popover__checkbox-label">
<input
type="checkbox"
checked={form.lastYear === null}
onChange={(e) =>
setForm((prev) => ({
...prev,
lastYear: e.target.checked ? null : parseISO(prev.startDate).year,
}))
}
disabled={isSaving}
data-testid="period-until-age4"
/>
{' '}{t('wishEditor.untilAge4')}
</label>
{form.lastYear !== null && (
<label className="period-popover__field">
{t('calendar.periodLastYearLabel')}
<input
type="number"
min={2000}
max={2200}
value={form.lastYear}
onChange={(e) =>
setForm((prev) => ({ ...prev, lastYear: Number(e.target.value) }))
}
disabled={isSaving}
data-testid="period-last-year-input"
/>
</label>
)}
</div>
)}
</>
)}
</div>
<div className="period-popover__footer">
<button
className="period-popover__button period-popover__button--danger"
onClick={() => void handleDelete()}
disabled={isSaving}
data-testid="period-delete-button"
>
{isSaving
? t('calendar.periodDeletingButton')
: wish
? t('calendar.periodDeleteButton')
: t('calendar.periodDeleteAdHocButton')}
</button>
<button
className="period-popover__button period-popover__button--secondary"
onClick={onClose}
disabled={isSaving}
data-testid="period-cancel-button"
>
{t('calendar.periodCancelButton')}
</button>
{wish && (
<button
className="period-popover__button period-popover__button--primary"
onClick={() => void handleSave()}
disabled={isSaving}
data-testid="period-save-button"
>
{isSaving ? t('calendar.periodSavingButton') : t('calendar.periodSaveButton')}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PresetRail, SEASON_PRESETS } from './PresetRail'
describe('PresetRail', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders one button per season preset', () => {
render(<PresetRail onSelectPreset={vi.fn()} />)
for (const preset of SEASON_PRESETS) {
expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeInTheDocument()
}
})
it('renders the heading and hint', () => {
render(<PresetRail onSelectPreset={vi.fn()} />)
expect(screen.getByTestId('preset-rail')).toBeInTheDocument()
})
it('calls onSelectPreset with the clicked preset', () => {
const onSelectPreset = vi.fn()
render(<PresetRail onSelectPreset={onSelectPreset} />)
fireEvent.click(screen.getByTestId('preset-rail-season-sommar'))
expect(onSelectPreset).toHaveBeenCalledWith(
expect.objectContaining({ key: 'season-sommar', defaultWeeks: 6 }),
)
})
it('every preset is flexible (window search, not a fixed placement)', () => {
// Season presets seed a "när det passar bäst" wish — the allocator
// resolves the placement. A fixed period is what dragging produces.
for (const preset of SEASON_PRESETS) {
expect(preset.startMonth).toBeGreaterThanOrEqual(1)
expect(preset.endMonth).toBeGreaterThanOrEqual(1)
}
expect(SEASON_PRESETS).toHaveLength(4)
})
it('disables every button when isBusy is true', () => {
render(<PresetRail isBusy onSelectPreset={vi.fn()} />)
for (const preset of SEASON_PRESETS) {
expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeDisabled()
}
})
it('does not call onSelectPreset for a disabled button click', () => {
const onSelectPreset = vi.fn()
render(<PresetRail isBusy onSelectPreset={onSelectPreset} />)
fireEvent.click(screen.getByTestId('preset-rail-season-var'))
expect(onSelectPreset).not.toHaveBeenCalled()
})
it('shows the week count and the flexible badge for each preset', () => {
render(<PresetRail onSelectPreset={vi.fn()} />)
const summerButton = screen.getByTestId('preset-rail-season-sommar')
expect(summerButton).toHaveTextContent('6')
expect(summerButton).toHaveTextContent('när det passar bäst')
})
})
@@ -0,0 +1,73 @@
import { ReactNode } from 'react'
import { useTranslation, type TranslationKey } from '@/i18n'
import './preset-rail.css'
export interface SeasonPreset {
key: string
labelKey: TranslationKey
startMonth: number
startDay: number
endMonth: number
endDay: number
defaultWeeks: number
}
/**
* The four season quick-picks, copied from `SEASON_PRESETS` in
* `WishEditor.tsx` (PLANNING_ENGINE_SPEC.md §10.7) — deliberately NOT
* imported from there since that component is owned by another task.
* Every one of these is a `flexible` wish: the dates below are the bounding
* range the allocator searches within for the best-costing placement, not
* the final dates. A fixed (exact-dates) period is what dragging on the
* calendar produces instead — see Calendar.tsx's drag-select handler.
*/
export const SEASON_PRESETS: SeasonPreset[] = [
{ 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 },
{ key: 'season-host', labelKey: 'wishEditor.seasonHost', startMonth: 9, startDay: 1, endMonth: 10, endDay: 31, defaultWeeks: 1 },
{ key: 'season-jul', labelKey: 'wishEditor.seasonJul', startMonth: 12, startDay: 1, endMonth: 1, endDay: 31, defaultWeeks: 4 },
]
export interface PresetRailProps {
/** Disables every button while a preset's create+sync is in flight. */
isBusy?: boolean | undefined
onSelectPreset: (preset: SeasonPreset) => void | Promise<void>
}
/**
* A side rail of one-click season presets. Clicking one creates a flexible
* `LeaveWish` and syncs the plan — the allocator picks the best placement,
* and the calendar scrolls to it and flashes the resulting band (see
* Calendar.tsx's `handleSelectPreset`). This rail only renders the choices
* and reports which one was picked; it owns no request/scroll logic itself.
*/
export function PresetRail({ isBusy = false, onSelectPreset }: PresetRailProps): ReactNode {
const { t } = useTranslation()
return (
<aside className="preset-rail" data-testid="preset-rail" aria-label={t('calendar.railHeading')}>
<h3 className="preset-rail__heading">{t('calendar.railHeading')}</h3>
<p className="preset-rail__hint">{t('calendar.railHint')}</p>
<div className="preset-rail__buttons">
{SEASON_PRESETS.map((preset) => (
<button
key={preset.key}
type="button"
className="preset-rail__button"
onClick={() => void onSelectPreset(preset)}
disabled={isBusy}
data-testid={`preset-rail-${preset.key}`}
aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
>
<span className="preset-rail__button-name">{t(preset.labelKey)}</span>
<span className="preset-rail__button-detail">
{preset.defaultWeeks} {t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}
{' · '}
{t('wishEditor.flexBadge')}
</span>
</button>
))}
</div>
</aside>
)
}
@@ -0,0 +1,177 @@
.period-popover__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.period-popover__content {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
max-width: 480px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.period-popover__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.period-popover__header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.period-popover__close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
padding: 0;
color: #666;
transition: color 0.2s;
}
.period-popover__close:hover {
color: #000;
}
.period-popover__body {
padding: 20px;
}
.period-popover__error {
background-color: #ffebee;
color: #c62828;
padding: 12px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
}
.period-popover__adhoc-notice {
font-size: 13px;
color: #666;
margin: 0 0 12px 0;
}
.period-popover__date-range {
font-size: 15px;
font-weight: 500;
color: #333;
}
.period-popover__field {
margin-bottom: 16px;
}
.period-popover__label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
color: #333;
}
.period-popover__input,
.period-popover__select {
width: 100%;
padding: 8px 10px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 14px;
background-color: #fff;
}
.period-popover__input:disabled,
.period-popover__select:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.period-popover__fieldset {
border: none;
padding: 0;
margin: 0 0 16px 0;
}
.period-popover__radio-label,
.period-popover__checkbox-label {
display: flex;
align-items: center;
font-size: 14px;
cursor: pointer;
user-select: none;
margin-bottom: 8px;
}
.period-popover__radio-label input,
.period-popover__checkbox-label input {
margin-right: 8px;
cursor: pointer;
}
.period-popover__footer {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
background-color: #fafafa;
}
.period-popover__button {
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.period-popover__button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.period-popover__button--primary {
background-color: #0066cc;
color: #fff;
}
.period-popover__button--primary:hover:not(:disabled) {
background-color: #0052a3;
}
.period-popover__button--secondary {
background-color: #e0e0e0;
color: #333;
}
.period-popover__button--secondary:hover:not(:disabled) {
background-color: #d0d0d0;
}
.period-popover__button--danger {
background-color: #d32f2f;
color: #fff;
margin-right: auto;
}
.period-popover__button--danger:hover:not(:disabled) {
background-color: #b71c1c;
}
@@ -0,0 +1,63 @@
.preset-rail {
width: 200px;
flex-shrink: 0;
padding: 16px;
background-color: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 4px;
align-self: flex-start;
}
.preset-rail__heading {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.preset-rail__hint {
margin: 0 0 12px 0;
font-size: 12px;
color: #666;
}
.preset-rail__buttons {
display: flex;
flex-direction: column;
gap: 8px;
}
.preset-rail__button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 10px 12px;
border: 1px solid #d0d0d0;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
text-align: left;
transition: background-color 0.2s, border-color 0.2s;
}
.preset-rail__button:hover:not(:disabled) {
background-color: #f0f8ff;
border-color: #0066cc;
}
.preset-rail__button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.preset-rail__button-name {
font-size: 14px;
font-weight: 600;
color: #333;
}
.preset-rail__button-detail {
font-size: 11px;
color: #666;
}
@@ -456,6 +456,26 @@ describe('CalendarWeek', () => {
})
})
describe('§Task7 flashWishId', () => {
it('adds the flash class to the band matching flashWishId', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-1" />)
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).toHaveClass('calendar-band--flash')
})
it('does not flash a band for a different wishId', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-2" />)
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
})
it('never flashes an ad-hoc (wishId null) band even if flashWishId is set oddly', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId={null} />)
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
})
})
describe('lanes: overlapping bands for two parents do not collide', () => {
it('assigns overlapping bands to different lanes (both render, neither is dropped)', () => {
const plannedDays = [
@@ -29,8 +29,12 @@ export interface CalendarWeekProps {
onPointerEnter: (date: string) => void
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
calendarDaysByDate?: Map<string, CalendarDayEntry> | undefined
/** Optional for now — a later task wires this to a period editor. */
/** Opens the period editor for the clicked band's underlying period. */
onBandClick?: ((info: BandClickInfo) => void) | undefined
/** §Task7 — the wishId of a just-created/just-edited period to briefly
* highlight (see Calendar.tsx's preset rail: "scrolls to the resolved
* placement and flashes the band"). `null`/omitted highlights nothing. */
flashWishId?: string | null | undefined
}
/** One day's slot inside a band segment: either a real planned_leave_day
@@ -241,6 +245,7 @@ export function CalendarWeek({
onPointerEnter,
calendarDaysByDate,
onBandClick,
flashWishId,
}: CalendarWeekProps): ReactNode {
const { t } = useTranslation()
const bandSegments = useMemo(
@@ -323,6 +328,7 @@ export function CalendarWeek({
'calendar-band',
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
]
.filter(Boolean)
.join(' ')
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlannedDayModal } from './PlannedDayModal'
import { makePlannedLeaveDay } from '@/../tests/fixtures'
import { makePlannedLeaveDay, makeCalendarDayEntry } from '@/../tests/fixtures'
describe('PlannedDayModal', () => {
const mockOnClose = vi.fn()
@@ -827,4 +827,136 @@ describe('PlannedDayModal', () => {
expect(screen.getByTestId('delete-button')).toHaveTextContent('Tar bort…')
})
})
describe('§Task7 pinned notice', () => {
it('shows a pinned notice when the existing day is pinned', () => {
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true })
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
expect(screen.getByTestId('pinned-notice')).toBeInTheDocument()
})
it('does not show a pinned notice for an unpinned day', () => {
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: false })
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
})
it('does not show a pinned notice for a brand-new day', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
})
})
describe('§Task7 day status switch (arbetsdag/ledig)', () => {
const onToggleOverride = vi.fn()
beforeEach(() => {
onToggleOverride.mockReset()
})
it('is hidden when dayInfo is not provided', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} onToggleOverride={onToggleOverride} />)
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
})
it('is hidden when onToggleOverride is not provided', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} dayInfo={makeCalendarDayEntry()} />)
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
})
it('is hidden in range mode even with dayInfo and onToggleOverride provided', () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode={true}
dayCount={3}
dayInfo={makeCalendarDayEntry()}
onToggleOverride={onToggleOverride}
/>
)
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
})
it('selects "Ledig" when the day is currently free', () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
dayInfo={makeCalendarDayEntry({ isFree: true })}
onToggleOverride={onToggleOverride}
/>
)
expect(screen.getByTestId('day-status-off')).toBeChecked()
expect(screen.getByTestId('day-status-workday')).not.toBeChecked()
})
it('selects "Arbetsdag" when the day is currently a working day', () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
dayInfo={makeCalendarDayEntry({ isFree: false })}
onToggleOverride={onToggleOverride}
/>
)
expect(screen.getByTestId('day-status-workday')).toBeChecked()
expect(screen.getByTestId('day-status-off')).not.toBeChecked()
})
it('calls onToggleOverride when picking the option that differs from the current status', async () => {
onToggleOverride.mockResolvedValue(undefined)
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
dayInfo={makeCalendarDayEntry({ isFree: false })}
onToggleOverride={onToggleOverride}
/>
)
await userEvent.click(screen.getByTestId('day-status-off'))
expect(onToggleOverride).toHaveBeenCalledTimes(1)
})
it('does not call onToggleOverride when clicking the already-selected option', async () => {
onToggleOverride.mockResolvedValue(undefined)
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
dayInfo={makeCalendarDayEntry({ isFree: false })}
onToggleOverride={onToggleOverride}
/>
)
await userEvent.click(screen.getByTestId('day-status-workday'))
expect(onToggleOverride).not.toHaveBeenCalled()
})
it('shows the override error when provided', () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
dayInfo={makeCalendarDayEntry()}
onToggleOverride={onToggleOverride}
overrideError="Det gick inte att uppdatera undantaget"
/>
)
expect(screen.getByTestId('day-status-error')).toHaveTextContent('Det gick inte att uppdatera undantaget')
})
it('disables the switch while saving', () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isSaving={true}
dayInfo={makeCalendarDayEntry()}
onToggleOverride={onToggleOverride}
/>
)
expect(screen.getByTestId('day-status-workday')).toBeDisabled()
expect(screen.getByTestId('day-status-off')).toBeDisabled()
})
})
})
@@ -1,5 +1,5 @@
import { ReactNode, useState, useEffect } from 'react'
import type { PlannedLeaveDay } from '@/types'
import type { CalendarDayEntry, PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import './planned-day-modal.css'
@@ -21,6 +21,14 @@ export interface PlannedDayModalProps {
isRangeMode?: boolean | undefined
dayCount?: number | undefined
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
// §Task7 day popover — this date's calendar metadata (röda dagar/weekend/
// overrides), driving the "arbetsdag/ledig" switch below. `undefined`
// while the calendar window hasn't loaded metadata for this date yet;
// `null`/absent `onToggleOverride` hides the switch entirely (e.g. in
// range mode, where there is no single day to toggle).
dayInfo?: CalendarDayEntry | null | undefined
onToggleOverride?: (() => Promise<void>) | undefined
overrideError?: string | null | undefined
}
export function PlannedDayModal({
@@ -37,12 +45,16 @@ export function PlannedDayModal({
isRangeMode = false,
dayCount = 1,
defaultDayType,
dayInfo,
onToggleOverride,
overrideError = null,
}: PlannedDayModalProps): ReactNode {
const { t, locale } = useTranslation()
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
const [parentId, setParentId] = useState(selectedParentId)
const [isDubbeldag, setIsDubbeldag] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const [isTogglingOverride, setIsTogglingOverride] = useState(false)
useEffect(() => {
if (existingDay) {
@@ -88,6 +100,22 @@ export function PlannedDayModal({
}
}
// §Task7 — the "Den här dagen är: arbetsdag / ledig" switch. There are
// only two effective states, so flipping to whichever option ISN'T
// currently selected is exactly the toggle `onToggleOverride` performs —
// no separate "set to X" call is needed. A no-op click on the
// already-selected option is ignored.
const handleSelectDayStatus = async (wantFree: boolean) => {
if (!onToggleOverride || !dayInfo) return
if (dayInfo.isFree === wantFree) return
try {
setIsTogglingOverride(true)
await onToggleOverride()
} finally {
setIsTogglingOverride(false)
}
}
if (!isOpen) return null
const displayDate = new Date(date + 'T00:00:00Z').toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
@@ -179,6 +207,52 @@ export function PlannedDayModal({
</p>
)}
</div>
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
📌 {t('plannedDayModal.pinnedNotice')}
</p>
)}
{!isRangeMode && dayInfo && onToggleOverride && (
<div className="planned-day-modal__field">
<span className="planned-day-modal__label">{t('plannedDayModal.dayStatusLabel')}</span>
<div
className="planned-day-modal__override-switch"
role="radiogroup"
aria-label={t('plannedDayModal.dayStatusLabel')}
data-testid="day-status-switch"
>
<label className="planned-day-modal__checkbox-label">
<input
type="radio"
name="day-status"
checked={!dayInfo.isFree}
onChange={() => void handleSelectDayStatus(false)}
disabled={isSaving || isTogglingOverride}
data-testid="day-status-workday"
/>
{' '}{t('plannedDayModal.workdayOption')}
</label>
<label className="planned-day-modal__checkbox-label">
<input
type="radio"
name="day-status"
checked={dayInfo.isFree}
onChange={() => void handleSelectDayStatus(true)}
disabled={isSaving || isTogglingOverride}
data-testid="day-status-off"
/>
{' '}{t('plannedDayModal.dayOffOption')}
</label>
</div>
{overrideError && (
<p className="planned-day-modal__error" role="alert" data-testid="day-status-error">
{overrideError}
</p>
)}
</div>
)}
</div>
<div className="planned-day-modal__footer">
@@ -121,6 +121,22 @@
font-style: italic;
}
.planned-day-modal__pinned-notice {
font-size: 12px;
color: #666;
margin: -8px 0 16px 0;
}
.planned-day-modal__override-switch {
display: flex;
gap: 16px;
margin-top: 6px;
}
.planned-day-modal__override-switch .planned-day-modal__checkbox-label {
margin-bottom: 0;
}
.planned-day-modal__footer {
display: flex;
gap: 8px;
+1
View File
@@ -2,6 +2,7 @@ export { useAnalytics } from './useAnalytics'
export { useLeaveWishes } from './useLeaveWishes'
export { usePlanProposal } from './usePlanProposal'
export { usePlanApply } from './usePlanApply'
export { usePlanSync } from './usePlanSync'
export { useWorkspaceMembers } from './useWorkspaceMembers'
export { useShowWeekNumbers, getLastLeaveType, setLastLeaveType } from './useCalendarPreferences'
export { useChildSettings } from './useChildSettings'
+114
View File
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { usePlanSync } from './usePlanSync'
import * as api from '@/services/api'
import { makePlanSyncResponse, makePlanChange } from '../../tests/fixtures'
vi.mock('@/services/api', () => ({
syncPlan: vi.fn(),
}))
describe('usePlanSync', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts idle with no error and no changes', () => {
const { result } = renderHook(() => usePlanSync())
expect(result.current.isSyncing).toBe(false)
expect(result.current.error).toBeNull()
expect(result.current.lastChanges).toBeNull()
})
it('calls api.syncPlan with the workspace id and stores the changes', async () => {
const response = makePlanSyncResponse({ changes: [makePlanChange({ kind: 'retyped' })] })
vi.mocked(api.syncPlan).mockResolvedValue(response)
const { result } = renderHook(() => usePlanSync())
let returned: Awaited<ReturnType<typeof result.current.sync>> | undefined
await act(async () => {
returned = await result.current.sync('ws-1')
})
expect(api.syncPlan).toHaveBeenCalledWith('ws-1')
expect(returned).toEqual(response)
expect(result.current.lastChanges).toEqual(response.changes)
expect(result.current.isSyncing).toBe(false)
expect(result.current.error).toBeNull()
})
it('flips isSyncing while the call is in flight', async () => {
let resolvePromise: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
vi.mocked(api.syncPlan).mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve
}),
)
const { result } = renderHook(() => usePlanSync())
let syncPromise: Promise<unknown> = Promise.resolve()
act(() => {
syncPromise = result.current.sync('ws-1')
})
await waitFor(() => expect(result.current.isSyncing).toBe(true))
resolvePromise(makePlanSyncResponse())
await act(async () => {
await syncPromise
})
expect(result.current.isSyncing).toBe(false)
})
it('clears a previous changes list at the start of a new call', async () => {
vi.mocked(api.syncPlan).mockResolvedValueOnce(
makePlanSyncResponse({ changes: [makePlanChange()] }),
)
const { result } = renderHook(() => usePlanSync())
await act(async () => {
await result.current.sync('ws-1')
})
expect(result.current.lastChanges).toHaveLength(1)
let resolveSecond: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
vi.mocked(api.syncPlan).mockReturnValueOnce(
new Promise((resolve) => {
resolveSecond = resolve
}),
)
act(() => {
void result.current.sync('ws-1')
})
await waitFor(() => expect(result.current.lastChanges).toBeNull())
resolveSecond(makePlanSyncResponse())
})
it('sets a readable error and rethrows when the request rejects with an Error', async () => {
vi.mocked(api.syncPlan).mockRejectedValue(new Error('workspace not found'))
const { result } = renderHook(() => usePlanSync())
await act(async () => {
await expect(result.current.sync('ws-1')).rejects.toThrow('workspace not found')
})
expect(result.current.error).toBe('workspace not found')
expect(result.current.isSyncing).toBe(false)
})
it('falls back to a generic error message for a non-Error rejection', async () => {
vi.mocked(api.syncPlan).mockRejectedValue('network exploded')
const { result } = renderHook(() => usePlanSync())
await act(async () => {
await expect(result.current.sync('ws-1')).rejects.toBe('network exploded')
})
expect(result.current.error).toBe('Failed to sync the plan')
})
})
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react'
import * as api from '@/services/api'
import type { PlanSyncResponse } from '@/types'
interface UsePlanSyncResult {
/** True while a sync call is in flight. */
isSyncing: boolean
error: string | null
/** The most recent sync's `changes` — cleared to `null` at the start of
* every new call so a stale diff from a previous sync never lingers. */
lastChanges: PlanSyncResponse['changes'] | null
sync: (workspaceId: string) => Promise<PlanSyncResponse>
}
/**
* `POST /api/plan/sync` — the direct-manipulation calendar's one call:
* recompute the plan from the workspace's `LeaveWish`es and persist it,
* never retyping/overwriting a pinned day, and reporting exactly what
* changed. Callers (Calendar.tsx) drive their own toast/undo UI off the
* returned `changes`; this hook only owns the request's loading/error state.
*/
export function usePlanSync(): UsePlanSyncResult {
const [isSyncing, setIsSyncing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastChanges, setLastChanges] = useState<PlanSyncResponse['changes'] | null>(null)
const sync = useCallback(async (workspaceId: string): Promise<PlanSyncResponse> => {
setIsSyncing(true)
setError(null)
setLastChanges(null)
try {
const result = await api.syncPlan(workspaceId)
setLastChanges(result.changes)
return result
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to sync the plan')
throw err
} finally {
setIsSyncing(false)
}
}, [])
return { isSyncing, error, lastChanges, sync }
}
+44
View File
@@ -49,6 +49,10 @@ export const calendar = {
'plannedDayModal.leaveTypeLabel': 'Leave Type',
'plannedDayModal.newTitle': 'Plan a Day',
'plannedDayModal.parentLabel': 'Parent',
'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
'plannedDayModal.dayStatusLabel': 'This day is:',
'plannedDayModal.workdayOption': 'Working day',
'plannedDayModal.dayOffOption': 'Day off',
'plannedDayModal.rangeTitle': 'Plan {count} days',
'plannedDayModal.saveButton': 'Save',
'plannedDayModal.saveError': 'Failed to save planned day',
@@ -63,4 +67,44 @@ export const calendar = {
'redDaysList.overrideFree': 'Exception: day off',
'redDaysList.overrideWorking': 'Exception: working day',
'redDaysList.prevWindow': 'Previous 4 years',
// §Task7 — the calendar becomes the plan: drag-select, period popover,
// preset rail, and the sync toast/undo.
'calendar.newPeriodName': 'New period',
'calendar.periodCreateError': 'Failed to create the period',
'calendar.syncError': 'Failed to sync the plan',
'calendar.syncingLabel': 'Syncing…',
'calendar.toastChangedDays': '{name}: {count} day(s) changed',
'calendar.toastGenericPeriod': 'The period',
'calendar.toastShortfallNone':
'{name} ({start}–{end}): none of the {weeksWished} week(s) requested could be funded — {shortfallDays} day(s) short',
'calendar.toastShortfallPartial':
'{name} ({start}–{end}): only {weeksFunded} of {weeksWished} week(s) funded — {shortfallDays} day(s) short',
'calendar.undoButton': 'Undo',
'calendar.undoError': 'Failed to undo the change',
'calendar.dismissToastAria': 'Dismiss the message',
'calendar.railHeading': 'Add leave',
'calendar.railHint': 'Click to add — the calendar jumps to wherever it fits best.',
'calendar.presetAddAria': 'Add {name}',
'calendar.periodPopoverTitle': 'Edit period',
'calendar.periodAdHocTitle': 'Period',
'calendar.periodAdHocNotice': 'This period is not linked to a saved wish.',
'calendar.periodNameLabel': 'Name',
'calendar.periodNameAria': "The period's name",
'calendar.periodStartLabel': 'Start date',
'calendar.periodEndLabel': 'End date',
'calendar.periodRepeatLabel': 'Repeat',
'calendar.periodRepeatNever': 'Never',
'calendar.periodRepeatYearly': 'Yearly',
'calendar.periodLastYearLabel': 'Last year',
'calendar.periodSaveButton': 'Save period',
'calendar.periodSavingButton': 'Saving…',
'calendar.periodDeleteButton': 'Delete period',
'calendar.periodDeletingButton': 'Deleting…',
'calendar.periodDeleteConfirm': 'Are you sure you want to delete this period?',
'calendar.periodDeleteAdHocButton': 'Delete these days',
'calendar.periodCancelButton': 'Cancel',
'calendar.periodCloseAria': 'Close',
} as const
+44
View File
@@ -49,6 +49,10 @@ export const calendar = {
'plannedDayModal.leaveTypeLabel': 'Typ av ledighet',
'plannedDayModal.newTitle': 'Planera en dag',
'plannedDayModal.parentLabel': 'Förälder',
'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
'plannedDayModal.dayStatusLabel': 'Den här dagen är:',
'plannedDayModal.workdayOption': 'Arbetsdag',
'plannedDayModal.dayOffOption': 'Ledig',
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
'plannedDayModal.saveButton': 'Spara',
'plannedDayModal.saveError': 'Det gick inte att spara den planerade dagen',
@@ -63,4 +67,44 @@ export const calendar = {
'redDaysList.overrideFree': 'Undantag: ledig dag',
'redDaysList.overrideWorking': 'Undantag: arbetsdag',
'redDaysList.prevWindow': 'Föregående 4 år',
// §Task7 — the calendar becomes the plan: drag-select, period popover,
// preset rail, and the sync toast/undo.
'calendar.newPeriodName': 'Ny period',
'calendar.periodCreateError': 'Det gick inte att skapa perioden',
'calendar.syncError': 'Det gick inte att synka planen',
'calendar.syncingLabel': 'Synkroniserar…',
'calendar.toastChangedDays': '{name}: {count} dag(ar) ändrades',
'calendar.toastGenericPeriod': 'Perioden',
'calendar.toastShortfallNone':
'{name} ({start}–{end}): inget av de {weeksWished} önskade veckorna kunde finansieras — {shortfallDays} dag(ar) saknas',
'calendar.toastShortfallPartial':
'{name} ({start}–{end}): endast {weeksFunded} av {weeksWished} veckor finansierades — {shortfallDays} dag(ar) saknas',
'calendar.undoButton': 'Ångra',
'calendar.undoError': 'Det gick inte att ångra ändringen',
'calendar.dismissToastAria': 'Stäng meddelandet',
'calendar.railHeading': 'Lägg till ledighet',
'calendar.railHint': 'Klicka för att lägga till — kalendern hoppar till där den passar bäst.',
'calendar.presetAddAria': 'Lägg till {name}',
'calendar.periodPopoverTitle': 'Redigera period',
'calendar.periodAdHocTitle': 'Period',
'calendar.periodAdHocNotice': 'Den här perioden är inte kopplad till en sparad önskan.',
'calendar.periodNameLabel': 'Namn',
'calendar.periodNameAria': 'Periodens namn',
'calendar.periodStartLabel': 'Startdatum',
'calendar.periodEndLabel': 'Slutdatum',
'calendar.periodRepeatLabel': 'Upprepning',
'calendar.periodRepeatNever': 'Aldrig',
'calendar.periodRepeatYearly': 'Varje år',
'calendar.periodLastYearLabel': 'Sista året',
'calendar.periodSaveButton': 'Spara period',
'calendar.periodSavingButton': 'Sparar…',
'calendar.periodDeleteButton': 'Ta bort period',
'calendar.periodDeletingButton': 'Tar bort…',
'calendar.periodDeleteConfirm': 'Är du säker på att du vill ta bort den här perioden?',
'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar',
'calendar.periodCancelButton': 'Avbryt',
'calendar.periodCloseAria': 'Stäng',
} as const
File diff suppressed because it is too large Load Diff
+626 -145
View File
@@ -1,4 +1,4 @@
import { ReactNode, useMemo, useState, useEffect } from 'react'
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useWorkspace } from '@/context'
import {
usePlannedDays,
@@ -7,14 +7,31 @@ import {
setLastLeaveType,
useCalendarDays,
useDayOverrides,
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
} from '@/hooks'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import type {
PlannedLeaveDay,
CalendarDayEntry,
LeaveWish,
LeaveWishCreateRequest,
LeaveWishUpdateRequest,
PersonalData,
PlanChange,
PlanSyncResponse,
WindowOutcome,
} from '@/types'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { CalendarWeek, type BandClickInfo } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
import { getColorForType } from '@/utils/leaveTypes'
import { datesInRange } from '@/utils/dates'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay } from '@/utils/dates'
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
import { useTranslation } from '@/i18n'
import './calendar.css'
@@ -28,10 +45,137 @@ const WEEKDAY_KEYS = [
'calendar.weekdaySun',
] as const
/** Small "what changed" toast + optional undo — see Calendar.tsx's usage. */
interface CalendarToast {
message: string
undo: { label: string; run: () => Promise<void> } | null
}
function splitISODate(date: string): { year: number; month: number; day: number } {
const [year, month, day] = date.split('-').map(Number)
return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 }
}
/** Builds the full `LeaveWishCreateRequest` a wish's current fields would
* recreate — used both to "undo" a period delete (recreate the wish) and,
* minus `workspaceId`/`childId`, as the patch that "undoes" a period edit
* (restore every field to its pre-edit value; §Task7 "undo reverts the
* wish edit, not the days"). */
function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
return {
workspaceId: w.workspaceId,
childId: w.childId,
name: w.name,
nameMessageKey: w.nameMessageKey,
windowStartMonth: w.windowStartMonth,
windowStartDay: w.windowStartDay,
windowEndMonth: w.windowEndMonth,
windowEndDay: w.windowEndDay,
targetWeeks: w.targetWeeks,
targetDays: w.targetDays ?? null,
recurrence: w.recurrence,
firstYear: w.firstYear,
lastYear: w.lastYear,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
}
}
function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
return {
name: w.name,
nameMessageKey: w.nameMessageKey,
windowStartMonth: w.windowStartMonth,
windowStartDay: w.windowStartDay,
windowEndMonth: w.windowEndMonth,
windowEndDay: w.windowEndDay,
targetWeeks: w.targetWeeks,
targetDays: w.targetDays ?? null,
recurrence: w.recurrence,
firstYear: w.firstYear,
lastYear: w.lastYear,
priority: w.priority,
parentMode: w.parentMode,
parentPersonalDataUuid: w.parentPersonalDataUuid,
windowMode: w.windowMode,
}
}
/** Groups a sync's `changes` by the wish (or "adhoc") they belong to and
* renders one "{name}: N dagar ändrades" fragment per group — house style
* is structured data in, composed copy out; the backend never sends prose. */
function summarizeChanges(changes: PlanChange[], wishes: LeaveWish[], t: Translate): string | null {
if (changes.length === 0) return null
const counts = new Map<string, number>()
for (const change of changes) {
const key = change.newWishId ?? change.oldWishId ?? 'adhoc'
counts.set(key, (counts.get(key) ?? 0) + 1)
}
const parts: string[] = []
for (const [wishId, count] of counts) {
const wish = wishes.find((w) => w.id === wishId)
const name = wish ? resolveWishName(wish, t) : t('calendar.toastGenericPeriod')
parts.push(t('calendar.toastChangedDays', { name, count }))
}
return parts.join(' · ')
}
/** Renders a `WindowOutcome`'s shortfall for the touched wish, from
* `outcome.messageKey`/`data` (today always `planOutcomes.windowExplanation`,
* carrying `wish_name`/`wish_name_message_key`) — falling back to the plain
* `shortfallDays` count when there's nothing richer to say. `funded`
* distinguishes "nothing could be placed" from "only part of it fit", the
* two phrasings the "sync that changes nothing must say why" fix needs. */
function summarizeOutcomeShortfall(outcome: WindowOutcome, funded: boolean, t: Translate): string {
const name = resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod')
const params = {
name,
start: formatDateForDisplay(outcome.startDate),
end: formatDateForDisplay(outcome.endDate),
weeksWished: outcome.weeksWished,
weeksFunded: outcome.weeksFunded,
shortfallDays: outcome.shortfallDays,
}
return t(funded ? 'calendar.toastShortfallPartial' : 'calendar.toastShortfallNone', params)
}
/** Builds the full sync toast message: what changed (existing summary), why
* the wish just touched (if any) fell short of what was wished, and any
* rejections the apply step returned — a genuine no-op sync (nothing to
* report on any of the three) still renders `null`, so it stays silent. */
function buildSyncToastMessage(
result: PlanSyncResponse,
wishes: LeaveWish[],
members: PersonalData[],
watchWishId: string | undefined,
t: Translate,
): string | null {
const parts: string[] = []
const changesMessage = summarizeChanges(result.changes, wishes, t)
if (changesMessage) parts.push(changesMessage)
if (watchWishId) {
const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId)
if (outcome && outcome.shortfallDays > 0) {
const funded = result.changes.some((c) => c.newWishId === watchWishId || c.oldWishId === watchWishId)
parts.push(summarizeOutcomeShortfall(outcome, funded, t))
}
}
for (const rejection of result.applyResult.rejections) {
parts.push(renderRejection(rejection, t, members))
}
return parts.length > 0 ? parts.join(' · ') : null
}
export function Calendar(): ReactNode {
const { t, locale } = useTranslation()
const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace()
const { data: plannedDays, isLoading: pdLoading, add, update, remove } = usePlannedDays(selectedChildId ?? undefined)
const plannedDaysHook = usePlannedDays(selectedChildId ?? undefined)
const [showWeekNumbers] = useShowWeekNumbers()
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDates, setSelectedDates] = useState<Set<string>>(new Set())
@@ -39,13 +183,20 @@ export function Calendar(): ReactNode {
const [dragCurrent, setDragCurrent] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isRangeMode, setIsRangeMode] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [isOverrideMode, setIsOverrideMode] = useState(false)
const [overrideError, setOverrideError] = useState<string | null>(null)
const isLoading = wsLoading || pdLoading
const [activeBand, setActiveBand] = useState<BandClickInfo | null>(null)
const [isPeriodOpen, setIsPeriodOpen] = useState(false)
const [isPeriodSaving, setIsPeriodSaving] = useState(false)
const [periodError, setPeriodError] = useState<string | null>(null)
const [isCreatingPeriod, setIsCreatingPeriod] = useState(false)
const [flashWishId, setFlashWishId] = useState<string | null>(null)
const [toast, setToast] = useState<CalendarToast | null>(null)
const [isUndoing, setIsUndoing] = useState(false)
const isLoading = wsLoading || plannedDaysHook.isLoading
// §3.4/§3.6 — the visible grid can spill into the previous year (January's
// leading days) or the next year (December's trailing days), so fetch
@@ -56,44 +207,155 @@ export function Calendar(): ReactNode {
const calendarMaxYear = viewMonth === 11 ? viewYear + 1 : viewYear
const calendarYearsSpan = calendarMaxYear - calendarMinYear + 1
const { data: calendarWindow, refetch: refetchCalendarWindow } = useCalendarDays(
workspace?.id ?? '',
calendarMinYear,
calendarYearsSpan,
)
const { data: dayOverrides, upsert: upsertDayOverride, remove: removeDayOverride } = useDayOverrides(
workspace?.id ?? '',
)
const calendarHook = useCalendarDays(workspace?.id ?? '', calendarMinYear, calendarYearsSpan)
const dayOverridesHook = useDayOverrides(workspace?.id ?? '')
const wishesHook = useLeaveWishes(workspace?.id ?? null)
const membersHook = useWorkspaceMembers(workspace?.id ?? null)
const syncHook = usePlanSync()
const calendarDaysByDate = useMemo(() => {
const map = new Map<string, CalendarDayEntry>()
for (const day of calendarWindow?.days ?? []) {
for (const day of calendarHook.data?.days ?? []) {
map.set(day.date, day)
}
return map
}, [calendarWindow])
}, [calendarHook.data])
const showToast = useCallback((message: string, undo: CalendarToast['undo']) => {
setToast({ message, undo })
}, [])
// Auto-dismiss the toast after a while so it never lingers indefinitely.
useEffect(() => {
if (!toast) return
const timer = setTimeout(() => setToast(null), 8000)
return () => clearTimeout(timer)
}, [toast])
// Clear a flashed band's highlight shortly after it's applied.
useEffect(() => {
if (!flashWishId) return
const timer = setTimeout(() => setFlashWishId(null), 1600)
return () => clearTimeout(timer)
}, [flashWishId])
// §Task7 — the calendar IS the plan: recompute + persist from the
// workspace's LeaveWishes in one call, then refresh the two views that
// depend on the result (planned days for the bands, calendar metadata for
// shading). `silent` suppresses the "what changed" toast (used for the
// initial load and for undo's re-sync) without suppressing error
// reporting — a broken sync should always be visible.
const runSync = useCallback(
async (
workspaceId: string,
options?: { silent?: boolean; undo?: CalendarToast['undo']; watchWishId?: string },
): Promise<PlanSyncResponse | undefined> => {
try {
const result = await syncHook.sync(workspaceId)
await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch()])
if (!options?.silent) {
const message = buildSyncToastMessage(
result,
wishesHook.data,
membersHook.data,
options?.watchWishId,
t,
)
if (message) showToast(message, options?.undo ?? null)
}
return result
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.syncError'), null)
return undefined
}
},
[syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast],
)
// Sync once per workspace as soon as it's known, so wishes seeded/created
// elsewhere show up as bands without requiring a manual edit first.
const didAutoSyncRef = useRef<string | null>(null)
useEffect(() => {
if (!workspace?.id) return
if (didAutoSyncRef.current === workspace.id) return
didAutoSyncRef.current = workspace.id
void runSync(workspace.id, { silent: true })
}, [workspace?.id, runSync])
const handleCreatePeriodFromDrag = useCallback(
async (dates: string[]) => {
setSelectedDates(new Set())
if (!workspace?.id || !selectedChildId || !personalDataUuid) return
const sorted = [...dates].sort()
const start = sorted[0]
const end = sorted[sorted.length - 1]
if (!start || !end) return
const { year: startYear, month: startMonth, day: startDay } = splitISODate(start)
const { month: endMonth, day: endDay } = splitISODate(end)
const dayCount = sorted.length
const targetWeeks = Math.min(52, Math.max(1, Math.ceil(dayCount / 7)))
try {
setIsCreatingPeriod(true)
const created = await wishesHook.add({
workspaceId: workspace.id,
childId: selectedChildId,
name: t('calendar.newPeriodName'),
nameMessageKey: 'calendar.newPeriodName',
windowStartMonth: startMonth,
windowStartDay: startDay,
windowEndMonth: endMonth,
windowEndDay: endDay,
targetWeeks,
targetDays: dayCount,
recurrence: 'once',
firstYear: startYear,
priority: wishesHook.data.length + 1,
parentMode: 'specific',
parentPersonalDataUuid: personalDataUuid,
windowMode: 'fixed',
})
await runSync(workspace.id, {
watchWishId: created.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.remove(created.id)
await runSync(workspace.id, { silent: true })
},
},
})
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null)
} finally {
setIsCreatingPeriod(false)
}
},
[workspace?.id, selectedChildId, personalDataUuid, wishesHook, t, runSync, showToast],
)
useEffect(() => {
const handlePointerUp = () => {
if (isDragging && dragAnchor && dragCurrent) {
const dates = datesInRange(dragAnchor, dragCurrent)
setSelectedDates(new Set(dates))
if (dates.length === 1) {
setIsRangeMode(false)
} else {
setIsRangeMode(true)
setSelectedDates(new Set(dates))
setIsModalOpen(true)
} else if (dates.length > 1) {
void handleCreatePeriodFromDrag(dates)
}
setIsModalOpen(true)
}
setIsDragging(false)
setDragAnchor(null)
setDragCurrent(null)
}
if (isDragging) {
window.addEventListener('pointerup', handlePointerUp)
return () => window.removeEventListener('pointerup', handlePointerUp)
}
}, [isDragging, dragAnchor, dragCurrent])
}, [isDragging, dragAnchor, dragCurrent, handleCreatePeriodFromDrag])
if (isLoading) {
return <LoadingSpinner message={t('calendar.loading')} />
@@ -106,8 +368,8 @@ export function Calendar(): ReactNode {
// `CalendarWindow.minYear`/`maxYear`, not a hardcoded literal), while
// always including the currently-selected year even before the window has
// loaded (or if it somehow falls outside the reported bounds).
const yearRangeMin = Math.min(calendarWindow?.minYear ?? year, year)
const yearRangeMax = Math.max(calendarWindow?.maxYear ?? year, year)
const yearRangeMin = Math.min(calendarHook.data?.minYear ?? year, year)
const yearRangeMax = Math.max(calendarHook.data?.maxYear ?? year, year)
const yearOptions = Array.from(
{ length: yearRangeMax - yearRangeMin + 1 },
(_, i) => yearRangeMin + i,
@@ -158,21 +420,13 @@ export function Calendar(): ReactNode {
setCurrentMonth(new Date(newYear, month, 1))
}
// §3.6 — an explicit mode toggle (rather than a modifier-click or context
// menu) keeps day-override editing unambiguous and fully decoupled from
// the pointer-drag multi-select gesture below: modifier keys don't exist
// on touch, and a context menu needs its own a11y/keyboard path. While the
// mode is active, pointer-drag is disabled entirely so a click always
// means "toggle this day's override", never "start a range selection".
const handlePointerDown = (date: string) => {
if (isOverrideMode) return
setDragAnchor(date)
setDragCurrent(date)
setIsDragging(true)
}
const handlePointerEnter = (date: string) => {
if (isOverrideMode) return
if (isDragging) {
setDragCurrent(date)
}
@@ -186,14 +440,14 @@ export function Calendar(): ReactNode {
try {
setOverrideError(null)
if (info.isOverride) {
const existing = (dayOverrides ?? []).find((o) => o.date === date)
const existing = (dayOverridesHook.data ?? []).find((o) => o.date === date)
if (existing) {
await removeDayOverride(existing.id)
await dayOverridesHook.remove(existing.id)
}
} else {
// Toggle the effective status: a currently-free day becomes an
// override marking it a working day, and vice versa.
await upsertDayOverride({
await dayOverridesHook.upsert({
workspaceId: workspace.id,
date,
isWorkingDay: info.isFree,
@@ -205,24 +459,21 @@ export function Calendar(): ReactNode {
// explicit refetch the day-overrides list would be up to date but the
// calendar cell would keep showing the pre-mutation state until a
// full page reload.
await refetchCalendarWindow()
await calendarHook.refetch()
} catch (err) {
setOverrideError(err instanceof Error ? err.message : t('calendar.overrideError'))
}
}
const handleDayClick = (date: string) => {
if (isOverrideMode) {
void handleOverrideToggle(date)
return
}
setSelectedDates(new Set([date]))
setIsRangeMode(false)
setIsModalOpen(true)
}
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
return (plannedDays ?? []).find((pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid)
return (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
)
}
const handleSavePlannedDay = async (payload: {
@@ -239,23 +490,27 @@ export function Calendar(): ReactNode {
setLastLeaveType(payload.dayType)
for (const date of selectedDates) {
const existingDay = (plannedDays ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid
const existingDay = (plannedDaysHook.data ?? []).find(
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
)
if (existingDay) {
await update(existingDay.id, {
// §Task7 — a hand-edit through the day popover pins the day so a
// later `POST /api/plan/sync` never silently retypes it.
await plannedDaysHook.update(existingDay.id, {
day_type: payload.dayType,
is_dubbeldag: payload.isDubbeldag,
is_pinned: true,
})
} else {
await add({
await plannedDaysHook.add({
workspace_id: workspace.id,
child_id: selectedChildId,
parent_personal_data_uuid: personalDataUuid,
parent_personal_data_uuid: payload.parentId,
date,
day_type: payload.dayType,
is_dubbeldag: payload.isDubbeldag,
is_pinned: true,
})
}
}
@@ -282,7 +537,7 @@ export function Calendar(): ReactNode {
const existingDay = getExistingDay(date)
if (existingDay) {
await remove(existingDay.id)
await plannedDaysHook.remove(existingDay.id)
}
setIsModalOpen(false)
@@ -294,11 +549,191 @@ export function Calendar(): ReactNode {
}
}
// Exposed cleanly for a later task: clicking a band (a planned period,
// possibly spanning several weeks) will open a period editor. That editor
// doesn't exist yet, so this is intentionally a no-op for now.
const handleBandClick = (_info: BandClickInfo): void => {
// TODO: open a period editor for this band once it exists.
const handleBandClick = (info: BandClickInfo): void => {
setActiveBand(info)
setIsPeriodOpen(true)
setPeriodError(null)
}
const closePeriodPopover = () => {
setIsPeriodOpen(false)
setActiveBand(null)
setPeriodError(null)
}
const handleSavePeriod = async (payload: PeriodPopoverSavePayload) => {
if (!activeBand?.wishId || !workspace?.id) return
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
if (!previousWish) return
const start = splitISODate(payload.startDate)
const end = splitISODate(payload.endDate)
try {
setIsPeriodSaving(true)
setPeriodError(null)
await wishesHook.update(previousWish.id, {
name: payload.name,
nameMessageKey: null,
windowStartMonth: start.month,
windowStartDay: start.day,
windowEndMonth: end.month,
windowEndDay: end.day,
firstYear: start.year,
recurrence: payload.recurrence,
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
parentMode: payload.parentMode,
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
})
closePeriodPopover()
await runSync(workspace.id, {
watchWishId: previousWish.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.update(previousWish.id, patchFromWish(previousWish))
await runSync(workspace.id, { silent: true })
},
},
})
} catch (err) {
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
} finally {
setIsPeriodSaving(false)
}
}
const handleDeletePeriod = async () => {
if (!workspace?.id || !activeBand) return
try {
setIsPeriodSaving(true)
setPeriodError(null)
if (activeBand.wishId) {
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
await wishesHook.remove(activeBand.wishId)
closePeriodPopover()
await runSync(workspace.id, {
undo: previousWish
? {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.add(createRequestFromWish(previousWish))
await runSync(workspace.id, { silent: true })
},
}
: null,
})
} else {
// Ad-hoc band (no wishId): delete the underlying planned days
// directly — there is no wish to resync from.
const bandDates = new Set(datesInRange(activeBand.startDate, activeBand.endDate))
const toDelete = (plannedDaysHook.data ?? []).filter(
(pd) =>
pd.parentPersonalDataUuid === activeBand.parentPersonalDataUuid &&
pd.wishId === null &&
bandDates.has(pd.date),
)
const snapshot = toDelete.map((pd) => ({ ...pd }))
for (const pd of toDelete) {
await plannedDaysHook.remove(pd.id)
}
closePeriodPopover()
if (snapshot.length > 0) {
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
label: t('calendar.undoButton'),
run: async () => {
for (const pd of snapshot) {
await plannedDaysHook.add({
workspace_id: pd.workspaceId,
child_id: pd.childId,
parent_personal_data_uuid: pd.parentPersonalDataUuid,
date: pd.date,
day_type: pd.dayType,
is_dubbeldag: pd.isDubbeldag,
is_pinned: pd.isPinned,
})
}
},
})
}
}
} catch (err) {
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
} finally {
setIsPeriodSaving(false)
}
}
const handleSelectPreset = async (preset: SeasonPreset) => {
if (!workspace?.id || !selectedChildId) return
try {
setIsCreatingPeriod(true)
const created = await wishesHook.add({
workspaceId: workspace.id,
childId: selectedChildId,
name: t(preset.labelKey),
nameMessageKey: preset.labelKey,
windowStartMonth: preset.startMonth,
windowStartDay: preset.startDay,
windowEndMonth: preset.endMonth,
windowEndDay: preset.endDay,
targetWeeks: preset.defaultWeeks,
recurrence: 'yearly',
firstYear: new Date().getFullYear(),
priority: wishesHook.data.length + 1,
parentMode: 'either',
windowMode: 'flexible',
})
const result = await runSync(workspace.id, {
watchWishId: created.id,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.remove(created.id)
await runSync(workspace.id, { silent: true })
},
},
})
if (result) {
const placedDates = result.changes
.filter((c) => c.newWishId === created.id)
.map((c) => c.date)
.sort()
const earliest = placedDates[0]
if (earliest) {
const { year: placedYear, month: placedMonth } = splitISODate(earliest)
setCurrentMonth(new Date(placedYear, placedMonth - 1, 1))
setFlashWishId(created.id)
const calendarEl = document.querySelector('[data-testid="calendar"]')
if (calendarEl && typeof calendarEl.scrollIntoView === 'function') {
calendarEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
}
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null)
} finally {
setIsCreatingPeriod(false)
}
}
const handleUndoClick = async () => {
const undo = toast?.undo
if (!undo) return
try {
setIsUndoing(true)
await undo.run()
setToast(null)
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.undoError'), null)
} finally {
setIsUndoing(false)
}
}
const defaultLeaveType = getLastLeaveType() ?? 'vacation'
@@ -311,6 +746,19 @@ export function Calendar(): ReactNode {
? new Set(datesInRange(dragAnchor, dragCurrent))
: selectedDates
// §Task7 — feed every workspace member, not just the logged-in parent, so
// couple mode can plan for either parent from the calendar's popovers.
// Falls back to the logged-in parent alone while members haven't loaded
// yet (or in solo mode, where there is only one).
const parentsForModals =
membersHook.data.length > 0
? membersHook.data.map((m) => ({ id: m.uuid, name: m.name }))
: [{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]
const activeWish = activeBand?.wishId
? wishesHook.data.find((w) => w.id === activeBand.wishId) ?? null
: null
return (
<Layout>
<div data-testid="calendar" className="calendar">
@@ -353,117 +801,150 @@ export function Calendar(): ReactNode {
</button>
</div>
<div className="calendar__toolbar">
<button
type="button"
onClick={() => setIsOverrideMode((prev) => !prev)}
aria-pressed={isOverrideMode}
className={`calendar__override-toggle${isOverrideMode ? ' calendar__override-toggle--active' : ''}`}
data-testid="calendar-override-toggle"
>
{isOverrideMode ? t('calendar.overrideModeActiveLabel') : t('calendar.overrideModeLabel')}
</button>
{isOverrideMode && (
<p className="calendar__toolbar-hint" data-testid="calendar-override-hint">
{t('calendar.overrideModeHint')}
</p>
)}
{overrideError && (
<p className="calendar__toolbar-error" role="alert" data-testid="calendar-override-error">
{overrideError}
</p>
)}
</div>
{(syncHook.isSyncing || isCreatingPeriod) && (
<p className="calendar__sync-indicator" data-testid="calendar-sync-indicator">
{t('calendar.syncingLabel')}
</p>
)}
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
{showWeekNumbers && <div className="calendar__week-number-header" />}
{WEEKDAY_KEYS.map((key) => (
<div key={key} className="calendar__weekday">
{t(key)}
</div>
))}
</div>
{toast && (
<div className="calendar__toast" role="status" data-testid="calendar-toast">
<span className="calendar__toast-message">{toast.message}</span>
{toast.undo && (
<button
type="button"
className="calendar__toast-undo"
onClick={() => void handleUndoClick()}
disabled={isUndoing}
data-testid="calendar-toast-undo"
>
{toast.undo.label}
</button>
)}
<button
type="button"
className="calendar__toast-dismiss"
aria-label={t('calendar.dismissToastAria')}
onClick={() => setToast(null)}
data-testid="calendar-toast-dismiss"
>
×
</button>
</div>
)}
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
calendar.css) so a multi-day band can span several day columns as
one element — that only works if each week is a separate grid,
not a flat row of the header grid above. */}
<div className="calendar__weeks">
{weeks.map((week, weekIdx) => {
const firstDate = week[0]
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
return (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDays ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
isCurrentMonth={weekMonth === month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
/>
)
})}
</div>
<div className="calendar__layout">
<div className="calendar__main">
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
{showWeekNumbers && <div className="calendar__week-number-header" />}
{WEEKDAY_KEYS.map((key) => (
<div key={key} className="calendar__weekday">
{t(key)}
</div>
))}
</div>
<div className="calendar__legend">
<h3>{t('calendar.legendTitle')}</h3>
<div className="calendar__legend-items">
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('vacation') }} />
<span>{t('calendar.legendVacation')}</span>
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
calendar.css) so a multi-day band can span several day columns as
one element — that only works if each week is a separate grid,
not a flat row of the header grid above. */}
<div className="calendar__weeks">
{weeks.map((week, weekIdx) => {
const firstDate = week[0]
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
return (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDaysHook.data ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
isCurrentMonth={weekMonth === month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
flashWishId={flashWishId}
/>
)
})}
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
<span>{t('calendar.legendIncomeBased')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
<span>{t('calendar.legendMinimumRate')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--red-day" />
<span>{t('calendar.legendRedDay')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--klamdag" />
<span>{t('calendar.legendKlamdag')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--override" />
<span>{t('calendar.legendOverride')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--dubbeldag">2×</span>
<span>{t('calendar.legendDoubleDay')}</span>
<div className="calendar__legend">
<h3>{t('calendar.legendTitle')}</h3>
<div className="calendar__legend-items">
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('vacation') }} />
<span>{t('calendar.legendVacation')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
<span>{t('calendar.legendIncomeBased')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
<span>{t('calendar.legendMinimumRate')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--red-day" />
<span>{t('calendar.legendRedDay')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--klamdag" />
<span>{t('calendar.legendKlamdag')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--override" />
<span>{t('calendar.legendOverride')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--dubbeldag">2×</span>
<span>{t('calendar.legendDoubleDay')}</span>
</div>
</div>
</div>
</div>
<PresetRail isBusy={isCreatingPeriod || syncHook.isSyncing} onSelectPreset={handleSelectPreset} />
</div>
{firstSelectedDate && (
<PlannedDayModal
date={firstSelectedDate}
isOpen={isModalOpen}
existingDay={isRangeMode ? null : getExistingDay(firstSelectedDate) ?? null}
parents={[{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]}
existingDay={getExistingDay(firstSelectedDate) ?? null}
parents={parentsForModals}
selectedParentId={personalDataUuid ?? ''}
onSave={handleSavePlannedDay}
onDelete={!isRangeMode && getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())
}}
isSaving={isSaving}
error={modalError}
isRangeMode={isRangeMode}
dayCount={selectedDates.size}
defaultDayType={defaultLeaveType}
dayInfo={calendarDaysByDate.get(firstSelectedDate) ?? null}
onToggleOverride={() => handleOverrideToggle(firstSelectedDate)}
overrideError={overrideError}
/>
)}
{activeBand && (
<PeriodPopover
isOpen={isPeriodOpen}
wish={activeWish}
startDate={activeBand.startDate}
endDate={activeBand.endDate}
parents={parentsForModals}
isSaving={isPeriodSaving}
error={periodError}
onSave={handleSavePeriod}
onDelete={handleDeletePeriod}
onClose={closePeriodPopover}
/>
)}
</div>
+66 -23
View File
@@ -319,6 +319,21 @@
outline-offset: 1px;
}
/* §Task7 — briefly highlights the band the preset rail just placed, after
the calendar has scrolled/navigated to it. */
.calendar-band--flash {
animation: calendar-band-flash 1.4s ease-in-out;
}
@keyframes calendar-band-flash {
0%, 100% {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
30%, 70% {
box-shadow: 0 0 0 3px #ffd54f, 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
.calendar-band__label {
position: absolute;
inset: 0;
@@ -462,46 +477,74 @@
border: 2px dashed #666;
}
/* §3.6 — override-mode toolbar */
.calendar__toolbar {
/* §Task7 — the calendar becomes the plan: a side rail of presets next to
the month grid, a sync-in-progress indicator, and a "what changed" toast
with optional undo. The global "mark working/day off" toolbar mode this
used to hold was deleted — the same capability now lives in the day
popover (see PlannedDayModal's "Den här dagen är" switch). */
.calendar__layout {
display: flex;
gap: 20px;
align-items: flex-start;
}
.calendar__main {
flex: 1;
min-width: 0;
}
.calendar__sync-indicator {
font-size: 12px;
color: #666;
margin: 0 0 12px 0;
}
.calendar__toast {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
margin-bottom: 16px;
flex-wrap: wrap;
background-color: #eef6ff;
border: 1px solid #b8d9ff;
border-radius: 4px;
font-size: 13px;
color: #003a70;
}
.calendar__override-toggle {
padding: 8px 14px;
border: 1px solid #d0d0d0;
.calendar__toast-message {
flex: 1;
}
.calendar__toast-undo {
padding: 4px 10px;
border: 1px solid #0066cc;
border-radius: 4px;
background: #fff;
color: var(--c-text);
color: #0066cc;
cursor: pointer;
font-size: 13px;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
font-size: 12px;
font-weight: 600;
}
.calendar__override-toggle:hover {
background-color: #f0f0f0;
}
.calendar__override-toggle--active {
.calendar__toast-undo:hover:not(:disabled) {
background-color: #0066cc;
border-color: #0066cc;
color: #fff;
}
.calendar__toolbar-hint {
font-size: 12px;
color: #666;
margin: 0;
.calendar__toast-undo:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.calendar__toolbar-error {
font-size: 12px;
color: #b3261e;
margin: 0;
.calendar__toast-dismiss {
background: none;
border: none;
font-size: 18px;
line-height: 1;
cursor: pointer;
color: #003a70;
padding: 0 2px;
}
.calendar__grid--with-week-numbers {