Files
app/docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md
BOHA 2cfa28dc47 docs(plan): implementation plan for multi-record plan cells (v2.0.5)
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>
2026-06-08 10:33:40 +02:00

48 KiB
Raw Blame History

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 03 (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.tstoday_plan becomes an array.

Frontend

  • src/admin/lib/queries/plan.tsGridData.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.tsxshowNote 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):

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 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:

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<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 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
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 resolveCell to 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 resolveGrid to 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_plan array

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 usePlanWork array-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 PlanGrid read 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 34).

  • 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:

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 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:

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 onCellClick prop type

In PlanGrid.tsx Props (lines ~376-380), change:

  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):

  "& .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:

  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 (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):

  | { 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):

  | { 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 56).