From 628cd54a812a6316cd606a0e4af0c1ef97c77f24 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 10:22:15 +0200 Subject: [PATCH] docs(plan): spec for multi-record plan cells (max 3 per cell) Feature A of 2: lets a plan cell hold up to 3 records (additive assignments + day exceptions) via the layered entries/overrides model, no migration. resolveCell/resolveGrid return arrays; cap enforced per layer on create; grid shows up to 3 stacked; cell editor becomes a day panel; dashboard today-card shows up to 3. Feature B (bulk create) decisions captured as deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-08-multi-record-plan-cells-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md diff --git a/docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md b/docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md new file mode 100644 index 0000000..198be4b --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md @@ -0,0 +1,208 @@ +# 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).