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:
+164
-20
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user