Files
F-r-ldraledigplannerare/CALENDAR_MULTISELECT_HANDOVER.md
T

217 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`:
```bash
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.
```