diff --git a/docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md b/docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md new file mode 100644 index 0000000..34cd22c --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md @@ -0,0 +1,1524 @@ +# 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).