Files
F-r-ldraledigplannerare/CALENDAR_MULTISELECT_HANDOVER.md
T

18 KiB
Raw Blame History

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):

  1. Drag to select multiple days, then apply a leave type to all of them at once.
  2. Remember the last-used leave type so entering several days in a row is faster (session-only cache is fine).
  3. "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).
  4. 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).
  5. 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 of weeks: string[][] (each entry an ISO date string) and renders one <CalendarDay> per date in a single CSS grid (.calendar__grid, 7 columns). It owns selectedDate + modal state and the single-day save/update/delete handlers. Note the local getColorForType() 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, if events are passed, the colored dots. Handles click + Enter/Space → onClick. Has tests in CalendarDay.test.tsx.
  • src/components/common/CalendarEvent.tsx — the small colored chip currently used only in the legend. Props: leaveType, color, title, onDismiss. Has tests in CalendarEvent.test.tsx.
  • src/components/modals/PlannedDayModal.tsx — the create/edit/delete modal for a single day. Already has a dayType select (vacation / sjukpenning / lagsta), an optional parent select (only shown when parents.length > 1), and an isDubbeldag checkbox. onSave payload is { dayType, parentId, isDubbeldag }.

Data layer (don't bypass it)

  • src/hooks/usePlannedDays.ts — { data, isLoading, error, refresh, add, update, remove }. add/update/remove already call the API and then refresh().
  • 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 camelCase PlannedLeaveDay by toPlannedLeaveDay.
  • src/types/entities.ts → PlannedLeaveDay: { id, workspaceId, childId, parentPersonalDataUuid, date, dayType, isDubbeldag, createdAt, updatedAt }. dayType is 'sjukpenning' | 'lagsta' | 'vacation'.

Context

  • src/context/WorkspaceContext.tsx (useWorkspace) exposes personalDataUuid and parentName for the current parent only. There is no partner name available yet.

Settings

  • src/pages/Settings.tsx — sectioned page. Most settings save to the backend via api.*. 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) → set dragAnchor = dragCurrent = date, isDragging = true.
  • onPointerEnterDay(date) → if isDragging, set dragCurrent = date.
  • A pointerup listener on window (in a useEffect) 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 (PlannedDayModal with 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', force isDubbeldag = false, disable the checkbox, and show a short hint (e.g. "Dubbeldagar gäller inte för semester"). Use a useEffect on dayType so 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):

  1. For each day, get its planned entries (filter plannedDays by date).
  2. Group entries into "series" by a stable key: `${parentPersonalDataUuid}|${dayType}|${isDubbeldag}`.
  3. 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 }.
  4. Lane assignment (so overlapping bars stack instead of colliding): sort bars by startCol asc 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 CalendarDay cells), and
  • an absolutely-positioned bar overlay (pointer-events: none so clicks fall through to the day cell beneath, which opens the edit modal). Position each bar with grid-column: ${startCol + 1} / ${endCol + 2} and grid-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() in useCalendarPreferences.ts: localStorage-backed boolean, reactive within the tab (dispatch a custom event on set and listen for it + the native storage event), 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 CalendarWeek showing getISOWeekNumber(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) and datesInRange (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 provides localStorage/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 calls add/update per 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 onClick and 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: 0 on the grid; reintroduce visual separation with cell borders so the bars don't get chopped by the gap.
  • Week-number reactivity: the native storage event 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/bulk endpoint 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), and npm run coverage (≥90%) all pass; existing calendar tests still green.
  • graphify update . run after changes.