diff --git a/AGENTS.md b/AGENTS.md index 8c3c114..40b6ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,6 +169,10 @@ export const MyComponent = () => { | `src/components/ProjectSheet.tsx` | Shared Sheet (drawer) for add and edit project forms; handles both modes via `mode` prop | | `src/components/CategorySheet.tsx` | Shared Sheet (drawer) for add and edit category forms; handles both modes via `mode` prop | | `src/pages/Clients.tsx` | Thin page wrapper around `ClientManagement` (route `/clients`) | +| `src/pages/ProjectPlanning.tsx` | Per-project Initiative planning view (route `/projects/:id/planning`); shows `InitiativeTimeline` and the add/edit `InitiativeSheet` | +| `src/components/InitiativeSheet.tsx` | Shared Sheet (drawer) for add/edit/delete of a planning `Initiative`; deadline is a specific date or a calendar quarter | +| `src/components/InitiativeTimeline.tsx` | Roadmap-style timeline for a project's Initiatives — status-grouped collapsible swimlanes, month-scale header, "Today" line | +| `src/utils/initiativeUtils.ts` | `resolveInitiativeDeadline`/`parseLocalDate` — date-vs-quarter deadline resolution without the UTC-midnight day-shift bug | | `src/services/localStorageService/clients.ts` | Client persistence module for guest mode (versioned localStorage blob) | | `supabase/migrations/20260530_clients.sql` | `clients` table + RLS + one-time backfill from distinct project clients | | `src/components/AppSidebar.tsx` | Collapsible sidebar nav (Planning/Manage/Reports groups); reads page title/actions from `PageTitleContext`; footer has print button, sync status, and auth | diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d42dd..6f65805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Each project now has its own Planning view — a goal/deadline/status "Initiative" entity scoped to that project, shown as a GitHub-Projects-style Roadmap timeline (status-grouped, collapsible swimlanes, a month-scale header, and a "Today" line), reached via a new "Planning" link on each project card. Deadlines are either a specific date or a calendar quarter (rendered as the quarter's end date); status is set manually (Not started/In progress/Done/Blocked). Backed by a new `Initiative` entity behind the existing `DataService` abstraction — guest localStorage, Supabase (`planning_initiatives` table, RLS, no FK to `projects` since default project ids are shared across accounts — cascade delete is handled at the app layer instead), and the self-hosted SQL backend (`server/repositories/initiatives.ts`, `/api/initiatives`) + — `src/components/InitiativeSheet.tsx` (new), `src/components/InitiativeTimeline.tsx` (new), `src/pages/ProjectPlanning.tsx` (new), `src/utils/initiativeUtils.ts` (new), `src/contexts/TimeTrackingContext.tsx`, `src/services/dataService.ts`, `src/services/supabaseService.ts`, `src/services/sqlApiService.ts`, `src/services/localStorageService/initiatives.ts` (new), `server/repositories/initiatives.ts` (new), `server/schema.ts`, `server/app.ts`, `supabase/migrations/20261023_planning_initiatives.sql` (new), `src/pages/ProjectList.tsx`, `src/App.tsx` - Task board now has sort (Priority, Title A–Z, Newest first, Oldest first) and filter (project, client, category) controls above the columns, applying to all four status columns at once. Choices persist to `localStorage` (`timetracker_task_board_view`), not Supabase, so they're per-browser only. New `useTaskBoardView` hook holds the sort/filter state and reconciles it with `plannedTasks` — `src/hooks/useTaskBoardView.ts` (new), `src/components/KanbanBoardControls.tsx` (new), `src/components/KanbanBoard.tsx` - Starting a day from the task board no longer requires a detour through the Dashboard. The Tasks page header has a "Start Day" button (starts at the current time, rounded to 15 minutes, without the start-time dialog), and each planned task's menu offers "Start Day and Pull" (or "Start Day and Resume" for in-progress tasks) when no day is running. New context method `startDayAndPullPlannedTask` starts the day and the task in one step; it is blocked while an ended day is still awaiting posting. Pull/add/start-day-and-pull share one internal helper, and `startNewTask` now delegates to an internal `beginTask` that takes the day state explicitly so a day and its first task can be created in the same tick without a stale-closure save diff --git a/README-EXT.md b/README-EXT.md index e3fae8a..dc58cb1 100644 --- a/README-EXT.md +++ b/README-EXT.md @@ -11,6 +11,7 @@ For the main overview, see [README.md](README.md). - [Daily Workflow](#daily-workflow) - [Keyboard Shortcuts](#keyboard-shortcuts) - [Project Management](#project-management) + - [Project Planning](#project-planning) - [Client Management](#client-management) - [Category Management](#category-management) - [Data Export & Import](#data-export--import) @@ -121,6 +122,17 @@ The sidebar footer contains two key elements: Projects enable automatic revenue calculation, per-client invoice exports, and archive filtering. +### Project Planning + +Each project has its own Planning view (the "Planning" link on its project card) for tracking goals and timelines, separate from day-to-day task tracking. + +1. Click **New Initiative** and enter a name, an optional goal, and a deadline — either a specific date or a calendar quarter (shown on the timeline as that quarter's last day). +2. Status (Not started / In progress / Done / Blocked) is set manually; new initiatives start as Not started and the status field is editable once an initiative exists. +3. The timeline groups initiatives into collapsible, status-grouped swimlanes on a six-month, month-scale window with a "Today" line, styled after GitHub Projects' Roadmap view. Each bar spans from the initiative's creation date to its deadline. +4. Click an initiative's row to edit it, or delete it from the edit sheet (undoable, like other deletes in the app). + +Initiatives are scoped to one project — there is no cross-project planning view. Deleting a project deletes its initiatives too. + ### Client Management 1. Navigate to **Settings → Manage Clients**. @@ -406,6 +418,8 @@ src/ │ ├── DaySummary.tsx │ ├── DeleteConfirmationDialog.tsx │ ├── ExportDialog.tsx +│ ├── InitiativeSheet.tsx # Add/edit/delete sheet for a project's planning Initiatives +│ ├── InitiativeTimeline.tsx # Roadmap-style status swimlanes for a project's Initiatives │ ├── InstallPrompt.tsx │ ├── KanbanBoard.tsx # Kanban planning board │ ├── KanbanBoardControls.tsx # Sort/filter bar above the board, backed by useTaskBoardView @@ -462,6 +476,7 @@ src/ │ ├── Index.tsx # Dashboard (start/end day, stats) │ ├── NotFound.tsx # 404 page │ ├── ProjectList.tsx # Project management +│ ├── ProjectPlanning.tsx # Per-project Initiative timeline ("Planning" link on a project card) │ ├── Report.tsx # AI weekly summary │ ├── Settings.tsx # App settings │ └── TaskList.tsx # Active task list and NewTaskForm @@ -474,6 +489,7 @@ src/ │ ├── calculationUtils.ts # Revenue and hours calculations │ ├── checklistUtils.ts # GFM checklist extraction │ ├── exportUtils.ts # CSV import/export +│ ├── initiativeUtils.ts # Initiative deadline resolution (date/quarter) and local-date parsing │ ├── reportUtils.ts # Report grouping and formatting │ └── timeUtil.ts # Time formatting, 15-minute rounding, zero-floored `elapsedMs`, end-before-start check ├── App.css diff --git a/README.md b/README.md index a03a3ae..c85dcd1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ A Progressive Web App (PWA) for time tracking built with React, TypeScript, and - **Keyboard Shortcuts** — `N` new task, `Cmd/Ctrl+S` save, `Cmd/Ctrl+K` command palette, `?` for the shortcuts help dialog (web and Tauri desktop; the desktop app's native menu also offers `Cmd/Ctrl+N` for new task); the sidebar footer now includes a Print button for quick printing - **No Account Required** — full functionality with local storage; optional cloud sync via Supabase - **Self-Hosted SQL Backend** — optionally run against your own PostgreSQL or MySQL database instead of Supabase or local storage (see [docs/SQL_BACKEND.md](docs/SQL_BACKEND.md)) +- **Project Planning View** — set a goal, status, and deadline (date or quarter) for initiatives scoped to a project, visualized as a Roadmap-style timeline - **PWA** — installable on desktop/mobile --- @@ -134,6 +135,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history of changes. **Recent highlights:** +- **Project planning view** — per-project goal/deadline/status "Initiatives" shown on a GitHub-Projects-style Roadmap timeline (status swimlanes, month-scale header, "Today" line) - **Keyboard shortcuts** — global `N`/`Cmd/Ctrl+S`/`Cmd/Ctrl+K` shortcuts and a `?` help dialog in the web/PWA build, plus native `Cmd/Ctrl+N`/`Cmd/Ctrl+S`/`Cmd/Ctrl+K` menu accelerators in the Tauri desktop build - **Guest-mode data durability** — schema-mismatch backups, write-failure toasts, undo for hard deletes, Tauri desktop disk-based backup snapshots (independent of `localStorage`, including a final flush on app quit), and an in-app "Data Recovery" UI in Settings to preview and restore those backups - **Self-hosted SQL backend** — opt-in PostgreSQL/MySQL support via a small REST API (`server/`), alongside the existing Supabase and local storage modes 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. 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 + +