18 KiB
Handover — Calendar multi-select, spanning bars & week numbers
Audience: junior frontend developer picking up this feature.
Area: frontend/ only (TypeScript + React + Vite). No backend changes are required.
Status: spec / not started. No production code has been written yet — this document is the brief.
Read AGENTS.md at the repo root first. The guardrails there (no any, no .js/.jsx, components don't call fetch directly, ESLint must pass with zero warnings, frontend coverage gate is 90%) all apply to this work.
1. What we're building
Five related improvements to the planning calendar (/calendar):
- Drag to select multiple days, then apply a leave type to all of them at once.
- Remember the last-used leave type so entering several days in a row is faster (session-only cache is fine).
- "Dubbeldag" rules + clearer display: a double day is only valid when the leave type is not vacation, and the fact that a day is a dubbeldag must be visible in the calendar (today it's invisible).
- Replace the colored dots with labeled bars that can span multiple days and say what the leave is and which parent is taking it (Google/Apple-Calendar style — see the screenshot the product owner attached).
- Settings toggle "Visa veckonummer" to show ISO week numbers in the calendar (Swedes plan in week numbers).
Plus a small CSS polish pass on the Legend (it currently renders with overlapping text — see screenshot).
2. Decisions already made (do not re-litigate)
These were confirmed with the product owner — build to them:
| Question | Decision |
|---|---|
| Bar rendering style | True spanning bars (continuous bar across the days, label shown once) — not a repeated chip per cell. |
| Applying a type to a dragged range that contains existing entries | Overwrite every day in the range for the current parent. |
| Whose name shows on a bar | The logged-in parent's name only. The app currently only knows the current parent (parentName / personalDataUuid from WorkspaceContext). Showing/where-to-switch the other parent is an explicit future follow-up — don't block on it. A generic "Partner" fallback for any other UUID is acceptable. |
| Where the week-number toggle lives | Settings page, persisted client-side (localStorage). It's a pure display preference; no backend/migration needed. |
⚠️ The attached screenshot is an Apple Calendar screenshot used only as a visual reference for the bar style (continuous, labeled, rounded bars, one per row). The labels in it ("Gotland", "Pappa reser", etc.) are that person's private events — do not try to reproduce those texts. Our bar labels come from the leave type + parent (see §5.D).
3. Orientation — the code as it is today
All paths relative to frontend/.
Page
src/pages/Calendar.tsx— builds the month grid as a flat list ofweeks: string[][](each entry an ISO date string) and renders one<CalendarDay>per date in a single CSS grid (.calendar__grid, 7 columns). It ownsselectedDate+ modal state and the single-day save/update/delete handlers. Note the localgetColorForType()at the bottom — you'll want to share this (see §5).src/pages/calendar.css— all calendar styling, including the dots (.calendar-day__event, 8×8 circles) and the.calendar__legend*rules that need the CSS polish.
Components
src/components/common/CalendarDay.tsx— one day cell. Props:date,events,isCurrentMonth,onClick,isToday. Renders the day number and, ifeventsare passed, the colored dots. Handles click + Enter/Space →onClick. Has tests inCalendarDay.test.tsx.src/components/common/CalendarEvent.tsx— the small colored chip currently used only in the legend. Props:leaveType,color,title,onDismiss. Has tests inCalendarEvent.test.tsx.src/components/modals/PlannedDayModal.tsx— the create/edit/delete modal for a single day. Already has adayTypeselect (vacation / sjukpenning / lagsta), an optional parent select (only shown whenparents.length > 1), and anisDubbeldagcheckbox.onSavepayload is{ dayType, parentId, isDubbeldag }.
Data layer (don't bypass it)
src/hooks/usePlannedDays.ts—{ data, isLoading, error, refresh, add, update, remove }.add/update/removealready call the API and thenrefresh().src/services/api.ts—getPlannedDays,createPlannedDay,updatePlannedDay,deletePlannedDay. Payloads are snake_case (CreatePlannedDayPayload:workspace_id,child_id,parent_personal_data_uuid,date,day_type,is_dubbeldag;UpdatePlannedDayPayload:day_type?,is_dubbeldag?). Responses are mapped to camelCasePlannedLeaveDaybytoPlannedLeaveDay.src/types/entities.ts→PlannedLeaveDay:{ id, workspaceId, childId, parentPersonalDataUuid, date, dayType, isDubbeldag, createdAt, updatedAt }.dayTypeis'sjukpenning' | 'lagsta' | 'vacation'.
Context
src/context/WorkspaceContext.tsx(useWorkspace) exposespersonalDataUuidandparentNamefor the current parent only. There is no partner name available yet.
Settings
src/pages/Settings.tsx— sectioned page. Most settings save to the backend viaapi.*. The week-number toggle is the exception: keep it client-side (localStorage).src/hooks/index.ts— barrel; export any new hook here so imports stay@/hooks.src/utils/dates.ts— date helpers (formatDateForInput,parseInputDate,isValidDate, etc.). Add the ISO-week + range helpers here.
4. Suggested file plan
| File | Action | Why |
|---|---|---|
src/utils/leaveTypes.ts |
new | Single source of truth for getColorForType + a label per type. Removes the duplicated color map from Calendar.tsx. |
src/utils/dates.ts |
edit | Add getISOWeekNumber() and datesInRange(a, b). |
src/hooks/useCalendarPreferences.ts |
new | useShowWeekNumbers() (localStorage, reactive) + getLastLeaveType()/setLastLeaveType() (sessionStorage). Export via hooks/index.ts. |
src/components/common/CalendarWeek.tsx |
new | Renders one week row: optional week-number gutter, 7 CalendarDay cells, and the spanning bar overlay (run-merge + lane layout). |
src/components/common/CalendarDay.tsx |
edit | Add selection (isSelected) and pointer handlers for drag; keep onClick working for back-compat/keyboard. Stop relying on dots in the new layout (pass no events). |
src/components/common/CalendarEvent.tsx |
edit | Add an optional dubbeldag badge + parent label; keep existing props/tests passing. |
src/components/modals/PlannedDayModal.tsx |
edit | Dubbeldag rule vs vacation; optional range mode (label + count, no delete) and a defaultDayType seed. |
src/pages/Calendar.tsx |
edit | Drag state + handlers, render via CalendarWeek, bulk apply, last-type cache, read week-number pref. |
src/pages/calendar.css |
edit | Bars, dubbeldag styling, selection highlight, week-number gutter, legend polish. |
src/pages/Settings.tsx |
edit | New "Kalender" section with the "Visa veckonummer" checkbox. |
| Tests | new/edit | See §6. |
Don't forget: after code changes run graphify update . (AGENTS.md).
5. Feature-by-feature guidance
A. Drag to select multiple days + bulk apply
Drive selection from pointer events on the day cells, not onClick, so a drag and a click don't both fire.
State to add in Calendar.tsx:
dragAnchor: string | null,dragCurrent: string | null,isDragging: boolean.
Handlers passed down to each CalendarDay:
onPointerDownDay(date)→ setdragAnchor = dragCurrent = date,isDragging = true.onPointerEnterDay(date)→ ifisDragging, setdragCurrent = date.- A
pointeruplistener onwindow(in auseEffect) finalizes the selection.
On pointer-up, compute the selected dates with datesInRange(dragAnchor, dragCurrent) (inclusive, ascending, order-independent), then:
- 1 day → open the existing single-day flow (
PlannedDayModalwith edit/delete). - >1 day → open the modal in range mode (§5.C/§D below): no
existingDay, no delete, header like "Plan N days".
Keep the selected dates highlighted (isSelected) until the modal closes.
Bulk save = loop over the selected dates and call add/update per day. Overwrite semantics (decision in §2): for each date, if the current parent already has an entry, update it; otherwise add. There is no bulk endpoint — a client-side loop is fine for now (note the N-requests trade-off in your PR; a /planned-days/bulk endpoint is a reasonable future optimization but out of scope). After the loop, usePlannedDays already refreshes on each call; consider whether to refresh once at the end instead to avoid flless re-fetches (acceptable either way for MVP).
Accessibility: keyboard users can't drag. Keep single-day open via keyboard (Enter/Space on a focused cell). Add an onActivate prop used by the key handler if you stop passing onClick in the new layout, so CalendarDay's existing tests keep passing.
B. Remember the last-used leave type
In useCalendarPreferences.ts, back this with sessionStorage (per the product ask, session-only is fine):
getLastLeaveType(): 'sjukpenning' | 'lagsta' | 'vacation' | null(validate the stored value).setLastLeaveType(type).
Wire-up: when the modal opens for a new day (no existingDay), seed its dayType from getLastLeaveType() (fall back to 'vacation'). On a successful save, call setLastLeaveType(savedType). Don't override the type when editing an existing day — that should reflect the stored entry.
C. Dubbeldag only valid when type ≠ vacation
In PlannedDayModal.tsx:
- When
dayType === 'vacation', forceisDubbeldag = false, disable the checkbox, and show a short hint (e.g. "Dubbeldagar gäller inte för semester"). Use auseEffectondayTypeso switching to vacation clears a previously-checked box. - Domain note from
AGENTS.md: a dubbeldag consumes each parent's balance and only applies early in the child's life — we are not enforcing the early-life window here, just the "not with vacation" rule. Keep it to that.
D. Spanning, labeled bars (the big one)
Replace the dots. Render bars per week row, so introduce CalendarWeek.
Why per-week: a bar can only span contiguous columns within a single week row; a stretch that crosses a week boundary becomes two bars (one ending Sunday, one starting Monday) — same as Google/Apple Calendar.
Building the bars for one week (7 ISO dates):
- For each day, get its planned entries (filter
plannedDaysbydate). - Group entries into "series" by a stable key:
`${parentPersonalDataUuid}|${dayType}|${isDubbeldag}`. - For each series, scan the 7 days left→right and merge consecutive days that contain that series into a bar
{ startCol, endCol, parentUuid, dayType, isDubbeldag }. - Lane assignment (so overlapping bars stack instead of colliding): sort bars by
startColasc then width desc; greedily place each bar in the first lane (row) whose columns[startCol..endCol]are free.
Rendering: give CalendarWeek a position: relative container with two layers sharing the same 7-column track and column-gap: 0 (so bars are visually continuous across day boundaries — use cell borders, not grid gaps, for separation):
- the day layer (7
CalendarDaycells), and - an absolutely-positioned bar overlay (
pointer-events: noneso clicks fall through to the day cell beneath, which opens the edit modal). Position each bar withgrid-column: ${startCol + 1} / ${endCol + 2}andgrid-row: ${lane + 1}.
Make the week's min-height grow with the lane count (e.g. numberHeight + lanes * barHeight + padding) so tall weeks don't clip. Many-lane weeks are an edge case — clipping with a small "+N more" affordance is an acceptable v1 if you run low on time; note it if so.
Bar contents: "{parentLabel} · {typeLabel}", where typeLabel comes from leaveTypes.ts and parentLabel is parentName (or "Du") for personalDataUuid, else "Partner". If isDubbeldag, render a small "2×" badge on the bar (this is the "show dubbeldag better" requirement). You can reuse CalendarEvent as the bar's inner visual by adding optional isDubbeldag + parentLabel props.
E. Legend CSS polish
The screenshot shows the legend swatches overlapping their labels ("VacaSiok Parental"). In calendar.css, the .calendar__legend-item currently relies on a CalendarEvent shrunk with !important. Rebuild each legend item as a clean swatch + label row (small square/rounded swatch, gap, readable label), aligned in a wrapping flex row, with a real section heading. Keep the three types: Vacation / Sick / Parental, using the shared colors. Add a swatch variant indicating a dubbeldag (the "2×" badge) so the legend explains it.
F. "Visa veckonummer" setting + week-number gutter
useShowWeekNumbers()inuseCalendarPreferences.ts: localStorage-backed boolean, reactive within the tab (dispatch a custom event on set and listen for it + the nativestorageevent), so toggling in Settings updates an open Calendar.- Settings: add a "Kalender" section with a checkbox bound to the hook. Swedish label "Visa veckonummer".
- Calendar: when enabled, render a left gutter in each
CalendarWeekshowinggetISOWeekNumber(firstDateOfWeek)(e.g. "v.27"). Add a matching empty/heading cell above the weekday header row so columns stay aligned. getISOWeekNumber: ISO-8601, Monday-first, week 1 = the week containing the year's first Thursday. (Pseudocode: shift the date to its Thursday, then count weeks from the first Thursday of that year. Test the known tricky boundaries — e.g. 2026-01-01 is week 1, 2025-12-29 is week 1 of 2026.)
6. Tests & CI gates (must pass before PR)
From frontend/:
npx tsc --noEmit # no type errors (no `any`, no ts-ignore)
npm run lint # ESLint, zero warnings
npm run coverage # Vitest, 90% coverage gate
Test additions/updates to plan for:
utils/dates—getISOWeekNumber(incl. year boundaries) anddatesInRange(forward, reversed, single day, invalid input).utils/leaveTypes— color + label lookups, unknown fallback.useCalendarPreferences— persists + reads week-number flag; reactive update; last-leave-type get/set + invalid value rejection. (jsdom provideslocalStorage/sessionStorage.)CalendarWeek— run-merging (consecutive same-series days become one bar; a gap splits them), lane stacking for overlapping parents, week-number gutter shown/hidden, dubbeldag badge present.CalendarDay— selection class + pointer handlers; keep the existing dot/onClick/keyboard tests green.CalendarEvent— new dubbeldag badge / parent label; keep existing tests green.PlannedDayModal— dubbeldag disabled + forced false on vacation; range-mode header/no-delete; default type seeding.Calendar— drag across cells builds a range and opens range mode; single click/Enter opens single-day; bulk apply callsadd/updateper day with overwrite.
Existing tests you must not break: Calendar.test.tsx, CalendarDay.test.tsx, CalendarEvent.test.tsx. If you change a component's contract, update its test properly (never delete a failing test to go green — see AGENTS.md).
7. Edge cases & gotchas
- Click vs drag double-fire: the #1 trap. Don't keep both
onClickand pointer-based selection firing the open. Decide which path opens the modal and stub the other. - Range crossing week boundary: must split into two bars (handled naturally by per-week rendering).
- Same day, both parents: two series on one day → two stacked lanes. Make lane assignment deterministic (stable key sort) so bars don't jump around between renders.
- Days outside the current month (
isCurrentMonth === false): keep them non-interactive for selection, as today. - Overwrite in range can fire many requests; if the UX feels janky, batch the
refresh()to the end of the loop. - Continuous bars need
column-gap: 0on the grid; reintroduce visual separation with cell borders so the bars don't get chopped by the gap. - Week-number reactivity: the native
storageevent only fires in other tabs — use a custom in-tab event so Settings ↔ Calendar sync live. - Don't bypass layers: go through
usePlannedDays/api.ts; remember API payloads are snake_case.
8. Known limitations / future follow-ups (call out in your PR)
- Partner identity: bars label non-current parents as "Partner" because the app only knows the current parent. A future ticket should expose partner name(s) and let the user pick which parent a day/range is for in the modal.
- No bulk API: range apply loops client-side. A
/planned-days/bulkendpoint would cut N requests to 1. - Many overlapping lanes may need a "+N more" overflow design if real usage stacks lots of same-day entries.
- Dubbeldag window not enforced (only the "not with vacation" rule) — encoding the early-life day limit is a separate domain task; per AGENTS.md, confirm the exact rule/number before hardcoding it.
9. Definition of done
- Drag across days selects a range; releasing opens a bulk-apply modal; applying overwrites every day in the range for the current parent.
- Single click / Enter still opens the single-day edit/delete modal.
- New-day modal pre-selects the last-used leave type (session); editing shows the stored type.
- Dubbeldag checkbox is disabled + cleared when type is vacation; dubbeldag is clearly shown on the calendar (2× badge) and explained in the legend.
- Dots replaced by continuous, labeled bars showing leave type + parent, spanning consecutive days and splitting at week boundaries.
- Legend is clean and readable (no overlapping text).
- Settings has a "Visa veckonummer" toggle; when on, the calendar shows ISO week numbers, and toggling updates an open calendar live.
tsc --noEmit,npm run lint(zero warnings), andnpm run coverage(≥90%) all pass; existing calendar tests still green.graphify update .run after changes.