# 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<T>` success branch (around lines 387-397) to drop `replacedData` and `replacedDescription`:

```ts
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**

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

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

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

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

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

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

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

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

```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 (
    <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)**

```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 && (
    <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:

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

```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 <EditForm/>`:

```ts
  if (mode.kind === "day") return <DayPanel {...props} mode={mode} />;
```

(d) Add the `DayPanel` component (and a small `EditIcon`) at the end of the file:

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

```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<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" }`):

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

```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).
