# 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<number, Record<string, ResolvedCell[]>>` (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).
