Five tasks: (1) backend cap + additive overrides, (2) resolve->arrays + coordinated frontend shape change + dashboard, (3) grid stacked render, (4) day-panel cell editor, (5) v2.0.5 release. Backend tasks are TDD against the real test DB; frontend tasks gate on tsc/build/Chrome (no component-test harness). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
48 KiB
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— addMAX_RECORDS_PER_CELL;resolveCell/resolveGrid→ arrays; cap increateEntry;createOverrideadditive-with-cap; trimResulttype.src/routes/admin/plan.ts— drop thereplacedDataaudit branch inPOST /overrides.src/routes/admin/dashboard.ts—today_planbecomes an array.
Frontend
src/admin/lib/queries/plan.ts—GridData.cellsvalue type →ResolvedCell[].src/admin/hooks/usePlanWork.ts— optimistic patch helpers + mutation handlers + rollback +getCell/getCellswork on arrays.src/admin/components/PlanGrid.tsx— render up to 3 stacked records;onCellClickpasses the array.src/admin/components/PlanRangeChips.tsx—showNoteprop (note only when single record).src/admin/components/PlanCellModal.tsx— newdaypanel 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
createOverridetests + 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):
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:
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 });):
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:
/** 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:
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
createOverrideadditive-with-cap (drop replace)
Replace the body of createOverride from the const date = toDateOnly(...) line through the return { ... replacedData ... } (lines ~613-681) with:
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
Resulttype (remove replaced* fields)
In plan.service.ts, change the Result<T> success branch (around lines 387-397) to drop replacedData and replacedDescription:
export type Result<T> =
| {
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
replacedDataaudit 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
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:
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:
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:
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:
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
resolveCellto return an array
Replace resolveCell (lines ~122-187) in src/services/plan.service.ts with:
export async function resolveCell(
userId: number,
dateStr: string,
): Promise<ResolvedCell[]> {
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
resolveGridto return arrays
Replace resolveGrid (lines ~197-290) with:
export async function resolveGrid(
userIds: number[],
dateFromStr: string,
dateToStr: string,
): Promise<Record<number, Record<string, ResolvedCell[]>>> {
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<number, Record<string, ResolvedCell[]>> = {};
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_planarray
In src/routes/admin/dashboard.ts, replace the today_plan block (lines ~51-67) with:
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):
cells: Record<number, Record<string, ResolvedCell[]>>;
- Step 7: Make
usePlanWorkarray-aware
In src/admin/hooks/usePlanWork.ts:
(a) Change patchCells (lines ~149-169) to operate on arrays:
function patchCells(
key: readonly unknown[],
userId: number,
dates: string[],
mutator: (prev: ResolvedCell[]) => ResolvedCell[],
): Record<string, ResolvedCell[]> | null {
const prev = snapshotGrid(key);
if (!prev) return null;
const userPrev = prev.cells[userId] ?? {};
const rolled: Record<string, ResolvedCell[]> = {};
const userNext: Record<string, ResolvedCell[]> = { ...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):
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:
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:
const grid = qc.getQueryData<GridData>(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:
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:
const grid = qc.getQueryData<GridData>(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:
const grid = qc.getQueryData<GridData>(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:
function rollbackMutation(mutation: unknown, userId: number) {
const stash = mutation as {
_rolled?: Record<string, ResolvedCell[]> | null;
};
if (!stash._rolled) return;
const prev = qc.getQueryData<GridData>(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:
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
PlanGridread the array (primary record for now)
In src/admin/components/PlanGrid.tsx, in the cell-render loop (around line 532) change:
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
PlanWorkmutation-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:
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):
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:
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 (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box
sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}
>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
border: 1,
borderColor: "divider",
bgcolor: `color-mix(in srgb, ${color} 12%, transparent)`,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: color,
flexShrink: 0,
}}
/>
<Typography
component="span"
sx={{ fontWeight: 700, fontSize: "0.8rem" }}
>
{plan.category_label}
</Typography>
</Box>
{plan.rangeFrom && plan.rangeTo && plan.rangeFrom !== plan.rangeTo && (
<Typography variant="caption" color="text.secondary">
součást rozsahu {formatDate(plan.rangeFrom)} –{" "}
{formatDate(plan.rangeTo)}
</Typography>
)}
</Box>
<Typography variant="h6" sx={{ lineHeight: 1.25 }}>
{projectLabel(plan)}
</Typography>
{plan.note && (
<Typography variant="body2" color="text.secondary">
{plan.note}
</Typography>
)}
</Box>
);
}
/** "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 (
<Card sx={{ mb: 3 }}>
<Typography
variant="subtitle2"
sx={{ mb: 1.5, fontWeight: 600, color: "text.secondary" }}
>
Vaše dnešní zařazení
</Typography>
{plans.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{plans.map((p, i) => (
<Box
key={i}
sx={{
pt: i === 0 ? 0 : 2,
borderTop: i === 0 ? 0 : 1,
borderColor: "divider",
}}
>
<PlanRow plan={p} />
</Box>
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Pro dnešek nemáte naplánováno.
</Typography>
)}
</Card>
);
}
In src/admin/pages/Dashboard.tsx: change the DashData field (line 75) to today_plan?: TodayPlan[]; and the render (line 328) to <DashTodayPlan plans={dashData?.today_plan ?? []} />.
- Step 11: Gate (tests + typecheck + build)
npx vitest run
npx tsc -b --noEmit
npm run build
Expected: vitest PASS, tsc exit 0, build success.
- Step 12: Commit
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
showNoteprop toPlanRangeChips
In src/admin/components/PlanRangeChips.tsx, add showNote?: boolean to Props and gate the note. Change the interface and the note line:
interface Props {
cell: ResolvedCell | null;
project: Project | null;
readonly?: boolean;
categoryLabel: string;
showNote?: boolean;
}
and the note render (line ~39):
{
showNote !== false && cell.note && (
<div className="plan-cell-note">{cell.note}</div>
);
}
- Step 2: Stack the records in
PlanGrid
In src/admin/components/PlanGrid.tsx, replace the single <PlanRangeChips ... /> inside the cell <button> (lines ~583-596) with a stacked map over cellArr. Also set --cat-color per record rather than per cell, and key the button styling off the first record. Replace the button's style + children:
<button
type="button"
className={cls}
style={
cell
? ({ "--cat-color": catMap[cell.category]?.color } as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? `${u.full_name}, ${date}, ${cellArr.length} ${cellArr.length === 1 ? "záznam" : "záznamy"}`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={c.entryId ?? `o${c.overrideId}` ?? i}
className="plan-cell-record"
style={{ "--cat-color": catMap[c.category]?.color } as CSSProperties}
>
<PlanRangeChips
cell={c}
project={
c.project_id
? (projects.find((p) => p.id === c.project_id) ?? null)
: null
}
readonly={!canEdit}
categoryLabel={planCategoryLabel(c.category, catMap)}
showNote={cellArr.length === 1}
/>
</Box>
))}
</button>
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. <Box> is already imported in PlanGrid.tsx.
- Step 3: Update the
onCellClickprop type
In PlanGrid.tsx Props (lines ~376-380), change:
onCellClick: (userId: number, date: string, cells: ResolvedCell[]) => void;
- Step 4: Add the
.plan-cell-recordstyle + tighten chip block for stacking
In the PlanGridRoot styled block, add a rule (near the .plan-chip-block rules, ~line 294):
"& .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.openCellsignature (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:
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
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
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(adddaytoModalMode) - 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
dayto the hook'sModalMode
In src/admin/hooks/usePlanWork.ts, add to the ModalMode union (line ~12):
| { kind: "day"; userId: number; date: string }
- Step 2: Add a
dayvariant +DayPaneltoPlanCellModal
In src/admin/components/PlanCellModal.tsx:
(a) Add to the PlanCellModalMode union (after view, ~line 67):
| { kind: "day"; userId: number; date: string; cells: ResolvedCell[] };
(b) Add callbacks to Props (after onSwitchToEditRange, ~line 86):
/** 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 <EditForm/>:
if (mode.kind === "day") return <DayPanel {...props} mode={mode} />;
(d) Add the DayPanel component (and a small EditIcon) at the end of the file:
const EditIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
function DayPanel(
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day" }> },
) {
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 (
<Modal
isOpen
title={`Plán — ${formatDate(mode.date)}`}
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
hideCancel
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25, mb: 2 }}>
{mode.cells.map((c, i) => {
const color =
catMap[c.category]?.color || "var(--mui-palette-divider)";
const projectLabel = cellProjectLabel(c);
return (
<Card key={c.entryId ?? `o${c.overrideId}` ?? i} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 4,
alignSelf: "stretch",
minHeight: 28,
borderRadius: 2,
bgcolor: color,
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 600, fontSize: "0.85rem" }}>
{planCategoryLabel(c.category, catMap)}
{projectLabel ? ` · ${projectLabel}` : ""}
</Typography>
{c.note && (
<Typography variant="body2" color="text.secondary" noWrap>
{c.note}
</Typography>
)}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={EditIcon}
onClick={() => onEditRecord(c, mode.userId, mode.date)}
>
Upravit
</Button>
</Box>
</Card>
);
})}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Button
variant="contained"
color="primary"
disabled={atCap}
onClick={() => onAddRecord(mode.userId, mode.date)}
>
+ Přidat záznam
</Button>
<Typography variant="caption" color="text.secondary">
{atCap ? "Maximum 3 záznamy na den" : `${free} volné místo`}
</Typography>
</Box>
</Modal>
);
}
(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):
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):
const editRecord = useCallback(
(cell: ReturnType<typeof getCell>, 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" }):
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 <PlanCellModal>:
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
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
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
npx tsc -b --noEmit
npx vitest run
npm run build
Expected: tsc exit 0, vitest all pass, build success.
- Step 3: Commit + tag
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)
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)
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
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).