From f71e46ba005c4fe763c15c5ee7a494ce199cf25f Mon Sep 17 00:00:00 2001 From: AdamJ Date: Fri, 23 Oct 2026 13:35:31 -0400 Subject: [PATCH 1/4] docs: add planning view spec and implementation plan --- .../plans/2026-10-23-planning-view.md | 955 ++++++++++++++++++ .../specs/2026-10-23-planning-view-design.md | 186 ++++ 2 files changed, 1141 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-23-planning-view.md create mode 100644 docs/superpowers/specs/2026-10-23-planning-view-design.md diff --git a/docs/superpowers/plans/2026-10-23-planning-view.md b/docs/superpowers/plans/2026-10-23-planning-view.md new file mode 100644 index 0000000..a396353 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-planning-view.md @@ -0,0 +1,955 @@ +# Planning View 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 each existing Project define "initiatives" (goal + deadline + manual status) and view them on a per-project planning timeline, so overlapping work is visible. + +**Architecture:** A new `Initiative` entity following the app's existing `PlannedTask` pattern exactly — same dual-storage shape (localStorage module, Supabase table, self-hosted SQL backend table), same context wiring style (ref + loaded-guard + immediate upsert on mutation), same shared-Sheet-component UI pattern (`ClientSheet`/`ProjectSheet`). A new per-project route renders a status-grouped timeline of that project's initiatives. + +**Tech Stack:** React 18 + TypeScript, Vite, Tailwind + Radix/shadcn, React Hook Form + Zod, Vitest + React Testing Library, Supabase (Postgres), Knex (self-hosted SQL backend). + +**Spec:** [docs/superpowers/specs/2026-10-23-planning-view-design.md](../specs/2026-10-23-planning-view-design.md) + +## Global Constraints + +- 2-space indentation, double quotes always, `@/` import alias only (never relative `../../`) — per `AGENTS.md`. +- Use shadcn/ui components only — no raw HTML with custom styles. +- Colors: semantic tokens or Radix scale classes only (steps 1-2 backgrounds, 3-5 fills, 6-8 borders, 9-10 solid, 11-12 text) — never arbitrary Tailwind palette colors like `bg-blue-500`. +- Standard calendar quarters: Q1 = Jan–Mar, Q2 = Apr–Jun, Q3 = Jul–Sep, Q4 = Oct–Dec (spec decision #5). +- A quarter deadline renders as a single point at the end of that quarter, never a block (spec decision #6). +- An initiative has no explicit start date — it appears on the timeline from `createdAt` through its deadline (spec decision #4). +- Status is manual only: `not_started` | `in_progress` | `done` | `blocked` (spec decision #3) — never derived from tasks. +- No cross-project view; no zoom/scale switching in this plan (spec Non-goals). +- `pnpm check:design-tokens`, `pnpm lint:markdown`, and `pnpm check:a11y` must pass before any commit (`AGENTS.md`). +- Never add a "Co-Authored-By:" line to any commit (`AGENTS.md`). + +## Review Focus + +- **Quarter deadline with no year, or year with no quarter** (partial deadline data from a bug or manual storage edit) — `resolveInitiativeDeadline` must not throw; it's the one function everything else (timeline positioning, sheet display) depends on. Covered in Task 1. +- **Deadline date entered earlier than the initiative's `createdAt`** — a reasonable person expects the save to be rejected with a clear message, not a silently inverted/zero-width timeline bar. Covered in Task 6. +- **Deleting a project that has initiatives** — a reasonable person expects no orphaned data left behind (guest mode has no DB cascade to rely on). Covered in Task 5. +- **Navigating to `/projects/:id/planning` for an id that doesn't exist** (stale bookmark, deleted/archived project) — a reasonable person expects a clear "not found" state, not a blank page or crash. Covered in Task 8. +- **A project with zero initiatives** — the timeline and its grouped swimlanes must render a sensible empty state rather than an empty/broken grid. Covered in Task 7. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/contexts/TimeTrackingContext.tsx` (modify) | `Initiative`/`InitiativeStatus`/`InitiativeDeadlineType` types; `initiatives` state + CRUD context methods; cascade-delete on `deleteProject` | +| `src/utils/initiativeUtils.ts` (create) | `resolveInitiativeDeadline`, `getQuarterEndDate`, `isDeadlineBeforeCreation` — pure date helpers shared by the Sheet and the Timeline | +| `src/services/dataService.ts` (modify) | `DataService` interface: `saveInitiatives`/`getInitiatives`/`upsertInitiative`/`deleteInitiative` | +| `src/services/localStorageService/constants.ts` (modify) | `STORAGE_KEYS.INITIATIVES` | +| `src/services/localStorageService/initiatives.ts` (create) | Guest-mode CRUD, mirrors `plannedTasks.ts` | +| `src/services/localStorageService/index.ts` (modify) | Wire the new module into `LocalStorageService` | +| `supabase/migrations/20261023_planning_initiatives.sql` (create) | `planning_initiatives` table + RLS | +| `src/services/supabaseService.ts` (modify) | Authenticated-mode CRUD + migration (`migrateFromLocalStorage`/`migrateToLocalStorage`) updates | +| `server/schema.ts` (modify) | `planning_initiatives` table for the self-hosted SQL backend | +| `server/repositories/initiatives.ts` (create) | Knex CRUD, mirrors `repositories/plannedTasks.ts` | +| `server/app.ts` (modify) | `/api/initiatives` routes | +| `src/services/sqlApiService.ts` (modify) | REST CRUD, mirrors its `PlannedTask` methods | +| `src/components/InitiativeSheet.tsx` (create) | Add/edit Sheet: name, goal, status, deadline (date or quarter) | +| `src/components/InitiativeTimeline.tsx` (create) | Status-grouped, month-scale timeline with a "Today" line | +| `src/pages/ProjectPlanning.tsx` (create) | Per-project planning page (`PageLayout` + `InitiativeTimeline` + add/edit) | +| `src/App.tsx` (modify) | `/projects/:id/planning` route | +| `src/components/ProjectList.tsx` (modify) | "Planning" link per project row | + +--- + +### Task 1: Initiative types and date-resolution utilities + +**Files:** +- Modify: `src/contexts/TimeTrackingContext.tsx` (add type exports near the existing `Project`/`Client` interfaces, ~line 70) +- Create: `src/utils/initiativeUtils.ts` +- Test: `src/utils/initiativeUtils.test.ts` + +**Interfaces:** +- Produces: + ```ts + export type InitiativeStatus = "not_started" | "in_progress" | "done" | "blocked"; + export type InitiativeDeadlineType = "date" | "quarter"; + + export interface Initiative { + id: string; + projectId: string; + name: string; + goal: string; + status: InitiativeStatus; + deadlineType: InitiativeDeadlineType; + deadlineDate?: string; // ISO date string; set when deadlineType === "date" + deadlineQuarter?: number; // 1-4; set when deadlineType === "quarter" + deadlineYear?: number; // set when deadlineType === "quarter" + createdAt: string; + updatedAt: string; + } + ``` + ```ts + // src/utils/initiativeUtils.ts + export function getQuarterEndDate(quarter: number, year: number): Date; + export function resolveInitiativeDeadline(initiative: Initiative): Date | null; + export function isDeadlineBeforeCreation(createdAt: string, deadline: Date): boolean; + ``` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/utils/initiativeUtils.test.ts +import { describe, it, expect } from "vitest"; +import { getQuarterEndDate, resolveInitiativeDeadline, isDeadlineBeforeCreation } from "./initiativeUtils"; +import type { Initiative } from "@/contexts/TimeTrackingContext"; + +describe("getQuarterEndDate", () => { + it("returns Mar 31 for Q1", () => { + const d = getQuarterEndDate(1, 2027); + expect(d.getUTCFullYear()).toBe(2027); + expect(d.getUTCMonth()).toBe(2); // 0-indexed: March + expect(d.getUTCDate()).toBe(31); + }); + + it("returns Dec 31 for Q4", () => { + const d = getQuarterEndDate(4, 2026); + expect(d.getUTCMonth()).toBe(11); + expect(d.getUTCDate()).toBe(31); + }); +}); + +describe("resolveInitiativeDeadline", () => { + const base: Initiative = { + id: "i1", projectId: "p1", name: "n", goal: "g", + status: "not_started", deadlineType: "date", + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }; + + it("resolves a date deadline directly", () => { + const result = resolveInitiativeDeadline({ ...base, deadlineDate: "2026-12-15" }); + expect(result?.toISOString().slice(0, 10)).toBe("2026-12-15"); + }); + + it("resolves a quarter deadline to that quarter's end date", () => { + const result = resolveInitiativeDeadline({ + ...base, deadlineType: "quarter", deadlineQuarter: 1, deadlineYear: 2027 + }); + expect(result?.getUTCMonth()).toBe(2); + expect(result?.getUTCDate()).toBe(31); + }); + + it("returns null for a quarter deadline missing its year", () => { + const result = resolveInitiativeDeadline({ + ...base, deadlineType: "quarter", deadlineQuarter: 1, deadlineYear: undefined + }); + expect(result).toBeNull(); + }); + + it("returns null for a quarter deadline missing its quarter", () => { + const result = resolveInitiativeDeadline({ + ...base, deadlineType: "quarter", deadlineQuarter: undefined, deadlineYear: 2027 + }); + expect(result).toBeNull(); + }); + + it("returns null for a date deadline missing its date", () => { + const result = resolveInitiativeDeadline({ ...base, deadlineDate: undefined }); + expect(result).toBeNull(); + }); +}); + +describe("isDeadlineBeforeCreation", () => { + it("is true when the deadline falls before the creation date", () => { + expect(isDeadlineBeforeCreation("2026-10-20T00:00:00.000Z", new Date("2026-10-10"))).toBe(true); + }); + + it("is false when the deadline is on or after the creation date", () => { + expect(isDeadlineBeforeCreation("2026-10-20T00:00:00.000Z", new Date("2026-10-20"))).toBe(false); + expect(isDeadlineBeforeCreation("2026-10-20T00:00:00.000Z", new Date("2026-11-01"))).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/utils/initiativeUtils.test.ts` +Expected: FAIL — `initiativeUtils` module not found. + +- [ ] **Step 3: Add the `InitiativeStatus`/`InitiativeDeadlineType`/`Initiative` exports to `TimeTrackingContext.tsx`** + +Place directly after the `Client` interface (~line 84), matching the exact shape in Interfaces above. + +- [ ] **Step 4: Implement `src/utils/initiativeUtils.ts`** + +`getQuarterEndDate(quarter, year)`: the last calendar day of that quarter, as a UTC `Date` (quarter 1→Mar 31, 2→Jun 30, 3→Sep 30, 4→Dec 31 — standard calendar quarters per Global Constraints). +`resolveInitiativeDeadline(initiative)`: for `deadlineType === "date"`, parse `deadlineDate` (return `null` if absent); for `"quarter"`, return `null` if `deadlineQuarter` or `deadlineYear` is missing, otherwise `getQuarterEndDate(deadlineQuarter, deadlineYear)`. +`isDeadlineBeforeCreation(createdAt, deadline)`: `deadline < new Date(createdAt)` (strict — equal is allowed, same convention as `isEndBeforeStart` in `src/utils/timeUtil.ts`). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm vitest run src/utils/initiativeUtils.test.ts` +Expected: PASS + +- [ ] **Step 6: Typecheck** + +Run: `pnpm build` +Expected: no type errors + +- [ ] **Step 7: Commit** + +```bash +git add src/contexts/TimeTrackingContext.tsx src/utils/initiativeUtils.ts src/utils/initiativeUtils.test.ts +git commit -m "feat: add Initiative types and deadline-resolution utilities" +``` + +--- + +### Task 2: Guest-mode (localStorage) persistence + +**Files:** +- Modify: `src/services/dataService.ts` +- Modify: `src/services/localStorageService/constants.ts` +- Create: `src/services/localStorageService/initiatives.ts` +- Modify: `src/services/localStorageService/index.ts` +- Test: `src/services/dataService.test.ts` (new `describe("initiatives")` block) + +**Interfaces:** +- Consumes: `Initiative` type (Task 1) +- Produces (added to `DataService`, implemented by `LocalStorageService`): + ```ts + saveInitiatives: (initiatives: Initiative[]) => Promise; + getInitiatives: () => Promise; + upsertInitiative: (initiative: Initiative) => Promise; + deleteInitiative: (id: string) => Promise; + ``` + +- [ ] **Step 1: Write the failing test** + +```ts +// in src/services/dataService.test.ts, alongside the existing describe('clients') block +describe("initiatives", () => { + it("round-trips an initiative with a date deadline", async () => { + const service = createDataService(false); + const initiative: import("@/contexts/TimeTrackingContext").Initiative = { + id: "init-1", projectId: "proj-1", name: "Redesign onboarding", + goal: "Cut signup drop-off in half", status: "in_progress", + deadlineType: "date", deadlineDate: "2026-12-15", + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }; + + await service.saveInitiatives([initiative]); + const loaded = await service.getInitiatives(); + + expect(loaded).toHaveLength(1); + expect(loaded[0].name).toBe("Redesign onboarding"); + expect(loaded[0].deadlineDate).toBe("2026-12-15"); + }); + + it("upsertInitiative inserts, then updates in place", async () => { + const service = createDataService(false); + const base: import("@/contexts/TimeTrackingContext").Initiative = { + id: "init-2", projectId: "proj-1", name: "Design system v3", + goal: "Ship v3 tokens", status: "not_started", + deadlineType: "quarter", deadlineQuarter: 1, deadlineYear: 2027, + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }; + + await service.upsertInitiative(base); + await service.upsertInitiative({ ...base, status: "in_progress" }); + const loaded = await service.getInitiatives(); + + expect(loaded).toHaveLength(1); + expect(loaded[0].status).toBe("in_progress"); + }); + + it("deleteInitiative removes a single row", async () => { + const service = createDataService(false); + const initiative: import("@/contexts/TimeTrackingContext").Initiative = { + id: "init-3", projectId: "proj-1", name: "To delete", + goal: "g", status: "not_started", deadlineType: "date", deadlineDate: "2026-11-01", + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }; + + await service.upsertInitiative(initiative); + await service.deleteInitiative("init-3"); + const loaded = await service.getInitiatives(); + + expect(loaded).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/services/dataService.test.ts -t initiatives` +Expected: FAIL — `saveInitiatives`/`getInitiatives`/`upsertInitiative`/`deleteInitiative` not defined on `DataService`. + +- [ ] **Step 3: Add `STORAGE_KEYS.INITIATIVES = "timetracker_initiatives"` to `constants.ts`** + +- [ ] **Step 4: Implement `src/services/localStorageService/initiatives.ts`** + +Copy the exact structure of `plannedTasks.ts` (Read it for the body: `writeVersioned`/`readVersioned`/`notifyWriteFailure` calls), substituting `Initiative`/`STORAGE_KEYS.INITIATIVES`/`initiatives`. + +- [ ] **Step 5: Add the four methods to the `DataService` interface in `dataService.ts`** (new "Initiatives operations" section, same shape as the "Planned tasks operations" section already there) + +- [ ] **Step 6: Wire the module into `LocalStorageService` in `index.ts`** (import + four one-line delegating methods, same as the existing `savePlannedTasks`/`getPlannedTasks`/`upsertPlannedTask`/`deletePlannedTask` delegations) + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `pnpm vitest run src/services/dataService.test.ts` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/services/dataService.ts src/services/localStorageService/constants.ts src/services/localStorageService/initiatives.ts src/services/localStorageService/index.ts src/services/dataService.test.ts +git commit -m "feat: add guest-mode persistence for initiatives" +``` + +--- + +### Task 3: Supabase (authenticated-mode) persistence + +**Files:** +- Create: `supabase/migrations/20261023_planning_initiatives.sql` +- Modify: `src/services/supabaseService.ts` +- Test: `src/services/supabaseService.test.ts` (new `describe("initiatives")` block + `getInitiatives` added to the hoisted `localServiceMocks`) + +**Interfaces:** +- Consumes: `Initiative` type (Task 1); `DataService` interface (Task 2) +- Produces: `SupabaseService.saveInitiatives`/`getInitiatives`/`upsertInitiative`/`deleteInitiative`, matching the `DataService` signatures from Task 2. + +- [ ] **Step 1: Write the migration** + +```sql +-- supabase/migrations/20261023_planning_initiatives.sql +CREATE TABLE IF NOT EXISTS planning_initiatives ( + id text PRIMARY KEY, + user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, + project_id text REFERENCES projects(id) ON DELETE CASCADE NOT NULL, + name text NOT NULL, + goal text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'not_started', + deadline_type text NOT NULL DEFAULT 'date', + deadline_date date, + deadline_quarter integer, + deadline_year integer, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_planning_initiatives_user_id ON planning_initiatives(user_id); +CREATE INDEX IF NOT EXISTS idx_planning_initiatives_project_id ON planning_initiatives(project_id); + +CREATE TRIGGER trg_update_planning_initiatives_updated_at +BEFORE UPDATE ON planning_initiatives +FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + +ALTER TABLE planning_initiatives ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own initiatives" + ON planning_initiatives FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own initiatives" + ON planning_initiatives FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own initiatives" + ON planning_initiatives FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own initiatives" + ON planning_initiatives FOR DELETE USING (auth.uid() = user_id); +``` + +- [ ] **Step 2: Write the failing tests** + +```ts +// in src/services/supabaseService.test.ts +// Add to the hoisted localServiceMocks: getInitiatives: vi.fn(async () => []) + +describe("saveInitiatives", () => { + it("upserts a stale/partial snapshot without deleting rows missing from it", async () => { + const initiatives: Initiative[] = [{ + id: "i1", projectId: "proj-1", name: "Redesign onboarding", goal: "g", + status: "in_progress", deadlineType: "date", deadlineDate: "2026-12-15", + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }]; + await service.saveInitiatives(initiatives); + + expect(fromMock).toHaveBeenCalledWith("planning_initiatives"); + expect(builder.delete).not.toHaveBeenCalled(); + expect(builder.upsert).toHaveBeenCalledWith( + [expect.objectContaining({ id: "i1", user_id: "user-1", project_id: "proj-1", name: "Redesign onboarding" })], + { onConflict: "id" } + ); + }); + + it("is a no-op for an empty array", async () => { + await service.saveInitiatives([]); + expect(fromMock).not.toHaveBeenCalledWith("planning_initiatives"); + }); +}); + +describe("upsertInitiative / deleteInitiative", () => { + it("upserts a single initiative row", async () => { + const initiative: Initiative = { + id: "i2", projectId: "proj-1", name: "Design system v3", goal: "g", + status: "not_started", deadlineType: "quarter", deadlineQuarter: 1, deadlineYear: 2027, + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }; + await service.upsertInitiative(initiative); + + expect(builder.upsert).toHaveBeenCalledWith( + expect.objectContaining({ id: "i2", deadline_quarter: 1, deadline_year: 2027 }), + { onConflict: "id" } + ); + }); + + it("deletes a single initiative row scoped to the user", async () => { + await service.deleteInitiative("i2"); + expect(builder.delete).toHaveBeenCalled(); + expect(builder.eq).toHaveBeenCalledWith("id", "i2"); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `pnpm vitest run src/services/supabaseService.test.ts -t initiatives` +Expected: FAIL — methods not defined on `SupabaseService`. + +- [ ] **Step 4: Implement the four methods in `supabaseService.ts`** + +Copy the exact structure of `savePlannedTasks`/`upsertPlannedTask`/`deletePlannedTask`/`getPlannedTasks` (read those for the body), substituting the table name `planning_initiatives` and the row shape: `project_id`, `name`, `goal`, `status`, `deadline_type`, `deadline_date`, `deadline_quarter`, `deadline_year`. Include `trackDbCall` calls matching the existing convention. + +- [ ] **Step 5: Update `migrateFromLocalStorage` and `migrateToLocalStorage`** + +Mirror the existing `plannedTasks` handling exactly: load `localService.getInitiatives()`, compute `hasInitiatives`, include it in the "nothing to migrate" early-return check, and in the `hasExistingData` branch only migrate if `(await this.getInitiatives()).length === 0` (same stale-overwrite guard as planned tasks); in the no-existing-data branch, save unconditionally. In `migrateToLocalStorage`, save initiatives to `localService` the same way `plannedTasks` are saved there. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `pnpm vitest run src/services/supabaseService.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add supabase/migrations/20261023_planning_initiatives.sql src/services/supabaseService.ts src/services/supabaseService.test.ts +git commit -m "feat: add Supabase persistence and migration for initiatives" +``` + +--- + +### Task 4: Self-hosted SQL backend parity + +**Files:** +- Modify: `server/schema.ts` +- Create: `server/repositories/initiatives.ts` +- Modify: `server/app.ts` +- Modify: `src/services/sqlApiService.ts` + +**Interfaces:** +- Consumes: `Initiative` type (Task 1); `DataService` interface (Task 2) +- Produces: `SqlApiService.saveInitiatives`/`getInitiatives`/`upsertInitiative`/`deleteInitiative` + +No test step: `server/` has no existing test suite to extend (confirmed — no test files exist under `server/`, and none are listed in `AGENTS.md`'s test inventory), so this task matches the project's existing untested convention for that directory. + +- [ ] **Step 1: Add the `planning_initiatives` table to `server/schema.ts`** + +Follow the existing single-tenant style (no `user_id`) used by every other table there. Columns: `id` (string, primary), `project_id` (string), `name` (string, not nullable), `goal` (text), `status` (string, default `"not_started"`), `deadline_type` (string, default `"date"`), `deadline_date` (string/date), `deadline_quarter` (integer), `deadline_year` (integer), `created_at` (timestamp, not nullable), `updated_at` (timestamp, not nullable, default now). + +- [ ] **Step 2: Implement `server/repositories/initiatives.ts`** + +Copy the exact structure of `repositories/plannedTasks.ts` (`mapRow`/`taskToRow`-equivalent/`getInitiatives`/`saveInitiatives`/`upsertInitiative`/`deleteInitiative`), substituting the `planning_initiatives` table and field names from Step 1. + +- [ ] **Step 3: Add routes to `server/app.ts`** + +Mirror the four `/api/planned-tasks` routes exactly, at `/api/initiatives`: +```ts +app.get("/api/initiatives", wrap(async (_req, res) => { res.json(await initiatives.getInitiatives()); })); +app.put("/api/initiatives", wrap(async (req, res) => { await initiatives.saveInitiatives(req.body); res.status(204).end(); })); +app.put("/api/initiatives/:id", wrap(async (req, res) => { await initiatives.upsertInitiative(req.body); res.status(204).end(); })); +app.delete("/api/initiatives/:id", wrap(async (req, res) => { await initiatives.deleteInitiative(String(req.params.id)); res.status(204).end(); })); +``` + +- [ ] **Step 4: Implement the four methods in `sqlApiService.ts`** + +Copy the exact structure of its `savePlannedTasks`/`getPlannedTasks`/`upsertPlannedTask`/`deletePlannedTask` methods, substituting the `/initiatives` path. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm build` +Expected: no type errors + +- [ ] **Step 6: Commit** + +```bash +git add server/schema.ts server/repositories/initiatives.ts server/app.ts src/services/sqlApiService.ts +git commit -m "feat: add self-hosted SQL backend parity for initiatives" +``` + +--- + +### Task 5: Context wiring (state, CRUD methods, cascade delete) + +**Files:** +- Modify: `src/contexts/TimeTrackingContext.tsx` +- Test: `src/contexts/TimeTracking.test.tsx` (new `describe("Initiatives")` block + a case added to the existing project-deletion coverage) + +**Interfaces:** +- Consumes: `Initiative` type (Task 1); `DataService.{save,get,upsert,delete}Initiatives` (Tasks 2-4) +- Produces (added to `TimeTrackingContextType` and the provider value): + ```ts + initiatives: Initiative[]; + addInitiative: (data: Omit) => Initiative; + updateInitiative: (id: string, updates: Partial) => Initiative | null; + deleteInitiative: (id: string) => void; + restoreDeletedInitiative: (initiative: Initiative) => void; + ``` + +- [ ] **Step 1: Write the failing tests** + +```tsx +// in src/contexts/TimeTracking.test.tsx +describe("Initiatives", () => { + it("adds an initiative with status not_started", async () => { + const { result } = renderHook(() => useTimeTracking(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + result.current.addInitiative({ + projectId: "proj-1", name: "Redesign onboarding", goal: "Cut drop-off", + deadlineType: "date", deadlineDate: "2026-12-15" + }); + }); + + await waitFor(() => { + expect(result.current.initiatives).toHaveLength(1); + expect(result.current.initiatives[0].status).toBe("not_started"); + expect(result.current.initiatives[0].id).toBeTruthy(); + }); + }); + + it("updates an initiative's status", async () => { + const { result } = renderHook(() => useTimeTracking(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + result.current.addInitiative({ + projectId: "proj-1", name: "Design system v3", goal: "g", + deadlineType: "quarter", deadlineQuarter: 1, deadlineYear: 2027 + }); + }); + let id: string; + await waitFor(() => { id = result.current.initiatives[0].id; }); + + await act(async () => { + result.current.updateInitiative(id, { status: "in_progress" }); + }); + + await waitFor(() => { + expect(result.current.initiatives[0].status).toBe("in_progress"); + }); + }); + + it("deletes an initiative", async () => { + const { result } = renderHook(() => useTimeTracking(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + result.current.addInitiative({ + projectId: "proj-1", name: "To delete", goal: "g", + deadlineType: "date", deadlineDate: "2026-11-01" + }); + }); + let id: string; + await waitFor(() => { id = result.current.initiatives[0].id; }); + + await act(async () => { result.current.deleteInitiative(id); }); + + await waitFor(() => { expect(result.current.initiatives).toHaveLength(0); }); + }); + + it("restores a deleted initiative with its original id preserved", async () => { + const { result } = renderHook(() => useTimeTracking(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + result.current.addInitiative({ + projectId: "proj-1", name: "To delete and restore", goal: "g", + deadlineType: "date", deadlineDate: "2026-11-01" + }); + }); + let deleted: Initiative; + await waitFor(() => { deleted = result.current.initiatives[0]; }); + await act(async () => { result.current.deleteInitiative(deleted.id); }); + await waitFor(() => { expect(result.current.initiatives).toHaveLength(0); }); + + await act(async () => { result.current.restoreDeletedInitiative(deleted); }); + + await waitFor(() => { + expect(result.current.initiatives).toHaveLength(1); + expect(result.current.initiatives[0].id).toBe(deleted.id); + }); + }); + + it("deleteProject removes that project's initiatives too", async () => { + const { result } = renderHook(() => useTimeTracking(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + result.current.addProject({ name: "Temp Project", client: "Acme" }); + }); + let projectId: string; + await waitFor(() => { projectId = result.current.projects.find(p => p.name === "Temp Project")!.id; }); + + await act(async () => { + result.current.addInitiative({ + projectId, name: "Orphan risk", goal: "g", deadlineType: "date", deadlineDate: "2026-11-01" + }); + }); + await waitFor(() => { expect(result.current.initiatives).toHaveLength(1); }); + + await act(async () => { result.current.deleteProject(projectId); }); + + await waitFor(() => { expect(result.current.initiatives).toHaveLength(0); }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/contexts/TimeTracking.test.tsx -t Initiatives` +Expected: FAIL — `addInitiative` etc. not defined on the context. + +- [ ] **Step 3: Add state and refs** + +Add `const [initiatives, setInitiatives] = useState([]);`, `const initiativesRef = useRef([]);`, `const initiativesLoadedRef = useRef(false);` alongside the existing `plannedTasks`/`plannedTasksRef`/`plannedLoadedRef` declarations (~lines 306-321). + +- [ ] **Step 4: Load initiatives in the `loadData` effect** + +Immediately after the planned-tasks load block (~line 481), add: +```ts +const loadedInitiatives = await dataService.getInitiatives(); +initiativesRef.current = loadedInitiatives; +setInitiatives(loadedInitiatives); +``` +Add `initiativesLoadedRef.current = true;` next to the existing `plannedLoadedRef.current = true;` (~line 506). In the post-migration reload block (~lines 494-501), add the equivalent re-fetch-and-set-state for initiatives after `dataService.migrateFromLocalStorage()`. + +- [ ] **Step 5: Implement `addInitiative`/`updateInitiative`/`deleteInitiative`/`restoreDeletedInitiative`** + +Copy the exact structure of `addPlannedTask`/`updatePlannedTask`/`deletePlannedTask`/`restoreDeletedPlannedTask` (read those, ~lines 1562-1604), substituting `Initiative`/`initiativesRef`/`setInitiatives`/`initiativesLoadedRef`/`upsertInitiative`/`deleteInitiative`. `addInitiative` sets `status: "not_started"` (not `"todo"`) and a fresh `id`/`createdAt`/`updatedAt`; `updateInitiative` returns the updated `Initiative` (or `null` if not found, matching `updateClient`'s return shape) and refreshes `updatedAt`. + +- [ ] **Step 6: Cascade-delete on `deleteProject`** + +Modify `deleteProject` (~line 1086): after filtering `projectsRef.current`, also filter `initiativesRef.current` down to initiatives whose `projectId !== projectId`, update `initiativesRef.current`/`setInitiatives`, and persist the removal — for each removed initiative, `void dataServiceRef.current?.deleteInitiative(initiative.id)` (single-row deletes, consistent with how `deleteProject` itself persists via a single-row `deleteProject` call rather than a full-list save). + +- [ ] **Step 7: Add the new fields/methods to `TimeTrackingContextType` and the provider's returned value** (both the memoized context value object and its dependency array, matching how `plannedTasks`/`addPlannedTask`/etc. are already listed there) + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `pnpm vitest run src/contexts/TimeTracking.test.tsx` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/contexts/TimeTrackingContext.tsx src/contexts/TimeTracking.test.tsx +git commit -m "feat: wire initiatives into TimeTrackingContext with cascade delete" +``` + +--- + +### Task 6: InitiativeSheet (add/edit form) + +**Files:** +- Create: `src/components/InitiativeSheet.tsx` +- Test: `src/components/InitiativeSheet.test.tsx` + +**Interfaces:** +- Consumes: `useTimeTracking().addInitiative`/`updateInitiative` (Task 5); `isDeadlineBeforeCreation` (Task 1); `ResponsiveSelect` (existing, `src/components/ui/responsive-select.tsx`) +- Produces: + ```ts + interface InitiativeSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "add" | "edit"; + projectId: string; + initiative?: Initiative; + } + export const InitiativeSheet: React.FC; + ``` + +- [ ] **Step 1: Write the failing tests** + +```tsx +// src/components/InitiativeSheet.test.tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { InitiativeSheet } from "./InitiativeSheet"; + +const addInitiativeMock = vi.fn(() => ({ id: "new-id" })); +const updateInitiativeMock = vi.fn(); + +vi.mock("@/hooks/useTimeTracking", () => ({ + useTimeTracking: () => ({ + addInitiative: addInitiativeMock, + updateInitiative: updateInitiativeMock + }) +})); + +describe("InitiativeSheet", () => { + it("renders the Add Initiative title in add mode", () => { + render( {}} mode="add" projectId="proj-1" />); + expect(screen.getByText("Add Initiative")).toBeInTheDocument(); + }); + + it("requires a name before submitting", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("rejects a date deadline before today when adding", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Backdated" } }); + fireEvent.change(screen.getByLabelText(/deadline date/i), { target: { value: "2000-01-01" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/deadline must be on or after/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("submits a valid date-deadline initiative", async () => { + const onOpenChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Redesign onboarding" } }); + fireEvent.change(screen.getByLabelText(/goal/i), { target: { value: "Cut drop-off" } }); + fireEvent.change(screen.getByLabelText(/deadline date/i), { target: { value: "2099-12-15" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(addInitiativeMock).toHaveBeenCalledWith(expect.objectContaining({ + projectId: "proj-1", name: "Redesign onboarding", deadlineType: "date", deadlineDate: "2099-12-15" + })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/components/InitiativeSheet.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `InitiativeSheet.tsx`** + +Mirror `ClientSheet.tsx`'s structure (`Sheet`/`Form`/`FormField`/`zodResolver`): fields are `name` (required `Input`), `goal` (`Textarea`), `status` (`ResponsiveSelect` with options `not_started`/`in_progress`/`done`/`blocked`, labeled "Not started"/"In progress"/"Done"/"Blocked"), a deadline-type toggle (two buttons or a `ResponsiveSelect` with options `date`/`quarter`) that conditionally renders either a date `Input[type=date]` (labeled "Deadline date") or a quarter `ResponsiveSelect` (1-4) + year `Input[type=number]` (labeled "Deadline quarter"/"Deadline year"). On submit, build the `Initiative`-shaped payload and call `addInitiative`/`updateInitiative` from `useTimeTracking()` (same add/edit branch as `ClientSheet`'s `onSubmit`). Validate with a Zod `.refine()` that calls `isDeadlineBeforeCreation` against `initiative?.createdAt ?? new Date().toISOString()`, with message "Deadline must be on or after the initiative's creation date." when it fails. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/components/InitiativeSheet.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/InitiativeSheet.tsx src/components/InitiativeSheet.test.tsx +git commit -m "feat: add InitiativeSheet add/edit form" +``` + +--- + +### Task 7: InitiativeTimeline (status-grouped roadmap view) + +**Files:** +- Create: `src/components/InitiativeTimeline.tsx` +- Test: `src/components/InitiativeTimeline.test.tsx` + +**Interfaces:** +- Consumes: `Initiative` type (Task 1); `resolveInitiativeDeadline` (Task 1) +- Produces: + ```ts + interface InitiativeTimelineProps { + initiatives: Initiative[]; + onSelect: (initiative: Initiative) => void; + } + export const InitiativeTimeline: React.FC; + ``` + +- [ ] **Step 1: Write the failing tests** + +```tsx +// src/components/InitiativeTimeline.test.tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { InitiativeTimeline } from "./InitiativeTimeline"; +import type { Initiative } from "@/contexts/TimeTrackingContext"; + +const make = (overrides: Partial): Initiative => ({ + id: "i1", projectId: "p1", name: "Initiative", goal: "g", + status: "not_started", deadlineType: "date", deadlineDate: "2026-12-15", + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z", + ...overrides +}); + +describe("InitiativeTimeline", () => { + it("renders an empty state with no initiatives", () => { + render(); + expect(screen.getByText(/no initiatives yet/i)).toBeInTheDocument(); + }); + + it("groups initiatives into their status swimlanes", () => { + render(); + + expect(screen.getByText("In progress")).toBeInTheDocument(); + expect(screen.getByText("Blocked")).toBeInTheDocument(); + expect(screen.queryByText("Done")).not.toBeInTheDocument(); + }); + + it("renders a bar for a quarter-deadline initiative using its resolved end date", () => { + render(); + // deadline shown as a formatted label somewhere in the row + expect(screen.getByText(/q1 2027/i)).toBeInTheDocument(); + }); + + it("calls onSelect when a bar is clicked", () => { + const onSelect = vi.fn(); + const initiative = make({ id: "a", name: "Clickable" }); + render(); + screen.getByText("Clickable").click(); + expect(onSelect).toHaveBeenCalledWith(initiative); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/components/InitiativeTimeline.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `InitiativeTimeline.tsx`** + +Fixed grouping order: `in_progress`, `not_started`, `blocked`, `done` (only render a swimlane for a status that has at least one initiative — Review Focus: empty project shows a single "No initiatives yet" message instead of four empty groups). Each swimlane is a `Card`-or-plain-`div` header (status label + count) followed by one row per initiative: a fixed-width name column, then a bar `div` positioned with inline `style={{ left: ..., width: ... }}` percentages computed from `createdAt` → `resolveInitiativeDeadline(initiative)` against a fixed default window (trailing 1 month + forward 5 months from today, per the spec's "Left to the builder"). Bar color per status using Radix scale classes: `in_progress` → blue (`bg-blue-9`), `done` → green (`bg-green-9`), `blocked` → red (`bg-red-9`), `not_started` → gray (`bg-mauve-9`) — matching `COLUMN_BADGE_COLORS`' role in `KanbanColumn.tsx` for the three shared statuses, with `not_started` as a new neutral key per the spec's builder note. A vertical "Today" line (`bg-red-9`, absolutely positioned) spans the full timeline height. Each bar's label includes the initiative name and a formatted deadline (`"Dec 15, 2026"` for a date deadline, `"Q1 2027"` for a quarter one). Clicking a bar or row calls `onSelect(initiative)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/components/InitiativeTimeline.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/InitiativeTimeline.tsx src/components/InitiativeTimeline.test.tsx +git commit -m "feat: add InitiativeTimeline status-grouped roadmap view" +``` + +--- + +### Task 8: ProjectPlanning page, route, and nav entry point + +**Files:** +- Create: `src/pages/ProjectPlanning.tsx` +- Modify: `src/App.tsx` +- Modify: `src/components/ProjectList.tsx` +- Test: `src/pages/ProjectPlanning.test.tsx` + +**Interfaces:** +- Consumes: `InitiativeTimeline` (Task 7), `InitiativeSheet` (Task 6), `useTimeTracking().{projects,initiatives}` (Task 5), `PageLayout` (existing) +- Produces: route `/projects/:id/planning`; a "Planning" link in each `ProjectList` row. + +- [ ] **Step 1: Write the failing tests** + +```tsx +// src/pages/ProjectPlanning.test.tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import ProjectPlanning from "./ProjectPlanning"; + +vi.mock("@/hooks/useTimeTracking", () => ({ + useTimeTracking: () => ({ + projects: [{ id: "proj-1", name: "Product and Design", client: "Acme" }], + initiatives: [] + }) +})); +vi.mock("@/components/InitiativeTimeline", () => ({ + InitiativeTimeline: () =>
timeline
+})); + +const renderAt = (id: string) => + render( + + + } /> + + + ); + +describe("ProjectPlanning", () => { + it("renders the project's name and the timeline for a valid id", () => { + renderAt("proj-1"); + expect(screen.getByText("Product and Design")).toBeInTheDocument(); + expect(screen.getByText("timeline")).toBeInTheDocument(); + }); + + it("shows a not-found state for an id with no matching project", () => { + renderAt("missing-id"); + expect(screen.getByText(/project not found/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /projects/i })).toHaveAttribute("href", "/projectlist"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/pages/ProjectPlanning.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `ProjectPlanning.tsx`** + +`useParams<{ id: string }>()` for the project id; look it up in `projects` from `useTimeTracking()`. If not found, render a centered "Project not found" message with a `Link` to `/projectlist` (Review Focus — spec's builder note on the missing-`:id` case). If found, wrap in `PageLayout` with the project's `name` as `title` and a "+ New Initiative" `actions` button that opens `InitiativeSheet` in `"add"` mode; filter `initiatives` to that project's `id` and pass to `InitiativeTimeline`, with `onSelect` opening `InitiativeSheet` in `"edit"` mode for the clicked initiative. + +- [ ] **Step 4: Add the route in `App.tsx`** + +Add `const ProjectPlanning = lazy(() => import("./pages/ProjectPlanning"));` with the other lazy imports, and `} />` above the catch-all `*` route. + +- [ ] **Step 5: Add a "Planning" link to each row in `ProjectList.tsx`** + +In `ItemActions` (~line 188), add a `Button variant="outline" asChild` wrapping a `Link to={`/projects/${project.id}/planning`}` with a `CalendarRange` icon (`lucide-react`) and "Planning" label, placed before the existing Edit button. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `pnpm vitest run src/pages/ProjectPlanning.test.tsx` +Expected: PASS + +- [ ] **Step 7: Full verification** + +Run: `pnpm lint && pnpm build && pnpm vitest run` +Expected: all pass + +- [ ] **Step 8: Commit** + +```bash +git add src/pages/ProjectPlanning.tsx src/App.tsx src/components/ProjectList.tsx src/pages/ProjectPlanning.test.tsx +git commit -m "feat: add per-project planning page and route" +``` + +--- + +## Self-Review + +**1. Spec coverage:** Data model (Task 1), dual-storage persistence incl. SQL backend (Tasks 2-4), context wiring incl. cascade delete (Task 5), add/edit UI incl. deadline validation (Task 6), timeline visualization incl. quarter-as-point and status grouping (Task 7), route/nav placement incl. not-found handling (Task 8). All "Decisions made" and "Left to the builder" items from the spec map to a task. Zoom and cross-project view are explicitly out of scope and have no task, matching the spec's Non-goals. + +**2. Step scan:** Reviewed each step; code steps carry exact signatures/field names and point to the specific existing file to mirror rather than restating its body, since the whole codebase's established pattern (`PlannedTask`) already is the algorithm. + +**3. Type consistency:** `Initiative`/`InitiativeStatus`/`InitiativeDeadlineType` field names (`projectId`, `deadlineType`, `deadlineDate`, `deadlineQuarter`, `deadlineYear`) are used identically from Task 1 through Task 8. `addInitiative`/`updateInitiative`/`deleteInitiative`/`restoreDeletedInitiative` names match across Tasks 5, 6, and 8. + +**4. Review Focus:** Each of the five items has an owning task and test: partial quarter deadline (Task 1, `resolveInitiativeDeadline` null-return tests), deadline-before-creation (Task 6, rejection test), project deletion cascade (Task 5, cascade test), invalid `:id` route (Task 8, not-found test), empty-project timeline (Task 7, empty-state test). + +**5. Proportion:** The plan is long because it threads one new entity through six existing subsystems (guest storage, Supabase, self-hosted SQL, context, two new components, routing) that each already have an established pattern to mirror — most of the length is "copy file X's structure," not new logic. No task invents algorithmic complexity beyond date math already isolated in Task 1. diff --git a/docs/superpowers/specs/2026-10-23-planning-view-design.md b/docs/superpowers/specs/2026-10-23-planning-view-design.md new file mode 100644 index 0000000..dd207cc --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-planning-view-design.md @@ -0,0 +1,186 @@ +# Planning View Design + +**Date:** 2026-10-23 +**Status:** Draft — awaiting approval + +## Intent + +Timetraked currently manages day-to-day time tracking and a flat task board, but has no +way to express higher-level goals: "what is this project trying to accomplish, and by +when." Adam runs multiple client/personal projects concurrently and needs a per-project +view that lays out each project's goals against time, so overlapping deadlines are +visible at a glance during planning sessions. + +## Goals + +- Let a project define one or more **initiatives**: a name, a goal description, a + deadline, and a manually-set status. +- Give each existing `Project` its own **planning view** — a timeline showing that + project's initiatives, so concurrent/overlapping work is visible. +- Support deadlines as either a specific date or a calendar quarter (e.g. "Q1 2027"). +- Work in both guest mode (localStorage) and authenticated mode (Supabase), following + the app's existing dual-storage pattern. + +## Non-goals (v1) + +- No cross-project planning view (each project's planning view is independent/scoped). +- No automatic status derivation from linked tasks/todos — status is set manually. +- No zoom/scale switching (week/month/quarter/year) on the timeline — fixed month-scale + view for v1. Zoom is an explicit follow-up, not part of this work. +- No `server/` (self-hosted SQL backend) parity in this pass — see Open Items. + +## Anti-goals + +- Don't let this become a second task-tracking system — initiatives are coarse-grained + goals, not a replacement for `PlannedTask`/the Kanban board. +- Don't couple initiative status to task completion — that's an explicit non-goal above, + not an oversight. + +## Decisions made (Adam's calls) + +1. An initiative belongs to exactly one existing `Project` (the time-tracking project + entity) — not a new top-level grouping that spans projects. +2. The planning/overlap view is scoped per project; there is no combined view across + projects. +3. Status is a manual field: `Not started` / `In progress` / `Done` / `Blocked`. Not + derived from linked tasks. +4. An initiative has no explicit start date — it appears on the timeline from its + `createdAt` through its deadline. +5. Quarter deadlines use standard calendar quarters (Q1 = Jan–Mar, etc.). +6. A quarter deadline renders as a single point at the end of that quarter, not a block + spanning the whole quarter. +7. Visual direction: GitHub Projects' Roadmap view — rows grouped into collapsible + swimlanes by status, a continuous month-scale timeline, bars running creation→deadline + labeled with name + deadline, and a "Today" marker line. Confirmed via mockup + (`.superpowers/brainstorm/12819-1792774186/content/layout-v2.html`). +8. Zoom control (switching month/quarter/year scale) ships later as a nice-to-have, not + in v1. + +## Data model + +New entity, following the existing `Project`/`Client`/`PlannedTask` pattern in +`src/contexts/TimeTrackingContext.tsx`: + +```ts +export type InitiativeStatus = "not_started" | "in_progress" | "done" | "blocked"; +export type InitiativeDeadlineType = "date" | "quarter"; + +export interface Initiative { + id: string; + projectId: string; // FK to Project.id + name: string; + goal: string; // free-text description of the outcome + status: InitiativeStatus; + deadlineType: InitiativeDeadlineType; + deadlineDate?: string; // ISO date string; set when deadlineType === "date" + deadlineQuarter?: number; // 1-4; set when deadlineType === "quarter" + deadlineYear?: number; // calendar year; set when deadlineType === "quarter" + createdAt: string; // ISO string — timeline start point + updatedAt: string; // ISO string +} +``` + +A `deadlineDate(initiative)` helper resolves the effective deadline to a single `Date` +for timeline math: the stored date, or the last calendar day of +`deadlineQuarter`/`deadlineYear`. + +**Why `projectId` and not a project-name string:** `Task.project` and +`PlannedTask.project` are denormalized name strings (matching how the app already +references projects elsewhere). Initiatives are reached via a per-project route +(`/projects/:id/planning`) where the project is already known by id, and a name-based +link would silently detach from its project on rename. This is a deliberate departure +from the existing string-reference convention — flagged for Adam to confirm, not a free +pass to diverge elsewhere. + +## Persistence + +Mirrors the `PlannedTask` pattern exactly (single-row upsert + bulk save, no bulk +inclusion in `forceSyncToDatabase`): + +- **`DataService` interface** (`src/services/dataService.ts`): add + `saveInitiatives`, `getInitiatives`, `upsertInitiative`, `deleteInitiative`. +- **Guest mode**: `src/services/localStorageService/initiatives.ts`, new + `STORAGE_KEYS.INITIATIVES = "timetracker_initiatives"` in `constants.ts`, versioned + read/write via the existing `readVersioned`/`writeVersioned` helpers. +- **Authenticated mode**: new Supabase table `planning_initiatives` (migration under + `supabase/migrations/`), with `project_id text references public.projects(id) on + delete cascade` (confirm `projects.id`'s column type when writing the migration — it + is not UUID-generated, since default project ids look like `"default-work"`), RLS + scoped to `user_id`, plus `supabaseService.ts` methods following the + `savePlannedTasks`/`upsertPlannedTask`/`deletePlannedTask`/`getPlannedTasks` shape + (including `trackDbCall` instrumentation). +- **Context** (`TimeTrackingContext.tsx`): `initiatives` state + `addInitiative`, + `updateInitiative`, `deleteInitiative` methods, persisted via `upsertInitiative` + (single-row), same as `addClient`/`updateClient`. + +## UI + +- **Route**: `/projects/:id/planning` (new — there is currently no per-project detail + route; `ProjectList.tsx` is a flat list with an edit sheet, no drill-in). Add a + "Planning" link/button to each row in `ProjectList.tsx`'s `ItemActions`. +- **Page** (`src/pages/ProjectPlanning.tsx`): `PageLayout` wrapper, project name as + title, "+ New Initiative" action. +- **Timeline component** (`src/components/InitiativeTimeline.tsx`): rows grouped into + collapsible swimlanes by status (fixed grouping for v1 — no configurable group-by); + continuous month-scale header; each initiative rendered as a rounded bar from + `createdAt` to its resolved deadline, labeled with name + formatted deadline; a + vertical "Today" line; status-colored bars matching the badge palette already used + elsewhere (e.g. `PlannedTaskStatus` badge colors in the Kanban board, for visual + consistency). +- **Add/edit**: an `InitiativeSheet` component (mirrors `ClientSheet`/`ProjectSheet`'s + shared add/edit Sheet pattern) — name, goal (textarea), status select, deadline type + toggle (date picker vs. quarter+year picker). +- Uses `ResponsiveSelect` for the status/deadline-type pickers, consistent with the + rest of the app's mobile/desktop select pattern. + +## Testing + +Follow the project's existing coverage pattern (see `AGENTS.md`'s test inventory): + +- `src/services/localStorageService/initiatives.test.ts` — save/load/upsert/delete + round-trips (mirrors `plannedTasks` coverage once added). +- `src/services/supabaseService.test.ts` — add initiative upsert/bulk-save/delete cases. +- `src/contexts/TimeTracking.test.tsx` — add/update/delete initiative methods. +- `src/components/InitiativeTimeline.test.tsx` — grouping, bar positioning for both + date and quarter deadlines, today-line placement. +- `src/components/ProjectPlanning.test.tsx` (or page-level) — render, add/edit flow. + +## Left to the builder + +- Exact bar color tokens (must use semantic/Radix-scale classes per `AGENTS.md`'s + non-negotiable color rules — mockup colors were illustrative only). +- Exact month-range window shown by default (e.g. trailing 1 month + forward 5 months, + centered on today) when a project has initiatives spanning a wide range. +- Whether "Done" initiatives' swimlane defaults to collapsed (mockup showed this but it + wasn't explicitly confirmed). +- `sqlApiService.ts` (self-hosted SQL backend) parity — out of scope per Non-goals, but + the interface addition means it needs stub/throwing implementations or the build + breaks; builder decides the minimal safe approach (likely matching however that file + currently handles `PlannedTask`). +- **Deleting a project deletes its initiatives too.** `deleteProject` in + `TimeTrackingContext.tsx` has no cascade logic today for any related entity, and + guest-mode localStorage has no DB-level cascade. To avoid orphaned initiatives nobody + can see or manage, `deleteProject` filters out that project's initiatives (and + persists via `deleteInitiative`/`saveInitiatives`) the same way it filters the + project itself. Supabase's `ON DELETE CASCADE` on `planning_initiatives.project_id` + handles the authenticated-mode case at the DB level. +- **`not_started` status color.** `InitiativeStatus` doesn't reuse + `PlannedTaskStatus`'s `"todo"` key, so there's no existing badge color to inherit for + it. Use a neutral gray Radix scale (matching the mockup), parallel to how `"todo"` is + styled in `KanbanColumn.tsx`'s `COLUMN_BADGE_COLORS` — i.e. same visual role, new key. +- **Unknown/missing `:id` on `/projects/:id/planning`.** No dynamic route exists + elsewhere in the app to copy. If `:id` doesn't match any project (stale bookmark, + deleted/archived project), show the page's existing empty-state treatment ("Project + not found") with a link back to `/projectlist`, rather than a blank page or crash. +- **Deadline before `createdAt`.** Mirrors the app's existing invariant pattern + (`isEndBeforeStart`/`END_BEFORE_START_MESSAGE` for task times): the `InitiativeSheet` + rejects a deadline earlier than the initiative's `createdAt` with an inline validation + message, consistent with how the rest of the app already treats backwards time ranges + as a validation error rather than silently allowing them. + +## Open items + +- `server/` SQL backend parity is explicitly deferred (Non-goals) but the `DataService` + interface is shared — see "Left to the builder" above. +- Zoom (week/month/quarter/year scale) is a known, explicitly deferred follow-up, not + tracked further here. From 95b2fdbc8bd517d0fb00c3389459e219cfe4da61 Mon Sep 17 00:00:00 2001 From: AdamJ Date: Fri, 23 Oct 2026 13:36:53 -0400 Subject: [PATCH 2/4] feat: add Initiative types and deadline-resolution utilities --- server/app.ts | 29 ++ server/repositories/initiatives.ts | 79 ++++ server/schema.ts | 20 + server/types.ts | 5 +- src/App.tsx | 2 + src/components/InitiativeSheet.test.tsx | 120 ++++++ src/components/InitiativeSheet.tsx | 360 ++++++++++++++++++ src/components/InitiativeTimeline.test.tsx | 83 ++++ src/components/InitiativeTimeline.tsx | 153 ++++++++ src/contexts/TimeTracking.test.tsx | 156 +++++++- src/contexts/TimeTrackingContext.tsx | 124 +++++- src/pages/ProjectList.tsx | 12 + src/pages/ProjectPlanning.test.tsx | 62 +++ src/pages/ProjectPlanning.tsx | 81 ++++ src/services/dataService.test.ts | 51 +++ src/services/dataService.ts | 8 +- src/services/localStorageService/constants.ts | 3 +- src/services/localStorageService/index.ts | 19 +- .../localStorageService/initiatives.ts | 37 ++ src/services/sqlApiService.ts | 29 +- src/services/supabaseService.test.ts | 50 ++- src/services/supabaseService.ts | 118 +++++- src/utils/initiativeUtils.test.ts | 95 +++++ src/utils/initiativeUtils.ts | 33 ++ .../20261023_planning_initiatives.sql | 46 +++ 25 files changed, 1762 insertions(+), 13 deletions(-) create mode 100644 server/repositories/initiatives.ts create mode 100644 src/components/InitiativeSheet.test.tsx create mode 100644 src/components/InitiativeSheet.tsx create mode 100644 src/components/InitiativeTimeline.test.tsx create mode 100644 src/components/InitiativeTimeline.tsx create mode 100644 src/pages/ProjectPlanning.test.tsx create mode 100644 src/pages/ProjectPlanning.tsx create mode 100644 src/services/localStorageService/initiatives.ts create mode 100644 src/utils/initiativeUtils.test.ts create mode 100644 src/utils/initiativeUtils.ts create mode 100644 supabase/migrations/20261023_planning_initiatives.sql diff --git a/server/app.ts b/server/app.ts index 292660d..4d7ef77 100644 --- a/server/app.ts +++ b/server/app.ts @@ -5,6 +5,7 @@ import * as categories from "./repositories/categories"; import * as clients from "./repositories/clients"; import * as currentDay from "./repositories/currentDay"; import * as plannedTasks from "./repositories/plannedTasks"; +import * as initiatives from "./repositories/initiatives"; import * as projects from "./repositories/projects"; import * as todos from "./repositories/todos"; @@ -156,6 +157,34 @@ export function createApp() { }) ); + app.get( + "/api/initiatives", + wrap(async (_req, res) => { + res.json(await initiatives.getInitiatives()); + }) + ); + app.put( + "/api/initiatives", + wrap(async (req, res) => { + await initiatives.saveInitiatives(req.body); + res.status(204).end(); + }) + ); + app.put( + "/api/initiatives/:id", + wrap(async (req, res) => { + await initiatives.upsertInitiative(req.body); + res.status(204).end(); + }) + ); + app.delete( + "/api/initiatives/:id", + wrap(async (req, res) => { + await initiatives.deleteInitiative(String(req.params.id)); + res.status(204).end(); + }) + ); + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { console.error("❌ Request failed:", err); res.status(500).json({ error: err instanceof Error ? err.message : "Internal error" }); diff --git a/server/repositories/initiatives.ts b/server/repositories/initiatives.ts new file mode 100644 index 0000000..2c5fe5b --- /dev/null +++ b/server/repositories/initiatives.ts @@ -0,0 +1,79 @@ +import { db } from "../db"; +import type { Initiative, InitiativeStatus, InitiativeDeadlineType } from "../types"; + +interface InitiativeRow { + id: string; + project_id: string | null; + name: string; + goal: string | null; + status: string; + deadline_type: string; + deadline_date: string | null; + deadline_quarter: number | null; + deadline_year: number | null; + created_at: Date | string; + updated_at: Date | string; +} + +function mapRow(row: InitiativeRow): Initiative { + return { + id: row.id, + projectId: row.project_id ?? "", + name: row.name, + goal: row.goal ?? "", + status: row.status as InitiativeStatus, + deadlineType: row.deadline_type as InitiativeDeadlineType, + deadlineDate: row.deadline_date ?? undefined, + deadlineQuarter: row.deadline_quarter ?? undefined, + deadlineYear: row.deadline_year ?? undefined, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString() + }; +} + +function initiativeToRow(initiative: Initiative) { + return { + id: initiative.id, + project_id: initiative.projectId, + name: initiative.name, + goal: initiative.goal, + status: initiative.status, + deadline_type: initiative.deadlineType, + deadline_date: initiative.deadlineDate ?? null, + deadline_quarter: initiative.deadlineQuarter ?? null, + deadline_year: initiative.deadlineYear ?? null, + created_at: initiative.createdAt, + updated_at: new Date() + }; +} + +export async function getInitiatives(): Promise { + const rows: InitiativeRow[] = await db("planning_initiatives").orderBy("created_at", "asc"); + return rows.map(mapRow); +} + +export async function saveInitiatives(initiatives: Initiative[]): Promise { + if (initiatives.length === 0) { + await db("planning_initiatives").delete(); + return; + } + + const existing = await db("planning_initiatives").select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(initiatives.map((i) => i.id)); + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("planning_initiatives").whereIn("id", toDelete).delete(); + } + + const rows = initiatives.map(initiativeToRow); + await db("planning_initiatives").insert(rows).onConflict("id").merge(); +} + +export async function upsertInitiative(initiative: Initiative): Promise { + await db("planning_initiatives").insert(initiativeToRow(initiative)).onConflict("id").merge(); +} + +export async function deleteInitiative(id: string): Promise { + await db("planning_initiatives").where({ id }).delete(); +} diff --git a/server/schema.ts b/server/schema.ts index 20dd28d..bb34ebb 100644 --- a/server/schema.ts +++ b/server/schema.ts @@ -117,4 +117,24 @@ export async function ensureSchema(db: Knex): Promise { t.timestamp("updated_at").notNullable().defaultTo(db.fn.now()); }); } + + if (!(await db.schema.hasTable("planning_initiatives"))) { + await db.schema.createTable("planning_initiatives", (t) => { + t.string("id").primary(); + t.string("project_id"); + t.string("name").notNullable(); + t.text("goal"); + t.string("status").notNullable().defaultTo("not_started"); + t.string("deadline_type").notNullable().defaultTo("date"); + // text, not a SQL date column: node-postgres/mysql2 return a DATE + // column as a JS Date/timestamp rather than "YYYY-MM-DD", which both + // breaks and reintroduces the UTC-midnight day + // shift the client-side date parsing was fixed to avoid. + t.string("deadline_date"); + t.integer("deadline_quarter"); + t.integer("deadline_year"); + t.timestamp("created_at").notNullable(); + t.timestamp("updated_at").notNullable().defaultTo(db.fn.now()); + }); + } } diff --git a/server/types.ts b/server/types.ts index a19a7fd..b3ece12 100644 --- a/server/types.ts +++ b/server/types.ts @@ -7,7 +7,10 @@ export type { Client, TodoItem, PlannedTask, - PlannedTaskStatus + PlannedTaskStatus, + Initiative, + InitiativeStatus, + InitiativeDeadlineType } from "../src/contexts/TimeTrackingContext"; export type { TaskCategory } from "../src/config/categories"; diff --git a/src/App.tsx b/src/App.tsx index 415eb78..4cb3c59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ const Categories = lazy(() => import("./pages/Categories")); const Clients = lazy(() => import("./pages/Clients")); const Report = lazy(() => import("./pages/Report")); const TaskList = lazy(() => import("./pages/TaskList")); +const ProjectPlanning = lazy(() => import("./pages/ProjectPlanning")); // Loading fallback component const PageLoader = () => ( @@ -141,6 +142,7 @@ const AppShell = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/InitiativeSheet.test.tsx b/src/components/InitiativeSheet.test.tsx new file mode 100644 index 0000000..d4b1a02 --- /dev/null +++ b/src/components/InitiativeSheet.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { InitiativeSheet } from "./InitiativeSheet"; + +const addInitiativeMock = vi.fn(() => ({ id: "new-id" })); +const updateInitiativeMock = vi.fn(); +const deleteInitiativeMock = vi.fn(); +const restoreDeletedInitiativeMock = vi.fn(); + +vi.mock("@/hooks/useTimeTracking", () => ({ + useTimeTracking: () => ({ + addInitiative: addInitiativeMock, + updateInitiative: updateInitiativeMock, + deleteInitiative: deleteInitiativeMock, + restoreDeletedInitiative: restoreDeletedInitiativeMock + }) +})); + +const EXISTING_INITIATIVE = { + id: "init-1", projectId: "proj-1", name: "Existing initiative", goal: "g", + status: "not_started" as const, deadlineType: "date" as const, deadlineDate: "2099-01-01", + createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" +}; + +describe("InitiativeSheet", () => { + it("renders the Add Initiative title in add mode", () => { + render( {}} mode="add" projectId="proj-1" />); + expect(screen.getByText("Add Initiative")).toBeInTheDocument(); + }); + + it("requires a name before submitting", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("rejects a date deadline before today when adding", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Backdated" } }); + fireEvent.change(screen.getByLabelText(/deadline date/i), { target: { value: "2000-01-01" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/deadline must be on or after/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("requires a deadline date when the deadline type is date", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "No deadline date" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/deadline date is required/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("requires both a quarter and a year when the deadline type is quarter", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Partial quarter" } }); + fireEvent.click(screen.getByRole("button", { name: /^quarter$/i })); + fireEvent.change(screen.getByLabelText(/deadline year/i), { target: { value: "2027" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/choose a quarter and a year/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("rejects a non-4-digit year for a quarter deadline", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Two digit year" } }); + fireEvent.click(screen.getByRole("button", { name: /^quarter$/i })); + fireEvent.change(screen.getByLabelText(/deadline year/i), { target: { value: "27" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/enter a 4-digit year/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("submits a valid date-deadline initiative", async () => { + const onOpenChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Redesign onboarding" } }); + fireEvent.change(screen.getByLabelText(/goal/i), { target: { value: "Cut drop-off" } }); + fireEvent.change(screen.getByLabelText(/deadline date/i), { target: { value: "2099-12-15" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(addInitiativeMock).toHaveBeenCalledWith(expect.objectContaining({ + projectId: "proj-1", name: "Redesign onboarding", deadlineType: "date", deadlineDate: "2099-12-15" + })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it("does not render a Delete button in add mode", () => { + render( {}} mode="add" projectId="proj-1" />); + expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument(); + }); + + it("deletes the initiative and closes the sheet when Delete is clicked in edit mode", () => { + const onOpenChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /delete/i })); + expect(deleteInitiativeMock).toHaveBeenCalledWith("init-1"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/InitiativeSheet.tsx b/src/components/InitiativeSheet.tsx new file mode 100644 index 0000000..a3443b6 --- /dev/null +++ b/src/components/InitiativeSheet.tsx @@ -0,0 +1,360 @@ +import React, { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { ResponsiveSelect } from "@/components/ui/responsive-select"; +import { Loader2 } from "lucide-react"; +import { Initiative, InitiativeStatus, InitiativeDeadlineType } from "@/contexts/TimeTrackingContext"; +import { useTimeTracking } from "@/hooks/useTimeTracking"; +import { useUndoableDelete } from "@/hooks/useUndoableDelete"; +import { getQuarterEndDate, isDeadlineBeforeCreation, parseLocalDate } from "@/utils/initiativeUtils"; + +interface InitiativeSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "add" | "edit"; + projectId: string; + initiative?: Initiative; +} + +const STATUS_OPTIONS: { value: InitiativeStatus; label: string }[] = [ + { value: "not_started", label: "Not started" }, + { value: "in_progress", label: "In progress" }, + { value: "done", label: "Done" }, + { value: "blocked", label: "Blocked" }, +]; + +const QUARTER_OPTIONS = [1, 2, 3, 4].map((q) => ({ value: String(q), label: `Q${q}` })); + +export const InitiativeSheet: React.FC = ({ + open, + onOpenChange, + mode, + projectId, + initiative, +}) => { + const { addInitiative, updateInitiative, deleteInitiative, restoreDeletedInitiative } = useTimeTracking(); + const { confirmDelete } = useUndoableDelete(); + + const [isSaving, setIsSaving] = React.useState(false); + + const initiativeFormSchema = z + .object({ + name: z.string().trim().min(1, "Name is required."), + goal: z.string().trim(), + status: z.enum(["not_started", "in_progress", "done", "blocked"]), + deadlineType: z.enum(["date", "quarter"]), + deadlineDate: z.string().trim(), + deadlineQuarter: z.string().trim(), + deadlineYear: z.string().trim(), + }) + .superRefine((values, ctx) => { + if (values.deadlineType === "date") { + if (!values.deadlineDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Deadline date is required.", + path: ["deadlineDate"], + }); + } + } else { + if (!values.deadlineQuarter || !values.deadlineYear) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Choose a quarter and a year.", + path: ["deadlineQuarter"], + }); + } + if (values.deadlineYear && !/^\d{4}$/.test(values.deadlineYear)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a 4-digit year.", + path: ["deadlineYear"], + }); + } + } + + const createdAt = initiative?.createdAt ?? new Date().toISOString(); + let deadline: Date | null = null; + if (values.deadlineType === "date") { + deadline = values.deadlineDate ? parseLocalDate(values.deadlineDate) : null; + } else { + const quarter = Number(values.deadlineQuarter); + const year = Number(values.deadlineYear); + const validYear = /^\d{4}$/.test(values.deadlineYear); + deadline = quarter && validYear ? getQuarterEndDate(quarter, year) : null; + } + if (deadline && isDeadlineBeforeCreation(createdAt, deadline)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Deadline must be on or after the initiative's creation date.", + path: [values.deadlineType === "date" ? "deadlineDate" : "deadlineYear"], + }); + } + }); + + type InitiativeFormValues = z.infer; + + const defaultFormValues: InitiativeFormValues = { + name: "", + goal: "", + status: "not_started", + deadlineType: "date", + deadlineDate: "", + deadlineQuarter: "", + deadlineYear: "", + }; + + const form = useForm({ + resolver: zodResolver(initiativeFormSchema), + mode: "onSubmit", + defaultValues: defaultFormValues, + }); + + useEffect(() => { + if (!open) return; + if (mode === "edit" && initiative) { + form.reset({ + name: initiative.name, + goal: initiative.goal, + status: initiative.status, + deadlineType: initiative.deadlineType, + deadlineDate: initiative.deadlineDate ?? "", + deadlineQuarter: initiative.deadlineQuarter ? String(initiative.deadlineQuarter) : "", + deadlineYear: initiative.deadlineYear ? String(initiative.deadlineYear) : "", + }); + } else { + form.reset(defaultFormValues); + } + setIsSaving(false); + // defaultFormValues is a fresh object literal each render; only `open`/`mode`/`initiative` should retrigger this. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, mode, initiative, form]); + + const deadlineType = form.watch("deadlineType"); + + const handleDelete = () => { + if (!initiative) return; + deleteInitiative(initiative.id); + confirmDelete(initiative, restoreDeletedInitiative, { + title: "Initiative deleted", + description: `"${initiative.name}" has been deleted.`, + }); + onOpenChange(false); + }; + + const onSubmit = async (values: InitiativeFormValues) => { + const deadlineFields: Pick = + values.deadlineType === "date" + ? { deadlineType: "date", deadlineDate: values.deadlineDate || undefined, deadlineQuarter: undefined, deadlineYear: undefined } + : { + deadlineType: "quarter", + deadlineDate: undefined, + deadlineQuarter: values.deadlineQuarter ? Number(values.deadlineQuarter) : undefined, + deadlineYear: values.deadlineYear ? Number(values.deadlineYear) : undefined, + }; + + setIsSaving(true); + try { + if (mode === "add") { + addInitiative({ + projectId, + name: values.name, + goal: values.goal, + ...deadlineFields, + }); + } else if (mode === "edit" && initiative) { + updateInitiative(initiative.id, { + name: values.name, + goal: values.goal, + status: values.status, + ...deadlineFields, + }); + } + onOpenChange(false); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + + {mode === "add" ? "Add Initiative" : "Edit Initiative"} + + + +
+ + ( + + + Name * + + + + + + + )} + /> + + ( + + Goal + +