# Multi-record Plan Cells Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let a work-plan cell (one person, one day) hold up to 3 records — additive assignments plus one-day exceptions — instead of collapsing to a single record. **Architecture:** No DB migration. The two existing tables stay: `work_plan_entries` (ranges = additive layer) and `work_plan_overrides` (single-day = exception layer that replaces a range for that day). `resolveCell`/`resolveGrid` change from returning one record to returning an array of 0–3 (overrides-for-the-day if any, else entries-covering-the-day, newest-first, capped at 3). A `MAX_RECORDS_PER_CELL = 3` constant is enforced per layer on create. The grid renders the records stacked; clicking an occupied cell opens a "day panel" that lists records with per-record edit/delete + add. The dashboard "today" card shows up to 3. **Tech Stack:** Fastify 5 + Prisma (MySQL), React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB). Spec: `docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md`. **Gates (run after every task that changes code):** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. The frontend has **no component-test harness** (server-side tests only, per CLAUDE.md), so frontend-only tasks are gated by `tsc -b` + `build` + a manual Chrome check rather than a failing-test-first step. --- ## File Structure **Backend** - `src/services/plan.service.ts` — add `MAX_RECORDS_PER_CELL`; `resolveCell`/`resolveGrid` → arrays; cap in `createEntry`; `createOverride` additive-with-cap; trim `Result` type. - `src/routes/admin/plan.ts` — drop the `replacedData` audit branch in `POST /overrides`. - `src/routes/admin/dashboard.ts` — `today_plan` becomes an array. **Frontend** - `src/admin/lib/queries/plan.ts` — `GridData.cells` value type → `ResolvedCell[]`. - `src/admin/hooks/usePlanWork.ts` — optimistic patch helpers + mutation handlers + rollback + `getCell`/`getCells` work on arrays. - `src/admin/components/PlanGrid.tsx` — render up to 3 stacked records; `onCellClick` passes the array. - `src/admin/components/PlanRangeChips.tsx` — `showNote` prop (note only when single record). - `src/admin/components/PlanCellModal.tsx` — new `day` panel mode. - `src/admin/pages/PlanWork.tsx` — cell-click opens the day panel; mutation wrappers scan arrays. - `src/admin/components/dashboard/DashTodayPlan.tsx` + `src/admin/pages/Dashboard.tsx` — render up to 3. **Tests** - `src/__tests__/plan.test.ts` — update for the array shape; add cap + additive tests. --- ## Task 1: Backend — cap enforcement + additive overrides **Files:** - Modify: `src/services/plan.service.ts` - Modify: `src/routes/admin/plan.ts:316-354` (`POST /overrides`) - Test: `src/__tests__/plan.test.ts` This task does **not** change return shapes (resolve still returns a single record), so the frontend is untouched and keeps working. It only changes write-side rules: entries get a per-day cap, overrides become additive-with-cap (no more replace). - [ ] **Step 1: Update the two `createOverride` tests + add cap tests (write the new expectations first)** In `src/__tests__/plan.test.ts`, **replace** the test `"creates an override and returns { data, oldData: null, replacedData: null }"` (lines ~442-459) with this version (drops the `replacedData` assertion): ```ts it("creates an override and returns { data, oldData: null }", async () => { const result = await createOverride( { user_id: adminUserId, shift_date: "2099-10-01", category: "leave", note: `${N}day off`, }, adminUserId, false, ); expect("data" in result).toBe(true); if ("data" in result) { expect(result.data.note).toBe(`${N}day off`); expect(result.oldData).toBeNull(); } }); ``` **Replace** the test `"soft-deletes the existing override and reports it in replacedData"` (lines ~461-493) with this additive + cap version: ```ts it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => { for (let i = 0; i < 3; i++) { const r = await createOverride( { user_id: adminUserId, shift_date: "2099-10-02", category: "leave", note: `${N}o${i}`, }, adminUserId, false, ); expect("data" in r).toBe(true); } // A 4th record on the same day is rejected by the cap. const fourth = await createOverride( { user_id: adminUserId, shift_date: "2099-10-02", category: "leave", note: `${N}o3`, }, adminUserId, false, ); expect("error" in fourth).toBe(true); if ("error" in fourth) expect(fourth.status).toBe(400); // All three earlier overrides remain active — no replace happened. const active = await prisma.work_plan_overrides.findMany({ where: { user_id: adminUserId, shift_date: new Date("2099-10-02"), is_deleted: false, }, }); expect(active.length).toBe(3); }); ``` Add this test to the `describe("plan.service.createEntry", ...)` block (after the existing tests, before its closing `});`): ```ts it("rejects a 4th entry covering a day already at the cap", async () => { for (let i = 0; i < 3; i++) { const r = await createEntry( { user_id: adminUserId, date_from: "2099-07-20", date_to: "2099-07-20", category: "work", note: `${N}e${i}`, }, adminUserId, false, ); expect("data" in r).toBe(true); } const fourth = await createEntry( { user_id: adminUserId, date_from: "2099-07-20", date_to: "2099-07-20", category: "work", note: `${N}e3`, }, adminUserId, false, ); expect("error" in fourth).toBe(true); if ("error" in fourth) expect(fourth.status).toBe(400); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `npx vitest run src/__tests__/plan.test.ts` Expected: FAIL — the additive/cap tests fail (createOverride still replaces; createEntry has no cap), and `replacedData` is no longer asserted but the old replace test is gone. - [ ] **Step 3: Add the constant + entry-cap helper in `plan.service.ts`** Near the top of `src/services/plan.service.ts`, after the `toDateOnly` helper (around line 402), add: ```ts /** Maximum number of records (entries OR overrides) shown per cell per day. */ export const MAX_RECORDS_PER_CELL = 3; /** * Returns { error, status } if creating an entry over [dateFromStr, dateToStr] * would push any single day past MAX_RECORDS_PER_CELL active entries for the * user. Names the first offending date. Counts entries regardless of whether an * override currently hides them (simpler and predictable). */ async function assertEntryCapAvailable( userId: number, dateFromStr: string, dateToStr: string, ): Promise<{ error: string; status: number } | null> { const from = toDateOnly(dateFromStr); const to = toDateOnly(dateToStr); const existing = await prisma.work_plan_entries.findMany({ where: { user_id: userId, is_deleted: false, date_from: { lte: to }, date_to: { gte: from }, }, select: { date_from: true, date_to: true }, }); for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) { const day = new Date(d); const count = existing.filter( (e) => e.date_from <= day && e.date_to >= day, ).length; if (count >= MAX_RECORDS_PER_CELL) { return { error: `Na den ${day.toISOString().slice(0, 10)} jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`, status: 400, }; } } return null; } ``` - [ ] **Step 4: Call the entry-cap helper in `createEntry`** In `createEntry` (around line 442), insert the cap check immediately after the existing `assertActiveCategory` check and before `prisma.work_plan_entries.create`: ```ts const catErr = await assertActiveCategory(input.category); if (catErr) return catErr; const capErr = await assertEntryCapAvailable( input.user_id, input.date_from, input.date_to, ); if (capErr) return capErr; ``` - [ ] **Step 5: Make `createOverride` additive-with-cap (drop replace)** Replace the body of `createOverride` from the `const date = toDateOnly(...)` line through the `return { ... replacedData ... }` (lines ~613-681) with: ```ts const date = toDateOnly(input.shift_date); // Additive up to MAX_RECORDS_PER_CELL. (This previously REPLACED the existing // override; multi-record cells stack instead.) count+create runs in one // transaction; a concurrent create could in theory add one extra row, but // resolve display caps at MAX_RECORDS_PER_CELL so that is benign. let created; try { created = await prisma.$transaction(async (tx) => { const count = await tx.work_plan_overrides.count({ where: { user_id: input.user_id, shift_date: date, is_deleted: false }, }); if (count >= MAX_RECORDS_PER_CELL) { throw Object.assign(new Error("cap"), { __cap: true }); } return tx.work_plan_overrides.create({ data: { user_id: input.user_id, shift_date: date, project_id: input.project_id ?? null, category: input.category, note: input.note, created_by: actorUserId, }, }); }); } catch (e) { if (e && typeof e === "object" && "__cap" in e) { return { error: `Na tento den jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`, status: 400, }; } throw e; } const { userName, projectName } = await resolvePlanLabels( created.user_id, created.project_id, ); const description = buildPlanAuditDescription({ userName, categoryLabel: await resolveCategoryLabel(created.category), projectName, dateFrom: input.shift_date, dateTo: input.shift_date, force, }); return { data: created, oldData: null, description }; ``` - [ ] **Step 6: Trim the `Result` type (remove replaced\* fields)** In `plan.service.ts`, change the `Result` success branch (around lines 387-397) to drop `replacedData` and `replacedDescription`: ```ts export type Result = | { data: T; oldData: unknown | null; /** Human-readable Czech subject for the audit-log row. */ description?: string; } | { error: string; status: number }; ``` Update its doc-comment above to remove the paragraph describing `replacedData`. - [ ] **Step 7: Remove the `replacedData` audit branch in the override route** In `src/routes/admin/plan.ts`, inside `POST /plan/overrides` (lines ~330-342), delete the entire `if (result.replacedData) { ... }` block. The handler keeps only the single create audit-log call that follows. - [ ] **Step 8: Run the tests to verify they pass** Run: `npx vitest run src/__tests__/plan.test.ts` Expected: PASS (all plan tests, including the new additive + cap tests). - [ ] **Step 9: Gate + commit** ```bash npx tsc -b --noEmit npx vitest run git add src/services/plan.service.ts src/routes/admin/plan.ts src/__tests__/plan.test.ts git commit -m "feat(plan): per-cell record cap (3) + additive overrides" ``` --- ## Task 2: Backend resolve → arrays + coordinated frontend shape change **Files:** - Modify: `src/services/plan.service.ts` (`resolveCell`, `resolveGrid`) - Modify: `src/routes/admin/dashboard.ts` - Modify: `src/admin/lib/queries/plan.ts` - Modify: `src/admin/hooks/usePlanWork.ts` - Modify: `src/admin/components/PlanGrid.tsx` - Modify: `src/admin/pages/PlanWork.tsx` - Modify: `src/admin/components/dashboard/DashTodayPlan.tsx` - Modify: `src/admin/pages/Dashboard.tsx` - Test: `src/__tests__/plan.test.ts` The resolve return-shape change is atomic across server + client (the cell type is a TS lie over runtime JSON; tsc won't catch a mismatch, so both sides must move together). This task keeps the app **behaving as before** — the grid and cell editor still show/act on the _first_ (primary) record — while the data is now an array. Stacked rendering (Task 3) and the day panel (Task 4) build on top. The dashboard card gets full stacking here (it's small). - [ ] **Step 1: Rewrite the resolve tests for the array shape (write expectations first)** In `src/__tests__/plan.test.ts`, replace the `describe("plan.service.resolveCell", ...)` block (lines ~77-148) with: ```ts describe("plan.service.resolveCell", () => { it("returns [] when nothing covers the date", async () => { const result = await resolveCell(adminUserId, "2099-01-01"); expect(result).toEqual([]); }); it("returns the entry that covers the date", async () => { await prisma.work_plan_entries.create({ data: { user_id: adminUserId, date_from: new Date("2099-06-01"), date_to: new Date("2099-06-10"), category: "work", note: `${N}PLC upgrade`, created_by: adminUserId, }, }); const result = await resolveCell(adminUserId, "2099-06-05"); expect(result.length).toBe(1); expect(result[0].source).toBe("entry"); expect(result[0].note).toBe(`${N}PLC upgrade`); expect(result[0].rangeFrom).toBe("2099-06-01"); expect(result[0].rangeTo).toBe("2099-06-10"); }); it("returns overrides (entries hidden) when an override exists", async () => { await prisma.work_plan_entries.create({ data: { user_id: adminUserId, date_from: new Date("2099-06-01"), date_to: new Date("2099-06-10"), category: "work", note: `${N}PLC upgrade`, created_by: adminUserId, }, }); await prisma.work_plan_overrides.create({ data: { user_id: adminUserId, shift_date: new Date("2099-06-05"), category: "leave", note: `${N}Volno po noční`, created_by: adminUserId, }, }); const result = await resolveCell(adminUserId, "2099-06-05"); expect(result.length).toBe(1); expect(result[0].source).toBe("override"); expect(result[0].note).toBe(`${N}Volno po noční`); }); it("returns multiple additive entries newest-first", async () => { await prisma.work_plan_entries.create({ data: { user_id: adminUserId, date_from: new Date("2099-06-01"), date_to: new Date("2099-06-10"), category: "work", note: `${N}first`, created_by: adminUserId, }, }); await prisma.work_plan_entries.create({ data: { user_id: adminUserId, date_from: new Date("2099-06-05"), date_to: new Date("2099-06-05"), category: "work", note: `${N}second`, created_by: adminUserId, }, }); const result = await resolveCell(adminUserId, "2099-06-05"); expect(result.length).toBe(2); expect(result[0].note).toBe(`${N}second`); // newest first }); it("caps the returned records at MAX_RECORDS_PER_CELL", async () => { for (let i = 0; i < 4; i++) { await prisma.work_plan_overrides.create({ data: { user_id: adminUserId, shift_date: new Date("2099-06-06"), category: "leave", note: `${N}cap${i}`, created_by: adminUserId, }, }); } const result = await resolveCell(adminUserId, "2099-06-06"); expect(result.length).toBe(3); }); it("ignores soft-deleted entries and overrides", async () => { await prisma.work_plan_entries.create({ data: { user_id: adminUserId, date_from: new Date("2099-06-01"), date_to: new Date("2099-06-10"), category: "work", note: `${N}deleted entry`, is_deleted: true, created_by: adminUserId, }, }); const result = await resolveCell(adminUserId, "2099-06-05"); expect(result).toEqual([]); }); }); ``` In the `describe("plan.service.resolveGrid", ...)` block, update the cell accessors to index `[0]` and expect `[]` for empty days. Replace lines ~166-198 assertions: ```ts const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05"); expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`); expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`); expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`); expect(cells[adminUserId]["2099-06-04"]).toEqual([]); expect(cells[adminUserId]["2099-06-05"]).toEqual([]); ``` and for the override-on-covered-day test: ```ts const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03"); expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`); expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`); expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`); ``` In the HTTP grid test (line ~813), change: ```ts expect(body.data.cells[adminUserId]["2097-06-01"][0].note).toBe( `${N}grid test`, ); ``` - [ ] **Step 2: Run resolve tests to verify they fail** Run: `npx vitest run src/__tests__/plan.test.ts` Expected: FAIL (resolveCell returns an object/null, not an array). - [ ] **Step 3: Rewrite `resolveCell` to return an array** Replace `resolveCell` (lines ~122-187) in `src/services/plan.service.ts` with: ```ts export async function resolveCell( userId: number, dateStr: string, ): Promise { const date = new Date(dateStr); const overrides = await prisma.work_plan_overrides.findMany({ where: { user_id: userId, shift_date: date, is_deleted: false }, orderBy: { created_at: "desc" }, take: MAX_RECORDS_PER_CELL, include: { projects: projectSelect }, }); if (overrides.length > 0) { return overrides.map((o) => ({ source: "override" as const, entryId: null, overrideId: o.id, user_id: o.user_id, shift_date: dateStr, project_id: o.project_id, ...projectFields(o.projects), category: o.category, note: o.note, rangeFrom: null, rangeTo: null, })); } const entries = await prisma.work_plan_entries.findMany({ where: { user_id: userId, date_from: { lte: date }, date_to: { gte: date }, is_deleted: false, }, orderBy: { created_at: "desc" }, take: MAX_RECORDS_PER_CELL, include: { projects: projectSelect }, }); return entries.map((entry) => ({ source: "entry" as const, entryId: entry.id, overrideId: null, user_id: entry.user_id, shift_date: dateStr, project_id: entry.project_id, ...projectFields(entry.projects), category: entry.category, note: entry.note, rangeFrom: entry.date_from.toISOString().slice(0, 10), rangeTo: entry.date_to.toISOString().slice(0, 10), })); } ``` - [ ] **Step 4: Rewrite `resolveGrid` to return arrays** Replace `resolveGrid` (lines ~197-290) with: ```ts export async function resolveGrid( userIds: number[], dateFromStr: string, dateToStr: string, ): Promise>> { const dateFrom = new Date(dateFromStr); const dateTo = new Date(dateToStr); const [entries, overrides] = await Promise.all([ prisma.work_plan_entries.findMany({ where: { user_id: { in: userIds }, is_deleted: false, date_from: { lte: dateTo }, date_to: { gte: dateFrom }, }, orderBy: { created_at: "desc" }, include: { projects: projectSelect }, }), prisma.work_plan_overrides.findMany({ where: { user_id: { in: userIds }, is_deleted: false, shift_date: { gte: dateFrom, lte: dateTo }, }, orderBy: { created_at: "desc" }, include: { projects: projectSelect }, }), ]); const dates: string[] = []; for ( let d = new Date(dateFrom); d <= dateTo; d.setUTCDate(d.getUTCDate() + 1) ) { dates.push(d.toISOString().slice(0, 10)); } const result: Record> = {}; for (const uid of userIds) { result[uid] = {}; for (const dateStr of dates) { const day = new Date(dateStr); const dayOverrides = overrides .filter( (o) => o.user_id === uid && o.shift_date.toISOString().slice(0, 10) === dateStr, ) .slice(0, MAX_RECORDS_PER_CELL); if (dayOverrides.length > 0) { result[uid][dateStr] = dayOverrides.map((o) => ({ source: "override" as const, entryId: null, overrideId: o.id, user_id: o.user_id, shift_date: dateStr, project_id: o.project_id, ...projectFields(o.projects), category: o.category, note: o.note, rangeFrom: null, rangeTo: null, })); continue; } const dayEntries = entries .filter( (e) => e.user_id === uid && e.date_from <= day && e.date_to >= day, ) .slice(0, MAX_RECORDS_PER_CELL); result[uid][dateStr] = dayEntries.map((e) => ({ source: "entry" as const, entryId: e.id, overrideId: null, user_id: e.user_id, shift_date: dateStr, project_id: e.project_id, ...projectFields(e.projects), category: e.category, note: e.note, rangeFrom: e.date_from.toISOString().slice(0, 10), rangeTo: e.date_to.toISOString().slice(0, 10), })); } } return result; } ``` - [ ] **Step 5: Update the dashboard route to build a `today_plan` array** In `src/routes/admin/dashboard.ts`, replace the `today_plan` block (lines ~51-67) with: ```ts if (has("attendance.record") || has("attendance.manage")) { const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const cells = await resolveCell(userId, todayStr); result.today_plan = await Promise.all( cells.map(async (cell) => { const cat = await prisma.plan_categories.findUnique({ where: { key: cell.category }, select: { label: true, color: true }, }); return { ...cell, category_label: cat?.label ?? cell.category, category_color: cat?.color ?? null, }; }), ); } ``` - [ ] **Step 6: Update the frontend cell type** In `src/admin/lib/queries/plan.ts`, change the `GridData.cells` value type (line ~84): ```ts cells: Record>; ``` - [ ] **Step 7: Make `usePlanWork` array-aware** In `src/admin/hooks/usePlanWork.ts`: (a) Change `patchCells` (lines ~149-169) to operate on arrays: ```ts function patchCells( key: readonly unknown[], userId: number, dates: string[], mutator: (prev: ResolvedCell[]) => ResolvedCell[], ): Record | null { const prev = snapshotGrid(key); if (!prev) return null; const userPrev = prev.cells[userId] ?? {}; const rolled: Record = {}; const userNext: Record = { ...userPrev }; for (const date of dates) { rolled[date] = userPrev[date] ?? []; userNext[date] = mutator(rolled[date]).slice(0, 3); } qc.setQueryData(key, { ...prev, cells: { ...prev.cells, [userId]: userNext }, }); return rolled; } ``` (b) `createEntry.onSuccess` (lines ~241-263) — prepend the new cell (newest-first): ```ts const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [ makeEntryCell({ userId: body.user_id, date: days[0], projectId: body.project_id ?? null, category: body.category, note: body.note, rangeFrom: body.date_from, rangeTo: body.date_to, entryId: id, }), ...prev, ]); ``` (c) `updateEntry.onSuccess` (lines ~281-326) — replace the owner-scan loop and the `patchCells` mutator. The scan now checks arrays: ```ts let ownerUserId: number | null = null; if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const cells of Object.values(byDate)) { if (cells.some((c) => c.entryId === id)) { ownerUserId = Number(uidStr); break; } } if (ownerUserId !== null) break; } } if (ownerUserId !== null) { const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) => { const existing = prev.find((c) => c.entryId === id) ?? null; const updated = makeEntryCell({ userId: ownerUserId!, date: days[0], projectId: body.project_id === undefined ? (existing?.project_id ?? null) : body.project_id, category: body.category ?? existing?.category ?? "work", note: body.note ?? existing?.note ?? "", rangeFrom: body.date_from, rangeTo: body.date_to, entryId: id, }); return [updated, ...prev.filter((c) => c.entryId !== id)]; }); (updateEntry as any)._rolled = rolled; } ``` (d) `deleteEntry.onSuccess` (lines ~334-361) — find by array membership, then remove the matching id from each day in the range: ```ts const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { let range: { from: string; to: string } | null = null; for (const cells of Object.values(byDate)) { const hit = cells.find( (c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo, ); if (hit) { range = { from: hit.rangeFrom!, to: hit.rangeTo! }; break; } } if (range) { const days = eachDay(range.from, range.to); const rolled = patchCells(currentGridKey, Number(uidStr), days, (prev) => prev.filter((c) => c.entryId !== vars.id), ); (deleteEntry as any)._rolled = rolled; break; } } } ``` (e) `createOverride.onSuccess` (lines ~372-389) — prepend: ```ts const rolled = patchCells( currentGridKey, vars.user_id, [vars.shift_date], (prev) => [ makeOverrideCell({ userId: vars.user_id, date: vars.shift_date, projectId: vars.project_id ?? null, category: vars.category, note: vars.note, overrideId: id, }), ...prev, ], ); ``` (f) `updateOverride.onSuccess` (lines ~408-436) — array scan + replace: ```ts const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [date, cells] of Object.entries(byDate)) { if (cells.some((c) => c.overrideId === vars.id)) { const rolled = patchCells( currentGridKey, Number(uidStr), [date], (prev) => { const existing = prev.find((c) => c.overrideId === vars.id) ?? null; const updated = makeOverrideCell({ userId: Number(uidStr), date, projectId: vars.body.project_id ?? existing?.project_id ?? null, category: vars.body.category ?? existing?.category ?? "work", note: vars.body.note ?? existing?.note ?? "", overrideId: vars.id, }); return [updated, ...prev.filter((c) => c.overrideId !== vars.id)]; }, ); (updateOverride as any)._rolled = rolled; break; } } } } ``` (g) `deleteOverride.onSuccess` (lines ~445-463) — array scan + remove: ```ts const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [date, cells] of Object.entries(byDate)) { if (cells.some((c) => c.overrideId === vars.id)) { const rolled = patchCells( currentGridKey, Number(uidStr), [date], (prev) => prev.filter((c) => c.overrideId !== vars.id), ); (deleteOverride as any)._rolled = rolled; break; } } } } ``` (h) `rollbackMutation` (lines ~473-490) — change the stash + cell types to arrays: ```ts function rollbackMutation(mutation: unknown, userId: number) { const stash = mutation as { _rolled?: Record | null; }; if (!stash._rolled) return; const prev = qc.getQueryData(currentGridKey as any); if (!prev) return; const userPrev = prev.cells[userId] ?? {}; const userNext = { ...userPrev }; for (const [date, cells] of Object.entries(stash._rolled)) { userNext[date] = cells; } qc.setQueryData(currentGridKey, { ...prev, cells: { ...prev.cells, [userId]: userNext }, }); stash._rolled = null; } ``` (i) `getCell` (lines ~521-527) — keep returning the primary record for now, and add `getCells` for the array: ```ts export function getCells( grid: GridData | undefined, userId: number, date: string, ): ResolvedCell[] { return grid?.cells?.[userId]?.[date] ?? []; } export function getCell( grid: GridData | undefined, userId: number, date: string, ): ResolvedCell | null { return getCells(grid, userId, date)[0] ?? null; } ``` - [ ] **Step 8: Make `PlanGrid` read the array (primary record for now)** In `src/admin/components/PlanGrid.tsx`, in the cell-render loop (around line 532) change: ```ts const cellArr = data.cells[u.id]?.[date] ?? []; const cell = cellArr[0] ?? null; ``` Leave the rest of the loop unchanged (it already keys off `cell`). The `onCellClick(u.id, date, cell)` call stays — clicking still opens the primary record (stacked rendering and array-passing come in Tasks 3–4). - [ ] **Step 9: Make `PlanWork` mutation-wrapper scans array-aware** In `src/admin/pages/PlanWork.tsx`, the wrappers `updateEntryFn`, `deleteEntryFn`, `updateOverrideFn`, `deleteOverrideFn` each scan `grid.cells` with `if (cell && cell.entryId === id)` / `cell.overrideId === id`. In every such loop, the inner value is now an array — change the destructure + predicate. Replace each occurrence of this shape: ```ts for (const [date, cell] of Object.entries(byDate)) { if (cell && cell.entryId === id) { userId = Number(uidStr); firstDate = body.date_from ?? date; break; } } ``` with the array form (matching the field used at that site — `entryId` for entry wrappers, `overrideId` for override wrappers; `rangeFrom` is read from the found record in `deleteEntryFn`): ```ts for (const [date, cells] of Object.entries(byDate)) { const hit = cells.find((c) => c.entryId === id); if (hit) { userId = Number(uidStr); firstDate = body.date_from ?? date; break; } } ``` For `deleteEntryFn`, set `firstDate = hit.rangeFrom ?? date;`. For the override wrappers, use `c.overrideId === id` and `date = d`. - [ ] **Step 10: Dashboard card — render up to 3 (full stacking here)** Replace `src/admin/components/dashboard/DashTodayPlan.tsx` so it accepts an array and stacks. Keep the existing single-record card markup, extracted into an inner row and mapped: ```tsx import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { Card } from "../../ui"; import { formatDate } from "../../utils/formatters"; /** One resolved plan record for today (see GET /api/admin/dashboard). */ export interface TodayPlan { shift_date: string; category: string; category_label: string; category_color: string | null; project_id: number | null; project_number: string | null; project_name: string | null; note: string; source: "entry" | "override"; rangeFrom: string | null; rangeTo: string | null; } function projectLabel(p: TodayPlan): string { const parts = [p.project_number, p.project_name].filter(Boolean); return parts.length > 0 ? parts.join(" — ") : "Bez projektu"; } function PlanRow({ plan }: { plan: TodayPlan }) { const color = plan.category_color || "var(--mui-palette-primary-main)"; return ( {plan.category_label} {plan.rangeFrom && plan.rangeTo && plan.rangeFrom !== plan.rangeTo && ( součást rozsahu {formatDate(plan.rangeFrom)} –{" "} {formatDate(plan.rangeTo)} )} {projectLabel(plan)} {plan.note && ( {plan.note} )} ); } /** "Vaše dnešní zařazení" — the logged-in user's resolved plan record(s) for * today (up to 3). Empty array = nothing scheduled. */ export default function DashTodayPlan({ plans }: { plans: TodayPlan[] }) { return ( Vaše dnešní zařazení {plans.length > 0 ? ( {plans.map((p, i) => ( ))} ) : ( Pro dnešek nemáte naplánováno. )} ); } ``` In `src/admin/pages/Dashboard.tsx`: change the `DashData` field (line 75) to `today_plan?: TodayPlan[];` and the render (line 328) to ``. - [ ] **Step 11: Gate (tests + typecheck + build)** ```bash npx vitest run npx tsc -b --noEmit npm run build ``` Expected: vitest PASS, tsc exit 0, build success. - [ ] **Step 12: Commit** ```bash git add src/services/plan.service.ts src/routes/admin/dashboard.ts src/admin/lib/queries/plan.ts src/admin/hooks/usePlanWork.ts src/admin/components/PlanGrid.tsx src/admin/pages/PlanWork.tsx src/admin/components/dashboard/DashTodayPlan.tsx src/admin/pages/Dashboard.tsx src/__tests__/plan.test.ts git commit -m "feat(plan): resolveCell/resolveGrid return arrays; dashboard shows up to 3" ``` --- ## Task 3: Grid — render up to 3 stacked records **Files:** - Modify: `src/admin/components/PlanRangeChips.tsx` - Modify: `src/admin/components/PlanGrid.tsx` Frontend-only and visual; no component test harness exists, so the gate is `tsc -b` + `build` + a Chrome check. After Task 2 the cell still shows only the primary record — this task stacks all of them. - [ ] **Step 1: Add a `showNote` prop to `PlanRangeChips`** In `src/admin/components/PlanRangeChips.tsx`, add `showNote?: boolean` to `Props` and gate the note. Change the interface and the note line: ```tsx interface Props { cell: ResolvedCell | null; project: Project | null; readonly?: boolean; categoryLabel: string; showNote?: boolean; } ``` and the note render (line ~39): ```tsx { showNote !== false && cell.note && (
{cell.note}
); } ``` - [ ] **Step 2: Stack the records in `PlanGrid`** In `src/admin/components/PlanGrid.tsx`, replace the single `` inside the cell ` ``` `onCellClick` now passes the **array** `cellArr` — update its `Props` type in Step 3. The button keeps a single `--cat-color` (the first record's, for the left-tape `::before`); each `.plan-cell-record` then sets its own `--cat-color` for its chip. `cell` is `cellArr[0] ?? null` from Task 2 Step 8. `` is already imported in PlanGrid.tsx. - [ ] **Step 3: Update the `onCellClick` prop type** In `PlanGrid.tsx` `Props` (lines ~376-380), change: ```ts onCellClick: (userId: number, date: string, cells: ResolvedCell[]) => void; ``` - [ ] **Step 4: Add the `.plan-cell-record` style + tighten chip block for stacking** In the `PlanGridRoot` styled block, add a rule (near the `.plan-chip-block` rules, ~line 294): ```ts "& .plan-cell-record": { display: "block", width: "100%", minWidth: 0, }, "& .plan-cell-record + .plan-cell-record": { marginTop: 5, paddingTop: 5, borderTop: `1px dashed ${theme.vars!.palette.divider}`, }, ``` - [ ] **Step 5: Temporarily adapt `PlanWork.openCell` signature (still opens primary)** `onCellClick` now passes `ResolvedCell[]`. In `src/admin/pages/PlanWork.tsx`, change `openCell` to accept the array but keep current behaviour by using its first element (Task 4 replaces this with the day panel). Change the signature line (~204) and its first line: ```ts const openCell = useCallback( (userId: number, date: string, _cells: ResolvedCell[]) => { const cell = getCell(grid, userId, date); void _cells; ``` Add `ResolvedCell` to the import from `../lib/queries/plan` at the top of PlanWork.tsx. - [ ] **Step 6: Gate + Chrome check** ```bash npx tsc -b --noEmit npm run build ``` Then in Chrome (dev server already running — do not start it): open `/plan-work`, create two entries on the same day for one person, confirm both render stacked in the cell; confirm a single-record cell still shows its note; confirm light/dark both readable. - [ ] **Step 7: Commit** ```bash git add src/admin/components/PlanRangeChips.tsx src/admin/components/PlanGrid.tsx src/admin/pages/PlanWork.tsx git commit -m "feat(plan): grid renders up to 3 stacked records per cell" ``` --- ## Task 4: Cell editor — the day panel **Files:** - Modify: `src/admin/components/PlanCellModal.tsx` - Modify: `src/admin/hooks/usePlanWork.ts` (add `day` to `ModalMode`) - Modify: `src/admin/pages/PlanWork.tsx` When a cell has ≥1 record, clicking it opens a panel listing the day's records (edit/delete each) plus "+ Přidat záznam" (disabled at 3). Empty cells still open the create form directly. Editing a record reuses the existing edit flows. - [ ] **Step 1: Add `day` to the hook's `ModalMode`** In `src/admin/hooks/usePlanWork.ts`, add to the `ModalMode` union (line ~12): ```ts | { kind: "day"; userId: number; date: string } ``` - [ ] **Step 2: Add a `day` variant + `DayPanel` to `PlanCellModal`** In `src/admin/components/PlanCellModal.tsx`: (a) Add to the `PlanCellModalMode` union (after `view`, ~line 67): ```ts | { kind: "day"; userId: number; date: string; cells: ResolvedCell[] }; ``` (b) Add callbacks to `Props` (after `onSwitchToEditRange`, ~line 86): ```ts /** Open the create form for a brand-new record on (userId, date). */ onAddRecord: (userId: number, date: string) => void; /** Open the right editor for one of the day's existing records. */ onEditRecord: (cell: ResolvedCell, userId: number, date: string) => void; ``` (c) In the top-level `PlanCellModal` switch (lines ~109-116), add before the final `return `: ```ts if (mode.kind === "day") return ; ``` (d) Add the `DayPanel` component (and a small `EditIcon`) at the end of the file: ```tsx const EditIcon = ( ); function DayPanel( props: Props & { mode: Extract }, ) { const { mode, onClose, onAddRecord, onEditRecord, categories } = props; const catMap = Object.fromEntries(categories.map((x) => [x.key, x])); const atCap = mode.cells.length >= 3; const free = 3 - mode.cells.length; return ( {mode.cells.map((c, i) => { const color = catMap[c.category]?.color || "var(--mui-palette-divider)"; const projectLabel = cellProjectLabel(c); return ( {planCategoryLabel(c.category, catMap)} {projectLabel ? ` · ${projectLabel}` : ""} {c.note && ( {c.note} )} ); })} {atCap ? "Maximum 3 záznamy na den" : `${free} volné místo`} ); } ``` (Per-record delete is reached through "Upravit" → the existing edit form's "Smazat" button, so the panel stays simple. The `cellProjectLabel` import already exists in this file.) - [ ] **Step 3: Wire the panel in `PlanWork`** In `src/admin/pages/PlanWork.tsx`: (a) Replace `openCell` so an occupied editable cell opens the `day` panel (empty → create, past → view as today): ```ts const openCell = useCallback( (userId: number, date: string, cells: ResolvedCell[]) => { const primary = cells[0] ?? null; if (isPastDate(date, todayIsoLocal())) { if (!primary) return; setModalCell(primary); setHookModal({ kind: "view", userId, date }); return; } if (cells.length === 0) { if (canEdit) openCreate(userId, date); return; } if (!canEdit) { setModalCell(primary); setHookModal({ kind: "view", userId, date }); return; } setHookModal({ kind: "day", userId, date }); }, [canEdit, openCreate, setHookModal], ); ``` (b) Add `onEditRecord` — open the correct editor for one record (this is the per-record version of the old `openCell` branch logic): ```ts const editRecord = useCallback( (cell: ReturnType, userId: number, date: string) => { if (!cell) return; setModalCell(cell); if (cell.source === "entry" && cell.entryId !== null) { if (cell.rangeFrom && cell.rangeTo && cell.rangeFrom !== cell.rangeTo) { setHookModal({ kind: "day-in-range", entryId: cell.entryId, userId, date, }); } else { setHookModal({ kind: "edit-range", entryId: cell.entryId, userId, date, }); } return; } if (cell.source === "override" && cell.overrideId !== null) { setHookModal({ kind: "edit-override", overrideId: cell.overrideId, userId, date, }); } }, [setHookModal], ); ``` (c) Build the `day` modalMode in the `modalMode` memo (add a branch before the final `return { kind: "closed" }`): ```ts if (hookModal.kind === "day") { return { kind: "day", userId: hookModal.userId, date: hookModal.date, cells: getCells(grid, hookModal.userId, hookModal.date), }; } ``` Import `getCells` alongside `getCell` from `../hooks/usePlanWork`. (d) Pass the two new props to ``: ```tsx onAddRecord = { openCreate }; onEditRecord = { editRecord }; ``` (e) Update the `modalKey` memo so the `day` mode re-keys cleanly — it already uses `m.userId`/`m.date`, which the `day` variant has, so no change is needed. Verify `HookModalMode` includes `day` (Step 1 added it). - [ ] **Step 4: Gate + Chrome check** ```bash npx tsc -b --noEmit npm run build ``` In Chrome on `/plan-work`: click an occupied cell → day panel lists the records; "+ Přidat záznam" creates a 2nd/3rd record; at 3 it's disabled with "Maximum 3 záznamy na den"; "Upravit" on a range record still shows the range-vs-this-day chooser; delete via the edit form removes one record; empty cell still opens create directly; past day opens view. - [ ] **Step 5: Commit** ```bash git add src/admin/components/PlanCellModal.tsx src/admin/hooks/usePlanWork.ts src/admin/pages/PlanWork.tsx git commit -m "feat(plan): day panel — list/add/edit up to 3 records per cell" ``` --- ## Task 5: Release v2.0.5 **Files:** - Modify: `package.json` - [ ] **Step 1: Bump version** Set `"version": "2.0.5"` in `package.json`. - [ ] **Step 2: Full gate** ```bash npx tsc -b --noEmit npx vitest run npm run build ``` Expected: tsc exit 0, vitest all pass, build success. - [ ] **Step 3: Commit + tag** ```bash git add package.json git commit -m "chore(release): v2.0.5 — multi-record plan cells" git tag -a v2.0.5 -m "v2.0.5 — multi-record plan cells (max 3 per cell)" ``` - [ ] **Step 4: Push to Gitea (with user confirmation)** ```bash git push origin master git push origin v2.0.5 ``` - [ ] **Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation — do not auto-deploy)** ```bash tar -czf app-ts-2.0.5.tar.gz dist dist-client prisma package.json package-lock.json scripts scp app-ts-2.0.5.tar.gz boha_admin@192.168.50.100:/tmp/ ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.0.5.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env' ``` No migration ships in this release, so `prisma migrate deploy` reports "No pending migrations". - [ ] **Step 6: Health check** ```bash ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session' ``` Expected: `200` then `401`. Confirm pm2 shows `app-ts` v2.0.5 online. --- ## Notes for the implementer - **Past-date rule** is unchanged and still enforced server-side; the day panel and create flow inherit it. - **Optimistic updates** are approximate by design — the `invalidate()` + refetch after each mutation is the source of truth and corrects the cell arrays (including the override-hides-entries layering) within ~200ms. Don't try to make the optimistic patch reproduce the exact layering. - **Do not start the dev server** — the user runs it. Use the running instance for Chrome checks. - **Do not deploy to production without explicit confirmation** (Task 5, Steps 5–6).