Files
app/docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md
BOHA 628cd54a81 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) <noreply@anthropic.com>
2026-06-08 10:22:15 +02:00

8.8 KiB
Raw Blame History

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 03 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[] (03) 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 23 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 (fromto), 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 = MonFri 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).