Redraw the calendar as an almanac: gridless cells, typed bands

Day cells lose their borders and fills; red days, klämdagar and
Saturdays are told apart by the numeral's ink with a 12px R/K mark,
today is a filled circle, overrides and exclusions mark the numeral.
Bands carry their funding type by fill, ink and hatching and their
owner by a left cap. The preset rail becomes a chip row, the legend a
help popover, the period popover's three delete buttons fold behind
one "Ta bort…", and solo users pick a type directly in the day modal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-19 19:02:02 +02:00
parent d14f86478a
commit dbef997da4
28 changed files with 1486 additions and 1109 deletions
@@ -1,6 +1,7 @@
import { ReactNode, useEffect, useState } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import './planned-day-modal.css'
/** The default path — todo.md #10/#11: the planner decides which day type
@@ -18,11 +19,22 @@ export interface PlannedDayModalSaveWishPayload {
* an exact day type for an exact parent, writing `planned_leave_day` rows
* directly. */
export interface PlannedDayModalSavePinnedPayload {
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
dayType: PinnedDayType
parentId: string
isDubbeldag: boolean
}
type PinnedDayType = 'sjukpenning' | 'lagsta' | 'vacation'
/** What the type select can hold: a concrete type to pin, or — for a solo
* household, where the select is the only question left — `'auto'`, the
* planner-decides default. */
type DayTypeChoice = PinnedDayType | 'auto'
function isPinnedDayType(value: string): value is PinnedDayType {
return value === 'sjukpenning' || value === 'lagsta' || value === 'vacation'
}
/** One day the user removed by hand, ready to be offered back. */
export interface PlannedDayExclusionEntry {
id: string
@@ -83,8 +95,11 @@ export function PlannedDayModal({
// Editing an existing pinned day always reopens the advanced/pinned path —
// it must never be silently reinterpreted as a new planner-funded wish.
const forcedAdvanced = !isRangeMode && !!existingDay
// A solo household has nobody to choose between, so the "Advanced"
// disclosure would hide exactly one control. The type select is shown
// directly instead, with the planner-decides choice as its default.
const soloDirect = !hasMultipleParents && !forcedAdvanced
const [isAdvancedOpen, setIsAdvancedOpen] = useState(forcedAdvanced)
const advancedOpen = forcedAdvanced || isAdvancedOpen
// --- Default ("planner decides") path state -----------------------------
const [parentMode, setParentMode] = useState<'either' | 'both' | 'specific'>(
@@ -94,9 +109,15 @@ export function PlannedDayModal({
const [wantsDubbeldag, setWantsDubbeldag] = useState(false)
// --- Advanced ("pin exactly") path state ---------------------------------
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
const [dayType, setDayType] = useState<DayTypeChoice>(soloDirect ? 'auto' : 'vacation')
const [isDubbeldag, setIsDubbeldag] = useState(false)
// Whether saving pins an exact type (the escape hatch) or hands the dates
// to the planner as a wish. Couples opt in through the disclosure; a solo
// household opts in by picking a concrete type in the select.
const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen)
const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen
const [localError, setLocalError] = useState<string | null>(null)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
@@ -115,11 +136,13 @@ export function PlannedDayModal({
}, [isOpen, date, isRangeMode])
useEffect(() => {
if (existingDay) {
setDayType(existingDay.dayType as 'sjukpenning' | 'lagsta' | 'vacation')
// A range is never an edit of its first date's day, so that day's type
// must not leak into the select as if it were being edited.
if (existingDay && !isRangeMode) {
setDayType(isPinnedDayType(existingDay.dayType) ? existingDay.dayType : 'vacation')
setIsDubbeldag(existingDay.isDubbeldag)
} else {
setDayType(defaultDayType ?? 'vacation')
setDayType(soloDirect ? 'auto' : (defaultDayType ?? 'vacation'))
setIsDubbeldag(false)
}
// Deliberately NOT a separate "if dayType === 'vacation', clear
@@ -131,7 +154,7 @@ export function PlannedDayModal({
// vacation/dubbeldag rule is instead enforced right where the user
// actually changes the type — see the advanced day-type `<select>`'s
// `onChange` below.
}, [isOpen, existingDay, defaultDayType])
}, [isOpen, existingDay, defaultDayType, soloDirect, isRangeMode])
useEffect(() => {
if (parentMode !== 'both') {
@@ -142,7 +165,7 @@ export function PlannedDayModal({
const handleSave = async () => {
try {
setLocalError(null)
if (advancedOpen) {
if (pinnedPath && dayType !== 'auto') {
await onSavePinned({ dayType, parentId: selectedParentId, isDubbeldag })
} else {
await onSaveWish({
@@ -217,7 +240,7 @@ export function PlannedDayModal({
<div className="modal__header">
<h2 data-testid="modal-title">{modalTitle}</h2>
<button type="button" className="btn btn--icon" onClick={onClose} aria-label={t('plannedDayModal.closeAria')}>
×
<Icon name="close" size={20} />
</button>
</div>
@@ -297,10 +320,10 @@ export function PlannedDayModal({
</div>
)}
{!forcedAdvanced && (
{!forcedAdvanced && hasMultipleParents && (
<button
type="button"
className="btn btn--ghost planned-day-modal__advanced-toggle"
className="btn btn--link planned-day-modal__advanced-toggle"
onClick={() => setIsAdvancedOpen((open) => !open)}
disabled={isSaving}
data-testid="advanced-toggle"
@@ -310,7 +333,7 @@ export function PlannedDayModal({
</button>
)}
{advancedOpen && (
{showPinnedControls && (
<div className="planned-day-modal__advanced" data-testid="advanced-section">
<div className="field">
<label htmlFor="day-type" className="field__label">
@@ -320,14 +343,16 @@ export function PlannedDayModal({
id="day-type"
value={dayType}
onChange={(e) => {
const value = e.target.value as 'sjukpenning' | 'lagsta' | 'vacation'
setDayType(value)
if (value === 'vacation') setIsDubbeldag(false)
const value = e.target.value
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
setDayType(next)
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
}}
className="field__control"
disabled={isSaving}
data-testid="day-type-select"
>
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
@@ -356,27 +381,30 @@ export function PlannedDayModal({
</div>
)}
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
{dayType !== 'auto' && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
)}
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
📌 {t('plannedDayModal.pinnedNotice')}
<Icon name="pin" size={16} />
<span>{t('plannedDayModal.pinnedNotice')}</span>
</p>
)}
</div>