From 6db87bf4ae311e9caf422ab525a6926261a351fc Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 11:54:44 +0200 Subject: [PATCH] docs(plan): implementation plan for bulk plan creation (v2.0.6) Three tasks: (1) backend bulk endpoint + bulkCreateEntries service (reuses createEntry; weekday filter + contiguous-run segmentation + per-day cap skip) with TDD tests, (2) BulkPlanModal + PlanWork wiring + usePlanWork bulkCreate mutation, (3) v2.0.6 release. No migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-08-bulk-plan-creation.md | 891 ++++++++++++++++++ 1 file changed, 891 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-bulk-plan-creation.md diff --git a/docs/superpowers/plans/2026-06-08-bulk-plan-creation.md b/docs/superpowers/plans/2026-06-08-bulk-plan-creation.md new file mode 100644 index 0000000..745551d --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-bulk-plan-creation.md @@ -0,0 +1,891 @@ +# Bulk Plan Creation 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:** Assign one project/category to many employees over a date range in a single action ("Hromadné přiřazení" on /plan-work). + +**Architecture:** A new `POST /api/admin/plan/entries/bulk` endpoint drives a `bulkCreateEntries` service that, per employee, computes the eligible days in the range (weekday filter + Feature A's under-cap rule), groups contiguous eligible days into runs, and creates one range `work_plan_entries` row per run by **reusing `createEntry`**. The frontend adds a presentational `BulkPlanModal` (mirroring the single-create form + the existing bulk-attendance employee picker) wired from `PlanWork`, with a summary alert and `["plan"]` invalidation. No DB migration. + +**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB). Spec: `docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md`. Builds on Feature A (multi-record plan cells, v2.0.5). + +**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. The frontend has no component-test harness (server-side tests only), so Task 2 gates on `tsc -b` + `build`. + +--- + +## File Structure + +**Backend** + +- `src/schemas/plan.schema.ts` — add `BulkPlanEntrySchema`. +- `src/services/plan.service.ts` — add `bulkCreateEntries(...)` (reuses `createEntry`, `assertNotPastDate`, `assertActiveCategory`, `toDateOnly`, `MAX_RECORDS_PER_CELL`). +- `src/routes/admin/plan.ts` — add `POST /entries/bulk`. + +**Frontend** + +- `src/admin/components/BulkPlanModal.tsx` — new presentational modal. +- `src/admin/hooks/usePlanWork.ts` — add a `bulkCreate` mutation. +- `src/admin/pages/PlanWork.tsx` — toolbar button + bulk form state + toggles + submit wiring. + +**Tests** + +- `src/__tests__/plan.test.ts` — `bulkCreateEntries` service tests. + +--- + +## Task 1: Backend — bulk endpoint + service + +**Files:** + +- Modify: `src/schemas/plan.schema.ts` +- Modify: `src/services/plan.service.ts` +- Modify: `src/routes/admin/plan.ts` +- Test: `src/__tests__/plan.test.ts` + +- [ ] **Step 1: Write the failing service tests** + +Add this block to the end of `src/__tests__/plan.test.ts` (before the final HTTP describe block is fine; anywhere at top level). It imports `bulkCreateEntries` — add it to the existing `import { ... } from "../services/plan.service"` list at the top of the file. + +```ts +// --------------------------------------------------------------------------- +// bulkCreateEntries +// --------------------------------------------------------------------------- + +describe("plan.service.bulkCreateEntries", () => { + it("creates one continuous range per employee when weekends are included", async () => { + // 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one + // range 06-01..06-03 covering all 3 days. + const res = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-01", + date_to: "2096-06-03", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}bulk-wknd`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.created_entries).toBe(1); + expect(res.data.created_days).toBe(3); + expect(res.data.skipped_days).toBe(0); + expect(res.data.users).toBe(1); + }); + + it("splits into per-work-week ranges and skips weekends when excluded", async () => { + // 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue. + // Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped. + const res = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-01", + date_to: "2096-06-05", + include_weekends: false, + project_id: null, + category: "work", + note: `${N}bulk-week`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.created_entries).toBe(2); + expect(res.data.created_days).toBe(3); + expect(res.data.skipped_days).toBe(0); + }); + + it("skips days already at the cap and splits the range around them", async () => { + // Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on + // bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips + // 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on. + for (let i = 0; i < 3; i++) { + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2096-07-02"), + date_to: new Date("2096-07-02"), + category: "work", + note: `${N}cap${i}`, + created_by: adminUserId, + }, + }); + } + const res = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-07-01", + date_to: "2096-07-03", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}bulk-cap`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.created_entries).toBe(2); + expect(res.data.created_days).toBe(2); + expect(res.data.skipped_days).toBe(1); + }); + + it("aggregates across multiple employees", async () => { + const res = await bulkCreateEntries( + { + user_ids: [adminUserId, noPermUserId], + date_from: "2096-08-03", // Friday + date_to: "2096-08-03", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}bulk-multi`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.users).toBe(2); + expect(res.data.created_entries).toBe(2); + expect(res.data.created_days).toBe(2); + }); + + it("rejects empty user_ids, past dates, bad range, and inactive category", async () => { + const empty = await bulkCreateEntries( + { + user_ids: [], + date_from: "2096-06-01", + date_to: "2096-06-01", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in empty && empty.status).toBe(400); + + const past = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2000-01-01", + date_to: "2000-01-02", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in past && past.status).toBe(403); + + const badRange = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-10", + date_to: "2096-06-01", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in badRange && badRange.status).toBe(400); + + const badCat = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-01", + date_to: "2096-06-01", + include_weekends: true, + project_id: null, + category: "__nope__", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in badCat && badCat.status).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/__tests__/plan.test.ts` +Expected: FAIL — `bulkCreateEntries` is not exported / not a function. + +- [ ] **Step 3: Implement `bulkCreateEntries` in `plan.service.ts`** + +Add at the end of `src/services/plan.service.ts` (after `listOverrides`): + +```ts +// --------------------------------------------------------------------------- +// Bulk entry creation +// --------------------------------------------------------------------------- + +/** + * Bulk-assign a project/category to many employees over a date range. For each + * employee, walk the range, keep the days that pass the weekday filter + * (weekends excluded unless include_weekends) AND are under the per-cell cap, + * group contiguous kept days into runs, and create one range entry per run by + * reusing createEntry (so cap / past-date / category validation stays DRY). + * + * Days that pass the weekday filter but are already at the cap are counted in + * skipped_days (and split the range). Weekend days, when excluded, are out of + * scope and are NOT counted as skipped. + */ +export async function bulkCreateEntries( + input: { + user_ids: number[]; + date_from: string; + date_to: string; + include_weekends: boolean; + project_id?: number | null; + category: string; + note: string; + }, + actorUserId: number, +): Promise< + Result<{ + created_entries: number; + created_days: number; + skipped_days: number; + users: number; + }> +> { + if (input.user_ids.length === 0) { + return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 }; + } + if (input.date_to < input.date_from) { + return { error: "Datum do musí být stejné nebo po datumu od", status: 400 }; + } + const lockFrom = assertNotPastDate(input.date_from, false); + if (lockFrom) return lockFrom; + const lockTo = assertNotPastDate(input.date_to, false); + if (lockTo) return lockTo; + const catErr = await assertActiveCategory(input.category); + if (catErr) return catErr; + + const from = toDateOnly(input.date_from); + const to = toDateOnly(input.date_to); + + // The day list for the whole range (UTC midnights), built once. + const days: Date[] = []; + for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) { + days.push(new Date(d)); + } + + let createdEntries = 0; + let createdDays = 0; + let skippedDays = 0; + + for (const userId of input.user_ids) { + // Per-day cap counts from this user's existing active entries. + 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 }, + }); + const countFor = (day: Date): number => + existing.filter((e) => e.date_from <= day && e.date_to >= day).length; + + // Eligibility per day (weekday filter + under-cap). Tally cap skips. + const eligible: boolean[] = []; + for (const day of days) { + const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat + const isWeekend = dow === 0 || dow === 6; + if (isWeekend && !input.include_weekends) { + eligible.push(false); + continue; + } + if (countFor(day) >= MAX_RECORDS_PER_CELL) { + skippedDays++; + eligible.push(false); + continue; + } + eligible.push(true); + } + + // Group contiguous eligible days into runs; create one range per run. + let runStart = -1; + for (let i = 0; i <= days.length; i++) { + const ok = i < days.length && eligible[i]; + if (ok && runStart === -1) { + runStart = i; + } else if (!ok && runStart !== -1) { + const segFrom = days[runStart].toISOString().slice(0, 10); + const segTo = days[i - 1].toISOString().slice(0, 10); + const runDays = i - runStart; + const res = await createEntry( + { + user_id: userId, + date_from: segFrom, + date_to: segTo, + project_id: input.project_id ?? null, + category: input.category, + note: input.note, + }, + actorUserId, + false, + ); + if ("data" in res) { + createdEntries++; + createdDays += runDays; + } else { + // Rare race (e.g. the cap filled concurrently). Treat as skipped. + skippedDays += runDays; + } + runStart = -1; + } + } + } + + return { + data: { + created_entries: createdEntries, + created_days: createdDays, + skipped_days: skippedDays, + users: input.user_ids.length, + }, + oldData: null, + }; +} +``` + +- [ ] **Step 4: Run the service tests to verify they pass** + +Run: `npx vitest run src/__tests__/plan.test.ts` +Expected: PASS (including the 5 new bulk tests). + +- [ ] **Step 5: Add `BulkPlanEntrySchema` to `plan.schema.ts`** + +Append to `src/schemas/plan.schema.ts`: + +```ts +export const BulkPlanEntrySchema = z + .object({ + user_ids: z + .array(intFromForm) + .min(1, "Vyberte alespoň jednoho zaměstnance"), + date_from: isoDate, + date_to: isoDate, + include_weekends: z + .union([z.boolean(), z.string()]) + .transform((v) => v === true || v === "true" || v === "1") + .default(false), + project_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .nullish(), + category: planCategoryEnum.default("work"), + note: z.string().max(500, "Maximálně 500 znaků").default(""), + }) + .refine((v) => v.date_to >= v.date_from, { + message: "Datum do musí být stejné nebo po datumu od", + path: ["date_to"], + }); +``` + +- [ ] **Step 6: Add the `POST /entries/bulk` route** + +In `src/routes/admin/plan.ts`: add `bulkCreateEntries` to the existing import from `../../services/plan.service` and `BulkPlanEntrySchema` to the import from `../../schemas/plan.schema`. Then add this route immediately after the `POST /entries` handler (after its closing `);`, before `PATCH /entries/:id`): + +```ts +// --- POST /plan/entries/bulk --- +app.post( + "/entries/bulk", + { preHandler: requirePermission("attendance.manage") }, + async (request: FastifyRequest, reply: FastifyReply) => { + const body = parseBody(BulkPlanEntrySchema, request.body); + if ("error" in body) return error(reply, body.error, 400); + const result = await bulkCreateEntries( + body.data!, + request.authData!.userId, + ); + if ("error" in result) return error(reply, result.error, result.status); + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "work_plan_entry", + description: `Hromadné přiřazení: ${body.data!.category} — ${result.data.users} zaměstnanců, ${body.data!.date_from}–${body.data!.date_to} (${result.data.created_days} dní)`, + }); + return success(reply, result.data, 201, "Hromadné přiřazení dokončeno"); + }, +); +``` + +- [ ] **Step 7: Add an HTTP route test** + +Add to the `describe("HTTP /api/admin/plan", ...)` block in `src/__tests__/plan.test.ts`: + +```ts +it("POST /entries/bulk requires attendance.manage", async () => { + const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, { + user_ids: [adminUserId], + date_from: "2095-06-01", + date_to: "2095-06-01", + include_weekends: true, + category: "work", + note: `${N}bulk-forbidden`, + }); + expect(res.statusCode).toBe(403); +}); + +it("POST /entries/bulk creates entries for an admin and returns a summary", async () => { + const res = await authPost("/api/admin/plan/entries/bulk", adminToken, { + user_ids: [adminUserId], + date_from: "2095-06-05", // Sunday; weekends on → 1 day + date_to: "2095-06-05", + include_weekends: true, + category: "work", + note: `${N}bulk-http`, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data.created_days).toBe(1); + expect(body.data.users).toBe(1); +}); +``` + +- [ ] **Step 8: Gate** + +Run: `npx tsc -b --noEmit` (exit 0), then `npx vitest run` (all pass). + +- [ ] **Step 9: Commit** + +```bash +git add src/schemas/plan.schema.ts src/services/plan.service.ts src/routes/admin/plan.ts src/__tests__/plan.test.ts +git commit -m "feat(plan): bulk entry creation endpoint + service" +``` + +--- + +## Task 2: Frontend — bulk modal + wiring + +**Files:** + +- Create: `src/admin/components/BulkPlanModal.tsx` +- Modify: `src/admin/hooks/usePlanWork.ts` +- Modify: `src/admin/pages/PlanWork.tsx` + +Frontend-only; gate on `tsc -b` + `build` (no component-test harness). Mirrors `BulkAttendanceModal.tsx` (presentational; parent owns state + toggles + submit). + +- [ ] **Step 1: Create `BulkPlanModal.tsx`** + +```tsx +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { + Modal, + Field, + DateField, + Select, + TextField, + CheckboxField, +} from "../ui"; +import type { PlanUser, PlanCategory } from "../lib/queries/plan"; +import type { Project } from "../lib/queries/projects"; + +export interface BulkPlanForm { + date_from: string; + date_to: string; + is_range: boolean; + user_ids: string[]; + include_weekends: boolean; + project_id: string; + category: string; + note: string; +} + +interface Props { + isOpen: boolean; + onClose: () => void; + form: BulkPlanForm; + setForm: (form: BulkPlanForm) => void; + users: PlanUser[]; + projects: Project[]; + categories: PlanCategory[]; + onSubmit: () => void; + submitting: boolean; + toggleUser: (userId: number) => void; + toggleAllUsers: () => void; +} + +export default function BulkPlanModal({ + isOpen, + onClose, + form, + setForm, + users, + projects, + categories, + onSubmit, + submitting, + toggleUser, + toggleAllUsers, +}: Props) { + const activeCategories = categories.filter((c) => c.is_active); + return ( + + + Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které už + mají 3 záznamy, se přeskočí. + + + + setForm({ ...form, date_from: val })} + /> + + + setForm({ ...form, is_range: checked })} + /> + + {form.is_range && ( + + setForm({ ...form, date_to: val })} + /> + + )} + + + setForm({ ...form, include_weekends: checked }) + } + /> + + + + + + Zaměstnanci + + + {form.user_ids.length === users.length + ? "Odznačit vše" + : "Vybrat vše"} + + + + {users.map((u) => ( + toggleUser(u.id)} + /> + ))} + + + Vybráno: {form.user_ids.length} z {users.length} + + + + + setForm({ ...form, category: val })} + options={activeCategories.map((c) => ({ + value: c.key, + label: c.label, + }))} + /> + + + setForm({ ...form, note: e.target.value })} + inputProps={{ maxLength: 500 }} + /> + + + ); +} +``` + +- [ ] **Step 2: Add a `bulkCreate` mutation to `usePlanWork.ts`** + +After the `deleteOverride` mutation definition (before the `rollbackMutation` function), add: + +```ts +const bulkCreate = useMutation({ + mutationFn: (body: any) => + apiCall("/api/admin/plan/entries/bulk", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + onSuccess: () => { + // No optimistic patch — bulk can touch many cells; just invalidate and + // let the grid refetch the authoritative state. + invalidate(); + }, +}); +``` + +Add `bulkCreate` to the hook's return object (next to `createEntry`, etc.). + +- [ ] **Step 3: Wire the bulk modal into `PlanWork.tsx`** + +(a) Add imports near the other component imports: + +```ts +import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal"; +``` + +(b) Pull `bulkCreate` out of the `usePlanWork(...)` destructure (alongside `createEntry`, etc.). + +(c) Add bulk state + a default form (place near the other `useState` declarations, after `catModalOpen`): + +```ts +const [bulkOpen, setBulkOpen] = useState(false); +const [bulkSubmitting, setBulkSubmitting] = useState(false); +const [bulkForm, setBulkForm] = useState(() => { + const today = todayIsoLocal(); + return { + date_from: today, + date_to: today, + is_range: false, + user_ids: [], + include_weekends: false, + project_id: "", + category: "work", + note: "", + }; +}); + +const planUsers = grid?.users ?? []; + +const toggleBulkUser = useCallback((userId: number) => { + setBulkForm((prev) => ({ + ...prev, + user_ids: prev.user_ids.includes(String(userId)) + ? prev.user_ids.filter((u) => u !== String(userId)) + : [...prev.user_ids, String(userId)], + })); +}, []); + +const toggleAllBulkUsers = useCallback(() => { + const allIds = (grid?.users ?? []).map((u) => String(u.id)); + setBulkForm((prev) => ({ + ...prev, + user_ids: prev.user_ids.length === allIds.length ? [] : allIds, + })); +}, [grid]); + +const submitBulk = useCallback(async () => { + if (bulkForm.user_ids.length === 0) return; + setBulkSubmitting(true); + try { + const data: any = await bulkCreate.mutateAsync({ + user_ids: bulkForm.user_ids.map(Number), + date_from: bulkForm.date_from, + date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from, + include_weekends: bulkForm.include_weekends, + project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null, + category: bulkForm.category, + note: bulkForm.note, + }); + const skipped = + data?.skipped_days > 0 + ? ` ${data.skipped_days} dní přeskočeno (limit 3 na den).` + : ""; + alert.success( + `Vytvořeno ${data?.created_days ?? 0} záznamů pro ${data?.users ?? 0} zaměstnanců.${skipped}`, + ); + setBulkOpen(false); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při ukládání"); + } finally { + setBulkSubmitting(false); + } +}, [bulkForm, bulkCreate, alert]); +``` + +(d) Add the toolbar button next to "Správa kategorií" (inside the same `canEdit` region of the toolbar ``): + +```tsx +{ + canEdit && ( + + ); +} +``` + +(e) Render the modal alongside ``: + +```tsx + setBulkOpen(false)} + form={bulkForm} + setForm={setBulkForm} + users={planUsers} + projects={projects} + categories={categories} + onSubmit={submitBulk} + submitting={bulkSubmitting} + toggleUser={toggleBulkUser} + toggleAllUsers={toggleAllBulkUsers} +/> +``` + +- [ ] **Step 4: Gate + Chrome check** + +```bash +npx tsc -b --noEmit +npm run build +``` + +Then in Chrome on `/plan-work` (dev server already running — do not start it): click "Hromadné přiřazení", select 2 employees, a date range with "Více dní", leave weekends off, pick a project + category, submit; confirm the grid fills only Mon–Fri across the selected people, the summary alert shows counts, and re-running on a day already at 3 records skips it. + +- [ ] **Step 5: Commit** + +```bash +git add src/admin/components/BulkPlanModal.tsx src/admin/hooks/usePlanWork.ts src/admin/pages/PlanWork.tsx +git commit -m "feat(plan): bulk assignment modal on /plan-work" +``` + +--- + +## Task 3: Release v2.0.6 + +**Files:** + +- Modify: `package.json` + +- [ ] **Step 1: Bump version** to `"version": "2.0.6"` 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: Merge to master + commit + tag** + +```bash +git checkout master +git merge --ff-only feat/bulk-plan-creation +git add package.json +git commit -m "chore(release): v2.0.6 — bulk plan creation" +git tag -a v2.0.6 -m "v2.0.6 — bulk plan creation" +``` + +(If implementation happened directly on master, skip the checkout/merge.) + +- [ ] **Step 4: Push to Gitea** + +```bash +git push origin master +git push origin v2.0.6 +``` + +- [ ] **Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation)** + +```bash +tar -czf app-ts-2.0.6.tar.gz dist dist-client prisma package.json package-lock.json scripts +scp app-ts-2.0.6.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.6.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env' +``` + +No migration ships in this release → `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.6 online. + +--- + +## Notes for the implementer + +- **Reuse, don't re-validate:** `bulkCreateEntries` calls `createEntry`, which already enforces the cap, past-date, category, and range checks. Don't duplicate those. +- **`skipped_days` semantics:** only weekday-eligible days blocked by the cap count as skipped. Weekend days (when excluded) are out of scope, not skipped. +- **No DB migration.** Don't run any prisma migrate command. +- **Do not start the dev server** — the user runs it. Use the running instance for the Chrome check. +- **Do not deploy to production without explicit confirmation** (Task 3, Steps 5–6).