Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes

Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
+164 -20
View File
@@ -1,16 +1,35 @@
import { ReactNode, useState, useEffect } from 'react'
import { ReactNode, useMemo, useState, useEffect } from 'react'
import { useWorkspace } from '@/context'
import { usePlannedDays, useShowWeekNumbers, getLastLeaveType, setLastLeaveType } from '@/hooks'
import type { PlannedLeaveDay } from '@/types'
import {
usePlannedDays,
useShowWeekNumbers,
getLastLeaveType,
setLastLeaveType,
useCalendarDays,
useDayOverrides,
} from '@/hooks'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { CalendarWeek } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { getColorForType } from '@/utils/leaveTypes'
import { datesInRange } from '@/utils/dates'
import { useTranslation } from '@/i18n'
import './calendar.css'
const WEEKDAY_KEYS = [
'calendar.weekdayMon',
'calendar.weekdayTue',
'calendar.weekdayWed',
'calendar.weekdayThu',
'calendar.weekdayFri',
'calendar.weekdaySat',
'calendar.weekdaySun',
] as const
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 [showWeekNumbers] = useShowWeekNumbers()
@@ -23,9 +42,33 @@ export function Calendar(): ReactNode {
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
// §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
// whichever 1-2 calendar years the current month view actually touches.
const viewYear = currentMonth.getFullYear()
const viewMonth = currentMonth.getMonth()
const calendarMinYear = viewMonth === 0 ? viewYear - 1 : viewYear
const calendarMaxYear = viewMonth === 11 ? viewYear + 1 : viewYear
const calendarYearsSpan = calendarMaxYear - calendarMinYear + 1
const { data: calendarWindow } = useCalendarDays(workspace?.id ?? '', calendarMinYear, calendarYearsSpan)
const { data: dayOverrides, upsert: upsertDayOverride, remove: removeDayOverride } = useDayOverrides(
workspace?.id ?? '',
)
const calendarDaysByDate = useMemo(() => {
const map = new Map<string, CalendarDayEntry>()
for (const day of calendarWindow?.days ?? []) {
map.set(day.date, day)
}
return map
}, [calendarWindow])
useEffect(() => {
const handlePointerUp = () => {
if (isDragging && dragAnchor && dragCurrent) {
@@ -49,7 +92,7 @@ export function Calendar(): ReactNode {
}, [isDragging, dragAnchor, dragCurrent])
if (isLoading) {
return <LoadingSpinner message="Loading calendar..." />
return <LoadingSpinner message={t('calendar.loading')} />
}
const year = currentMonth.getFullYear()
@@ -90,19 +133,66 @@ export function Calendar(): ReactNode {
setCurrentMonth(new Date(year, month + 1, 1))
}
const handleMonthClick = () => {
setCurrentMonth(new Date(year, month + 1, 1))
}
const handleYearClick = () => {
setCurrentMonth(new Date(year + 1, 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)
}
}
const handleOverrideToggle = async (date: string) => {
if (!workspace?.id) return
const info = calendarDaysByDate.get(date)
if (!info) return
try {
setOverrideError(null)
if (info.isOverride) {
const existing = (dayOverrides ?? []).find((o) => o.date === date)
if (existing) {
await removeDayOverride(existing.id)
}
} else {
// Toggle the effective status: a currently-free day becomes an
// override marking it a working day, and vice versa.
await upsertDayOverride({
workspaceId: workspace.id,
date,
isWorkingDay: info.isFree,
note: null,
})
}
} 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)
@@ -150,7 +240,7 @@ export function Calendar(): ReactNode {
setIsModalOpen(false)
setSelectedDates(new Set())
} catch (err) {
setModalError(err instanceof Error ? err.message : 'Failed to save planned days')
setModalError(err instanceof Error ? err.message : t('calendar.saveError'))
} finally {
setIsSaving(false)
}
@@ -175,7 +265,7 @@ export function Calendar(): ReactNode {
setIsModalOpen(false)
setSelectedDates(new Set())
} catch (err) {
setModalError(err instanceof Error ? err.message : 'Failed to delete planned day')
setModalError(err instanceof Error ? err.message : t('calendar.deleteError'))
} finally {
setIsSaving(false)
}
@@ -195,22 +285,63 @@ export function Calendar(): ReactNode {
<Layout>
<div data-testid="calendar" className="calendar">
<div className="calendar__header">
<button onClick={handlePrevMonth} aria-label="Previous month">
<button onClick={handlePrevMonth} aria-label={t('calendar.prevMonthAria')}>
←
</button>
<h2>
{currentMonth.toLocaleDateString('sv-SE', { month: 'long', year: 'numeric' })}
<h2 className="calendar__title">
<button
onClick={handleMonthClick}
className="calendar__month-button"
aria-label={t('calendar.advanceMonthAria', {
month: currentMonth.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' }),
})}
>
{currentMonth.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
month: 'long',
})}
</button>
<button
onClick={handleYearClick}
className="calendar__year-button"
aria-label={t('calendar.advanceYearAria', {
year: currentMonth.getFullYear(),
})}
>
{currentMonth.getFullYear()}
</button>
</h2>
<button onClick={handleNextMonth} aria-label="Next month">
<button onClick={handleNextMonth} aria-label={t('calendar.nextMonthAria')}>
→
</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>
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
{showWeekNumbers && <div className="calendar__week-number-header" />}
{['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map((day) => (
<div key={day} className="calendar__weekday">
{day}
{WEEKDAY_KEYS.map((key) => (
<div key={key} className="calendar__weekday">
{t(key)}
</div>
))}
@@ -223,36 +354,49 @@ export function Calendar(): ReactNode {
weekDates={week}
plannedDays={plannedDays ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? 'Du'}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
isCurrentMonth={weekMonth === month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
/>
)
})}
</div>
<div className="calendar__legend">
<h3>Legend</h3>
<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>Vacation</span>
<span>{t('calendar.legendVacation')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
<span>Sick Leave</span>
<span>{t('calendar.legendIncomeBased')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
<span>Parental</span>
<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>Double day</span>
<span>{t('calendar.legendDoubleDay')}</span>
</div>
</div>
</div>
@@ -262,7 +406,7 @@ export function Calendar(): ReactNode {
date={firstSelectedDate}
isOpen={isModalOpen}
existingDay={isRangeMode ? null : getExistingDay(firstSelectedDate) ?? null}
parents={[{ id: personalDataUuid ?? '', name: parentName ?? 'Du' }]}
parents={[{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]}
selectedParentId={personalDataUuid ?? ''}
onSave={handleSavePlannedDay}
onDelete={!isRangeMode && getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}