# Design — Multi-record plan cells (max 3 per cell) **Date:** 2026-06-08 **Status:** Approved (brainstorming) — ready for implementation plan **Feature A of 2.** Feature B (bulk plan creation) is a separate later cycle — see "Deferred: Feature B" at the end. --- ## Goal Today a plan cell (one person, one day) resolves to **exactly one** record: an override hides the planned range, and overlapping range entries collapse to "newest wins". The work plan should instead let a cell hold **up to 3 records**, so a person can have several assignments on the same day (additive) _and_ still get one-day exceptions to a planned range. This is the enabling model change. The eventual "bulk assign a project to many employees over a date range" form (Feature B) will create records under this model and respect the 3-per-cell cap. ## Use cases (both required) 1. **Additive** — several projects/tasks the same day (e.g. morning project A, afternoon project B). Both records show in the cell. 2. **Day exception** — assigned to a multi-day range, but one day differs; replace just that day without splitting the range. ## Non-goals / out of scope - No bulk-create UI (Feature B). - No database migration — the tables already allow multiple rows per (user, day) (the old `work_plan_overrides` uniqueness constraint was dropped in migration `20260605122718_drop_wpo_unique_constraint`). - The max is a fixed constant (3), not user-configurable. --- ## Model — "layered" (approved) Keep the two existing tables and their meaning; stop collapsing them to one record. - **`work_plan_entries` (ranges) = the normal-plan layer.** Multiple active ranges may cover the same day → the **additive** case. - **`work_plan_overrides` (single-day) = the exception layer.** When a day has ≥1 active override, the overrides **replace** the range entries for that day (today's "override beats entry" rule, kept) → the **day-exception** case. ### Resolution rule For a given `(user, day)`: 1. Active overrides for that exact day (`is_deleted = false`). If any → the cell is the overrides, **newest-first, capped at 3**. 2. Otherwise → active entries covering that day (`date_from ≤ day ≤ date_to`, `is_deleted = false`), **newest-first, capped at 3**. 3. Otherwise → empty. The cell is therefore an array of 0–3 `ResolvedCell` items, all from a single layer (overrides **or** entries, never mixed). This preserves the "exception replaces the day" semantic while allowing additive stacking within each layer. **Known limitation (accepted):** a range entry and an exception override cannot show together on the same day — an override is all-or-nothing for that day. This matches what "exception" means. ### Cap (max 3 per cell) `MAX_RECORDS_PER_CELL = 3` (single constant in `plan.service.ts`). Enforced on create, per layer, per day: - **`createOverride`** — reject if the `(user, day)` already has 3 active overrides. - **`createEntry`** — reject if **any** day in the new `[date_from, date_to]` range is already covered by 3 active entries for that user. The error names the first offending date (Czech message). Cap counts entries regardless of whether they are currently hidden by an override (simpler and predictable). `createOverride` **changes from replace-semantics to additive-with-cap**: it no longer soft-deletes the existing override for that day. The transaction + row-lock that protected the old "at most one active override" invariant is repurposed to enforce "at most 3" under concurrency. The `replacedData` / `replacedDescription` plumbing (and the route branch that logged the replaced row's soft-delete) is removed. --- ## Backend changes ### `src/services/plan.service.ts` - `ResolvedCell` interface unchanged (still one record). - `resolveCell(userId, dateStr)` → returns `ResolvedCell[]` (0–3) using the resolution rule above. Replaces the current single-result + "newest wins" `console.warn`. - `resolveGrid(userIds, from, to)` → `cells[userId][dateStr]` becomes `ResolvedCell[]` (empty array, not `null`, for no records). Same two-query load; per (user, day) build the capped array from the right layer. - `createEntry` → add the per-day entry-cap check before insert. - `createOverride` → drop the soft-delete-replace; add the override-cap check; keep the transaction/lock for race safety; drop `replacedData`. - Add `export const MAX_RECORDS_PER_CELL = 3;`. ### `src/routes/admin/plan.ts` - `POST /plan/overrides` — remove the `result.replacedData` audit branch (no longer produced). Everything else unchanged (cap errors surface via the normal `{ error, status }` path). ### `src/routes/admin/dashboard.ts` - `today_plan` is built from `resolveCell` → now an **array**. Map each record with its category label + colour (the existing enrichment, applied per item). `result.today_plan` becomes `TodayPlan[]` (possibly empty). --- ## Frontend changes ### Types — `src/admin/lib/queries/plan.ts` - `GridData.cells: Record>` (was `ResolvedCell | null`). ### Grid — `src/admin/components/PlanGrid.tsx` + `PlanRangeChips.tsx` - A cell renders **up to 3 stacked records**, each a compact row: category colour bar + category label + project (mono). When the cell has **exactly one** record, also show its note (2-line clamp), as today. With 2–3 records, notes are omitted for density. - `--cat-color` is set per record row (not per cell). - `onCellClick(userId, date, cells)` passes the **array**. Empty cell → still a one-click create. Occupied cell → opens the day panel (below). - Past-day / read-only / weekend / today styling unchanged. ``` ┌─ Jan N. · Čt 12 ───────┐ │▌ PRÁCE · 26710001 │ │▌ DOVOLENÁ │ │▌ ŠKOLENÍ · 26710044 │ └────────────────────────┘ ▌ = category colour bar ``` ### Cell editor — `src/admin/components/PlanCellModal.tsx` New top-level **day-panel** mode shown when a cell has ≥1 record: - Lists the day's records (each: colour bar, category, project, optional note) with **edit (✎)** and **delete (🗑)** per record. - **"+ Přidat záznam"** button, disabled at 3 with a hint ("Maximum 3 záznamy na den"). Add targets the **showing layer**: an entry in normal mode, an override in exception mode — so the panel reads simply as "records for this day" and the entry/override distinction stays invisible. - Editing a record reuses the existing `EditForm`. Editing a record that belongs to a **multi-day range** still routes through the existing `day-in-range` chooser ("edit whole range" vs "carve out this day" = create override). - Empty cell skips the panel and opens the create form directly (current behaviour). `PlanWork.tsx` wires the new panel: cell-click opens it with the array; the panel's per-record actions reuse the existing entry/override create/update/delete mutations and `invalidate: ["plan"]`. ### Dashboard — `DashTodayPlan.tsx` + `Dashboard.tsx` - `today_plan` prop becomes `TodayPlan[]`. Render up to 3 records stacked (reuse the existing single-record card layout per item). Empty array → the existing "Pro dnešek nemáte naplánováno." state. --- ## Testing (`src/__tests__/plan.test.ts`) Update existing assertions for the array shape, and add: - `resolveCell` returns `[]` for an empty day; one item for a single entry; the override layer (entries hidden) when an override exists. - **Additive:** two entries covering the same day → both returned (newest first). - **Cap display:** 4 overlapping entries on a day → only 3 returned. - **Cap enforcement:** `createEntry` rejects when a day in range already has 3 entries (error names the date); `createOverride` rejects at 3 overrides. - **Additive overrides:** `createOverride` no longer soft-deletes the prior override; two overrides on a day coexist. - `resolveGrid` cell arrays match `resolveCell` for the same (user, day). Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. ## Rollout Ships as its own release (**v2.0.5**) via the standard process (commit → tag → Gitea → tarball → prod deploy → health check). No migration step. --- ## Deferred: Feature B (bulk plan creation) — decisions captured A later, separate spec + cycle. Locked decisions from this brainstorming: - **Form:** select one project, a date range (from–to), and multiple employees; create the assignment for all of them at once. - **Category:** a category picker in the bulk form, **default Práce**. - **Weekends:** an **"include weekends" checkbox** (off = Mon–Fri only, which requires splitting each person into per-work-week entries; on = one continuous range). - **Conflict handling:** governed by this feature's **max-3-per-cell** rule — for each employee/day, create the record only if the cell is under the cap; otherwise **skip** that day (no error).