From 2122e82b821b56650d5d79904b702af2f3f4de2c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:55:55 +0000 Subject: [PATCH 1/2] feat: add optional start date to planning initiatives Initiatives previously only had a deadline, with the timeline bar starting at the creation date. Add an optional startDate across the form, timeline, localStorage, Supabase (new start_date column + migration), and the SQL backend (column auto-added to existing databases). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WpYBKifQTutKUHHgtRMNTM --- AGENTS.md | 6 +- CHANGELOG.md | 2 + README-EXT.md | 6 +- README.md | 4 +- server/repositories/initiatives.ts | 3 + server/schema.ts | 6 ++ src/components/InitiativeSheet.test.tsx | 70 ++++++++++++++++++++++ src/components/InitiativeSheet.tsx | 45 +++++++++++++- src/components/InitiativeTimeline.test.tsx | 29 +++++++++ src/components/InitiativeTimeline.tsx | 18 +++++- src/contexts/TimeTrackingContext.tsx | 1 + src/services/dataService.test.ts | 11 ++++ src/services/supabaseService.test.ts | 14 ++++- src/services/supabaseService.ts | 4 ++ src/utils/initiativeUtils.test.ts | 41 ++++++++++++- src/utils/initiativeUtils.ts | 17 +++++- 16 files changed, 258 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 40b6ff1..60a7565 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,9 +170,9 @@ export const MyComponent = () => { | `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/components/InitiativeSheet.tsx` | Shared Sheet (drawer) for add/edit/delete of a planning `Initiative`; optional `startDate` (supersedes the creation-date floor on the deadline) plus a deadline that 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; bars run from `startDate` (else `createdAt`) to the deadline | +| `src/utils/initiativeUtils.ts` | `resolveInitiativeDeadline`/`resolveInitiativeStart`/`parseLocalDate` — date-vs-quarter deadline and start-date resolution without the UTC-midnight day-shift bug; `isDeadlineBeforeStart` backs the form's start-vs-deadline validation | | `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 6f65805..293d79c 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 +- Planning initiatives now have an optional start date, so a timeline bar covers a full start-to-deadline span instead of always starting at the creation date. The Add/Edit Initiative sheet gains a "Start date" field (blank defaults to the creation date, so existing initiatives render unchanged); the bar label shows "Start – Deadline" when a start is set. An explicit start date replaces the creation-date floor on the deadline, so past work can be logged retroactively, and a deadline before the start date is rejected. Persisted as `startDate` across guest localStorage, Supabase (new `start_date date` column via `20261110_initiative_start_date.sql` — run it on existing projects), and the self-hosted SQL backend (new `start_date` column, added automatically to existing databases on startup) + — `src/components/InitiativeSheet.tsx`, `src/components/InitiativeTimeline.tsx`, `src/utils/initiativeUtils.ts`, `src/contexts/TimeTrackingContext.tsx`, `src/services/supabaseService.ts`, `server/repositories/initiatives.ts`, `server/schema.ts`, `supabase/migrations/20261110_initiative_start_date.sql` (new) - 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` diff --git a/README-EXT.md b/README-EXT.md index dc58cb1..d4915de 100644 --- a/README-EXT.md +++ b/README-EXT.md @@ -126,9 +126,9 @@ Projects enable automatic revenue calculation, per-client invoice exports, and a 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). +1. Click **New Initiative** and enter a name, an optional goal, an optional start date, and a deadline — either a specific date or a calendar quarter (shown on the timeline as that quarter's last day). The deadline can't fall before the start date. Leaving the start date blank uses the date the initiative was created; setting one lets you log past or future-dated work. 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. +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 start date (or its creation date, if none was set) 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. @@ -489,7 +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 +│ ├── initiativeUtils.ts # Initiative start/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 c85dcd1..1160002 100644 --- a/README.md +++ b/README.md @@ -29,7 +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 +- **Project Planning View** — set a goal, status, optional start date, and deadline (date or quarter) for initiatives scoped to a project, visualized as a Roadmap-style timeline - **PWA** — installable on desktop/mobile --- @@ -135,7 +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) +- **Project planning view** — per-project goal/start/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/server/repositories/initiatives.ts b/server/repositories/initiatives.ts index 2c5fe5b..ab27c84 100644 --- a/server/repositories/initiatives.ts +++ b/server/repositories/initiatives.ts @@ -7,6 +7,7 @@ interface InitiativeRow { name: string; goal: string | null; status: string; + start_date: string | null; deadline_type: string; deadline_date: string | null; deadline_quarter: number | null; @@ -22,6 +23,7 @@ function mapRow(row: InitiativeRow): Initiative { name: row.name, goal: row.goal ?? "", status: row.status as InitiativeStatus, + startDate: row.start_date ?? undefined, deadlineType: row.deadline_type as InitiativeDeadlineType, deadlineDate: row.deadline_date ?? undefined, deadlineQuarter: row.deadline_quarter ?? undefined, @@ -38,6 +40,7 @@ function initiativeToRow(initiative: Initiative) { name: initiative.name, goal: initiative.goal, status: initiative.status, + start_date: initiative.startDate ?? null, deadline_type: initiative.deadlineType, deadline_date: initiative.deadlineDate ?? null, deadline_quarter: initiative.deadlineQuarter ?? null, diff --git a/server/schema.ts b/server/schema.ts index bb34ebb..c77855c 100644 --- a/server/schema.ts +++ b/server/schema.ts @@ -125,6 +125,7 @@ export async function ensureSchema(db: Knex): Promise { t.string("name").notNullable(); t.text("goal"); t.string("status").notNullable().defaultTo("not_started"); + t.string("start_date"); // text, same reasoning as deadline_date below 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 @@ -136,5 +137,10 @@ export async function ensureSchema(db: Knex): Promise { t.timestamp("created_at").notNullable(); t.timestamp("updated_at").notNullable().defaultTo(db.fn.now()); }); + } else if (!(await db.schema.hasColumn("planning_initiatives", "start_date"))) { + // Databases created before start dates were introduced. + await db.schema.alterTable("planning_initiatives", (t) => { + t.string("start_date"); + }); } } diff --git a/src/components/InitiativeSheet.test.tsx b/src/components/InitiativeSheet.test.tsx index d4b1a02..c46f09f 100644 --- a/src/components/InitiativeSheet.test.tsx +++ b/src/components/InitiativeSheet.test.tsx @@ -97,6 +97,76 @@ describe("InitiativeSheet", () => { }); }); + it("submits an optional start date alongside the deadline", async () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "With start" } }); + fireEvent.change(screen.getByLabelText(/start date/i), { target: { value: "2099-11-01" } }); + 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({ + startDate: "2099-11-01", deadlineDate: "2099-12-15" + })); + }); + }); + + it("leaves startDate undefined when the start date is left blank", async () => { + addInitiativeMock.mockClear(); + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "No start" } }); + fireEvent.change(screen.getByLabelText(/deadline date/i), { target: { value: "2099-12-15" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => expect(addInitiativeMock).toHaveBeenCalled()); + expect(addInitiativeMock.mock.calls[0][0]).toEqual(expect.objectContaining({ startDate: undefined })); + }); + + it("rejects a deadline before the start date", async () => { + addInitiativeMock.mockClear(); + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Inverted" } }); + fireEvent.change(screen.getByLabelText(/start date/i), { target: { value: "2099-12-15" } }); + fireEvent.change(screen.getByLabelText(/deadline date/i), { target: { value: "2099-11-01" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(screen.getByText(/deadline must be on or after the start date/i)).toBeInTheDocument(); + }); + expect(addInitiativeMock).not.toHaveBeenCalled(); + }); + + it("allows a backdated start and deadline, since an explicit start supersedes the creation floor", async () => { + addInitiativeMock.mockClear(); + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Past work" } }); + fireEvent.change(screen.getByLabelText(/start date/i), { target: { value: "2000-01-01" } }); + fireEvent.change(screen.getByLabelText(/deadline date/i), { target: { value: "2000-02-01" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(addInitiativeMock).toHaveBeenCalledWith(expect.objectContaining({ + startDate: "2000-01-01", deadlineDate: "2000-02-01" + })); + }); + }); + + it("pre-fills the start date in edit mode and clears it when emptied", async () => { + updateInitiativeMock.mockClear(); + render( + {}} + mode="edit" + projectId="proj-1" + initiative={{ ...EXISTING_INITIATIVE, startDate: "2098-06-01" }} + /> + ); + const input = screen.getByLabelText(/start date/i) as HTMLInputElement; + expect(input.value).toBe("2098-06-01"); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + await waitFor(() => { + expect(updateInitiativeMock).toHaveBeenCalledWith("init-1", expect.objectContaining({ startDate: undefined })); + }); + }); + 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(); diff --git a/src/components/InitiativeSheet.tsx b/src/components/InitiativeSheet.tsx index a3443b6..aee8740 100644 --- a/src/components/InitiativeSheet.tsx +++ b/src/components/InitiativeSheet.tsx @@ -15,6 +15,7 @@ import { FormItem, FormLabel, FormControl, + FormDescription, FormMessage, } from "@/components/ui/form"; import { Button } from "@/components/ui/button"; @@ -25,7 +26,12 @@ 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"; +import { + getQuarterEndDate, + isDeadlineBeforeCreation, + isDeadlineBeforeStart, + parseLocalDate, +} from "@/utils/initiativeUtils"; interface InitiativeSheetProps { open: boolean; @@ -61,6 +67,7 @@ export const InitiativeSheet: React.FC = ({ name: z.string().trim().min(1, "Name is required."), goal: z.string().trim(), status: z.enum(["not_started", "in_progress", "done", "blocked"]), + startDate: z.string().trim(), deadlineType: z.enum(["date", "quarter"]), deadlineDate: z.string().trim(), deadlineQuarter: z.string().trim(), @@ -102,11 +109,22 @@ export const InitiativeSheet: React.FC = ({ const validYear = /^\d{4}$/.test(values.deadlineYear); deadline = quarter && validYear ? getQuarterEndDate(quarter, year) : null; } - if (deadline && isDeadlineBeforeCreation(createdAt, deadline)) { + const deadlinePath = values.deadlineType === "date" ? "deadlineDate" : "deadlineYear"; + if (deadline && values.startDate) { + // An explicit start date supersedes the creation-date floor, so past + // initiatives can be logged retroactively. + if (isDeadlineBeforeStart(parseLocalDate(values.startDate), deadline)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Deadline must be on or after the start date.", + path: [deadlinePath], + }); + } + } else 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"], + path: [deadlinePath], }); } }); @@ -117,6 +135,7 @@ export const InitiativeSheet: React.FC = ({ name: "", goal: "", status: "not_started", + startDate: "", deadlineType: "date", deadlineDate: "", deadlineQuarter: "", @@ -136,6 +155,7 @@ export const InitiativeSheet: React.FC = ({ name: initiative.name, goal: initiative.goal, status: initiative.status, + startDate: initiative.startDate ?? "", deadlineType: initiative.deadlineType, deadlineDate: initiative.deadlineDate ?? "", deadlineQuarter: initiative.deadlineQuarter ? String(initiative.deadlineQuarter) : "", @@ -179,6 +199,7 @@ export const InitiativeSheet: React.FC = ({ projectId, name: values.name, goal: values.goal, + startDate: values.startDate || undefined, ...deadlineFields, }); } else if (mode === "edit" && initiative) { @@ -186,6 +207,7 @@ export const InitiativeSheet: React.FC = ({ name: values.name, goal: values.goal, status: values.status, + startDate: values.startDate || undefined, ...deadlineFields, }); } @@ -258,6 +280,23 @@ export const InitiativeSheet: React.FC = ({ )} /> + ( + + Start date + + + + + Optional. Defaults to the date the initiative was created. + + + + )} + /> +
{!isCollapsed && items.map((initiative) => { - const start = new Date(initiative.createdAt); + const start = resolveInitiativeStart(initiative); const deadline = resolveInitiativeDeadline(initiative) ?? new Date(start.getTime() + MS_PER_DAY); const left = toPercent(start); const right = toPercent(deadline); @@ -143,7 +155,7 @@ export const InitiativeTimeline: React.FC = ({ initiati className="absolute h-6 rounded text-primary-foreground text-xs flex items-center px-2 whitespace-nowrap overflow-hidden" style={{ left: `${left}%`, width: `${width}%`, backgroundColor: STATUS_BAR_COLOR[status] }} > - {initiative.name} · {formatDeadlineLabel(initiative)} + {initiative.name} · {formatSpanLabel(initiative)}
diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 449b7df..e72c602 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -92,6 +92,7 @@ export interface Initiative { name: string; goal: string; status: InitiativeStatus; + startDate?: string; // "YYYY-MM-DD"; optional, falls back to createdAt when unset deadlineType: InitiativeDeadlineType; deadlineDate?: string; // ISO date string; set when deadlineType === "date" deadlineQuarter?: number; // 1-4; set when deadlineType === "quarter" diff --git a/src/services/dataService.test.ts b/src/services/dataService.test.ts index 4c4028c..0b40149 100644 --- a/src/services/dataService.test.ts +++ b/src/services/dataService.test.ts @@ -245,6 +245,17 @@ describe('DataService', () => { }); describe("initiatives", () => { + it("round-trips an initiative's start date", async () => { + const service = createDataService(false); + await service.saveInitiatives([{ + id: "i-start", projectId: "p1", name: "n", goal: "g", status: "not_started", + startDate: "2026-11-03", deadlineType: "date", deadlineDate: "2026-12-15", + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }]); + const loaded = await service.getInitiatives(); + expect(loaded[0].startDate).toBe("2026-11-03"); + }); + it("round-trips an initiative with a date deadline", async () => { const service = createDataService(false); const initiative: import("@/contexts/TimeTrackingContext").Initiative = { diff --git a/src/services/supabaseService.test.ts b/src/services/supabaseService.test.ts index 50bbbe6..a8001f5 100644 --- a/src/services/supabaseService.test.ts +++ b/src/services/supabaseService.test.ts @@ -300,7 +300,19 @@ describe("SupabaseService — upsert-only bulk saves (#215)", () => { await service.upsertInitiative(initiative); expect(builder.upsert).toHaveBeenCalledWith( - expect.objectContaining({ id: "i2", deadline_quarter: 1, deadline_year: 2027 }), + expect.objectContaining({ id: "i2", deadline_quarter: 1, deadline_year: 2027, start_date: null }), + { onConflict: "id" } + ); + }); + + it("persists the start date as start_date", async () => { + await service.upsertInitiative({ + id: "i3", projectId: "proj-1", name: "n", goal: "g", status: "not_started", + startDate: "2026-11-03", deadlineType: "date", deadlineDate: "2026-12-15", + createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" + }); + expect(builder.upsert).toHaveBeenCalledWith( + expect.objectContaining({ id: "i3", start_date: "2026-11-03" }), { onConflict: "id" } ); }); diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts index ff85907..cbb6cf0 100644 --- a/src/services/supabaseService.ts +++ b/src/services/supabaseService.ts @@ -1025,6 +1025,7 @@ export class SupabaseService implements DataService { name: i.name, goal: i.goal, status: i.status, + start_date: i.startDate ?? null, deadline_type: i.deadlineType, deadline_date: i.deadlineDate ?? null, deadline_quarter: i.deadlineQuarter ?? null, @@ -1048,6 +1049,7 @@ export class SupabaseService implements DataService { name: initiative.name, goal: initiative.goal, status: initiative.status, + start_date: initiative.startDate ?? null, deadline_type: initiative.deadlineType, deadline_date: initiative.deadlineDate ?? null, deadline_quarter: initiative.deadlineQuarter ?? null, @@ -1090,6 +1092,7 @@ export class SupabaseService implements DataService { name: string; goal: string; status: string; + start_date: string | null; deadline_type: string; deadline_date: string | null; deadline_quarter: number | null; @@ -1102,6 +1105,7 @@ export class SupabaseService implements DataService { name: row.name, goal: row.goal, status: row.status as Initiative["status"], + startDate: row.start_date ?? undefined, deadlineType: row.deadline_type as Initiative["deadlineType"], deadlineDate: row.deadline_date ?? undefined, deadlineQuarter: row.deadline_quarter ?? undefined, diff --git a/src/utils/initiativeUtils.test.ts b/src/utils/initiativeUtils.test.ts index 1624deb..d40fd51 100644 --- a/src/utils/initiativeUtils.test.ts +++ b/src/utils/initiativeUtils.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { getQuarterEndDate, resolveInitiativeDeadline, isDeadlineBeforeCreation } from "./initiativeUtils"; +import { + getQuarterEndDate, + resolveInitiativeDeadline, + resolveInitiativeStart, + isDeadlineBeforeCreation, + isDeadlineBeforeStart +} from "./initiativeUtils"; import type { Initiative } from "@/contexts/TimeTrackingContext"; describe("getQuarterEndDate", () => { @@ -93,3 +99,36 @@ describe("isDeadlineBeforeCreation", () => { expect(isDeadlineBeforeCreation(createdAt, todayAsResolvedDeadline)).toBe(false); }); }); + +describe("resolveInitiativeStart", () => { + const base: Initiative = { + id: "i1", projectId: "p1", name: "n", goal: "g", + status: "not_started", deadlineType: "date", deadlineDate: "2026-12-15", + createdAt: "2026-10-01T12:00:00.000Z", updatedAt: "2026-10-01T12:00:00.000Z" + }; + + it("falls back to the creation timestamp when no start date is set", () => { + expect(resolveInitiativeStart(base).toISOString()).toBe("2026-10-01T12:00:00.000Z"); + }); + + it("resolves an explicit start date to the exact local calendar day entered", () => { + const result = resolveInitiativeStart({ ...base, startDate: "2026-11-03" }); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(10); + expect(result.getDate()).toBe(3); + }); +}); + +describe("isDeadlineBeforeStart", () => { + it("is true when the deadline falls on an earlier day than the start", () => { + expect(isDeadlineBeforeStart(new Date(2026, 10, 10), new Date(2026, 10, 9))).toBe(true); + }); + + it("is false when the deadline is the same day as the start, regardless of time of day", () => { + expect(isDeadlineBeforeStart(new Date(2026, 10, 10, 17, 0), new Date(2026, 10, 10, 0, 0))).toBe(false); + }); + + it("is false when the deadline is after the start", () => { + expect(isDeadlineBeforeStart(new Date(2026, 10, 10), new Date(2026, 11, 1))).toBe(false); + }); +}); diff --git a/src/utils/initiativeUtils.ts b/src/utils/initiativeUtils.ts index c77b555..f8cf48c 100644 --- a/src/utils/initiativeUtils.ts +++ b/src/utils/initiativeUtils.ts @@ -22,12 +22,23 @@ export function resolveInitiativeDeadline(initiative: Initiative): Date | null { return getQuarterEndDate(initiative.deadlineQuarter, initiative.deadlineYear); } +// Start of the initiative's span: the explicit start date when set, otherwise +// the creation timestamp (the pre-start-date behavior, so legacy rows render +// unchanged). +export function resolveInitiativeStart(initiative: Initiative): Date { + return initiative.startDate ? parseLocalDate(initiative.startDate) : new Date(initiative.createdAt); +} + // Compares at local calendar-day granularity rather than exact instant, so // choosing "today" as a deadline is never rejected due to the time-of-day // already elapsed when the initiative was created. export function isDeadlineBeforeCreation(createdAt: string, deadline: Date): boolean { - const created = new Date(createdAt); - const createdDay = new Date(created.getFullYear(), created.getMonth(), created.getDate()); + return isDeadlineBeforeStart(new Date(createdAt), deadline); +} + +// True when `deadline` falls on an earlier local calendar day than `start`. +export function isDeadlineBeforeStart(start: Date, deadline: Date): boolean { + const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate()); const deadlineDay = new Date(deadline.getFullYear(), deadline.getMonth(), deadline.getDate()); - return deadlineDay < createdDay; + return deadlineDay < startDay; } From cc38266e1f578cdbcafa454d0a6ca06d80a51aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:58:40 +0000 Subject: [PATCH 2/2] feat: pre-fill initiative deadline pickers with today and current year The Specific date picker now opens on today's local date and the Quarter picker's year on the current year. Start date stays blank. Edit mode keeps saved values and only falls back to the defaults when switching deadline type. Claude-Session: https://claude.ai/code/session_01WpYBKifQTutKUHHgtRMNTM --- AGENTS.md | 4 +-- CHANGELOG.md | 2 ++ README-EXT.md | 2 +- src/components/InitiativeSheet.test.tsx | 39 +++++++++++++++++++++++++ src/components/InitiativeSheet.tsx | 17 ++++++++--- src/utils/initiativeUtils.test.ts | 19 +++++++++++- src/utils/initiativeUtils.ts | 8 +++++ 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60a7565..00f5a3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,9 +170,9 @@ export const MyComponent = () => { | `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`; optional `startDate` (supersedes the creation-date floor on the deadline) plus a deadline that is a specific date or a calendar quarter | +| `src/components/InitiativeSheet.tsx` | Shared Sheet (drawer) for add/edit/delete of a planning `Initiative`; deadline pickers pre-fill with today / the current year; optional `startDate` (supersedes the creation-date floor on the deadline) plus a deadline that 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; bars run from `startDate` (else `createdAt`) to the deadline | -| `src/utils/initiativeUtils.ts` | `resolveInitiativeDeadline`/`resolveInitiativeStart`/`parseLocalDate` — date-vs-quarter deadline and start-date resolution without the UTC-midnight day-shift bug; `isDeadlineBeforeStart` backs the form's start-vs-deadline validation | +| `src/utils/initiativeUtils.ts` | `resolveInitiativeDeadline`/`resolveInitiativeStart`/`parseLocalDate` — date-vs-quarter deadline and start-date resolution without the UTC-midnight day-shift bug; `isDeadlineBeforeStart` backs the form's start-vs-deadline validation; `toLocalDateString` formats the local day for date-input defaults | | `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 293d79c..8d6bcd0 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 +- The Add Initiative sheet's deadline pickers now open pre-filled: the "Specific date" picker with today's local date and the "Quarter" picker's year with the current year (the quarter itself is still chosen by the user). Editing an existing initiative keeps its saved deadline and only falls back to these values when switching deadline type. The start date stays blank by default + — `src/components/InitiativeSheet.tsx`, `src/utils/initiativeUtils.ts` (`toLocalDateString`) - Planning initiatives now have an optional start date, so a timeline bar covers a full start-to-deadline span instead of always starting at the creation date. The Add/Edit Initiative sheet gains a "Start date" field (blank defaults to the creation date, so existing initiatives render unchanged); the bar label shows "Start – Deadline" when a start is set. An explicit start date replaces the creation-date floor on the deadline, so past work can be logged retroactively, and a deadline before the start date is rejected. Persisted as `startDate` across guest localStorage, Supabase (new `start_date date` column via `20261110_initiative_start_date.sql` — run it on existing projects), and the self-hosted SQL backend (new `start_date` column, added automatically to existing databases on startup) — `src/components/InitiativeSheet.tsx`, `src/components/InitiativeTimeline.tsx`, `src/utils/initiativeUtils.ts`, `src/contexts/TimeTrackingContext.tsx`, `src/services/supabaseService.ts`, `server/repositories/initiatives.ts`, `server/schema.ts`, `supabase/migrations/20261110_initiative_start_date.sql` (new) - 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`) diff --git a/README-EXT.md b/README-EXT.md index d4915de..4605389 100644 --- a/README-EXT.md +++ b/README-EXT.md @@ -126,7 +126,7 @@ Projects enable automatic revenue calculation, per-client invoice exports, and a 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, an optional start date, and a deadline — either a specific date or a calendar quarter (shown on the timeline as that quarter's last day). The deadline can't fall before the start date. Leaving the start date blank uses the date the initiative was created; setting one lets you log past or future-dated work. +1. Click **New Initiative** and enter a name, an optional goal, an optional start date, and a deadline — either a specific date or a calendar quarter (shown on the timeline as that quarter's last day). The deadline pickers open pre-filled with today's date (specific date) or the current year (quarter), and the start date is left blank. The deadline can't fall before the start date. Leaving the start date blank uses the date the initiative was created; setting one lets you log past or future-dated work. 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 start date (or its creation date, if none was set) 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). diff --git a/src/components/InitiativeSheet.test.tsx b/src/components/InitiativeSheet.test.tsx index c46f09f..eaa326a 100644 --- a/src/components/InitiativeSheet.test.tsx +++ b/src/components/InitiativeSheet.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { InitiativeSheet } from "./InitiativeSheet"; +import { toLocalDateString } from "@/utils/initiativeUtils"; const addInitiativeMock = vi.fn(() => ({ id: "new-id" })); const updateInitiativeMock = vi.fn(); @@ -51,6 +52,7 @@ describe("InitiativeSheet", () => { 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.change(screen.getByLabelText(/deadline date/i), { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: /add/i })); await waitFor(() => { expect(screen.getByText(/deadline date is required/i)).toBeInTheDocument(); @@ -97,6 +99,43 @@ describe("InitiativeSheet", () => { }); }); + it("pre-fills the deadline date with today's local date in add mode", () => { + render( {}} mode="add" projectId="proj-1" />); + const input = screen.getByLabelText(/deadline date/i) as HTMLInputElement; + expect(input.value).toBe(toLocalDateString(new Date())); + }); + + it("pre-fills the quarter deadline year with the current year, leaving the quarter unset", () => { + render( {}} mode="add" projectId="proj-1" />); + fireEvent.click(screen.getByRole("button", { name: /^quarter$/i })); + const year = screen.getByLabelText(/deadline year/i) as HTMLInputElement; + expect(year.value).toBe(String(new Date().getFullYear())); + }); + + it("leaves the optional start date blank by default", () => { + render( {}} mode="add" projectId="proj-1" />); + expect((screen.getByLabelText(/start date/i) as HTMLInputElement).value).toBe(""); + }); + + it("submits with only a name when the pre-filled deadline date is accepted", async () => { + addInitiativeMock.mockClear(); + render( {}} mode="add" projectId="proj-1" />); + fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "Defaults only" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + await waitFor(() => { + expect(addInitiativeMock).toHaveBeenCalledWith(expect.objectContaining({ + deadlineType: "date", deadlineDate: toLocalDateString(new Date()) + })); + }); + }); + + it("keeps an existing initiative's saved deadline instead of the defaults in edit mode", () => { + render( + {}} mode="edit" projectId="proj-1" initiative={EXISTING_INITIATIVE} /> + ); + expect((screen.getByLabelText(/deadline date/i) as HTMLInputElement).value).toBe("2099-01-01"); + }); + it("submits an optional start date alongside the deadline", async () => { render( {}} mode="add" projectId="proj-1" />); fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: "With start" } }); diff --git a/src/components/InitiativeSheet.tsx b/src/components/InitiativeSheet.tsx index aee8740..1f7abbd 100644 --- a/src/components/InitiativeSheet.tsx +++ b/src/components/InitiativeSheet.tsx @@ -31,6 +31,7 @@ import { isDeadlineBeforeCreation, isDeadlineBeforeStart, parseLocalDate, + toLocalDateString, } from "@/utils/initiativeUtils"; interface InitiativeSheetProps { @@ -131,15 +132,21 @@ export const InitiativeSheet: React.FC = ({ type InitiativeFormValues = z.infer; + // Deadline pickers open pre-filled with today / the current year. The + // quarter itself is left for the user to choose. + const today = new Date(); + const todayString = toLocalDateString(today); + const currentYearString = String(today.getFullYear()); + const defaultFormValues: InitiativeFormValues = { name: "", goal: "", status: "not_started", startDate: "", deadlineType: "date", - deadlineDate: "", + deadlineDate: todayString, deadlineQuarter: "", - deadlineYear: "", + deadlineYear: currentYearString, }; const form = useForm({ @@ -157,9 +164,11 @@ export const InitiativeSheet: React.FC = ({ status: initiative.status, startDate: initiative.startDate ?? "", deadlineType: initiative.deadlineType, - deadlineDate: initiative.deadlineDate ?? "", + // Fall back to the add-mode defaults so switching deadline type on an + // existing initiative also opens pre-filled. + deadlineDate: initiative.deadlineDate ?? defaultFormValues.deadlineDate, deadlineQuarter: initiative.deadlineQuarter ? String(initiative.deadlineQuarter) : "", - deadlineYear: initiative.deadlineYear ? String(initiative.deadlineYear) : "", + deadlineYear: initiative.deadlineYear ? String(initiative.deadlineYear) : defaultFormValues.deadlineYear, }); } else { form.reset(defaultFormValues); diff --git a/src/utils/initiativeUtils.test.ts b/src/utils/initiativeUtils.test.ts index d40fd51..bc7768b 100644 --- a/src/utils/initiativeUtils.test.ts +++ b/src/utils/initiativeUtils.test.ts @@ -3,8 +3,10 @@ import { getQuarterEndDate, resolveInitiativeDeadline, resolveInitiativeStart, + parseLocalDate, isDeadlineBeforeCreation, - isDeadlineBeforeStart + isDeadlineBeforeStart, + toLocalDateString } from "./initiativeUtils"; import type { Initiative } from "@/contexts/TimeTrackingContext"; @@ -132,3 +134,18 @@ describe("isDeadlineBeforeStart", () => { expect(isDeadlineBeforeStart(new Date(2026, 10, 10), new Date(2026, 11, 1))).toBe(false); }); }); + +describe("toLocalDateString", () => { + it("zero-pads month and day", () => { + expect(toLocalDateString(new Date(2026, 0, 5))).toBe("2026-01-05"); + }); + + it("uses the local calendar day, not the UTC day, late in the evening", () => { + // 23:30 local is already the next day in UTC for any timezone west of it. + expect(toLocalDateString(new Date(2026, 10, 10, 23, 30))).toBe("2026-11-10"); + }); + + it("round-trips with parseLocalDate", () => { + expect(toLocalDateString(parseLocalDate("2026-12-15"))).toBe("2026-12-15"); + }); +}); diff --git a/src/utils/initiativeUtils.ts b/src/utils/initiativeUtils.ts index f8cf48c..c84289f 100644 --- a/src/utils/initiativeUtils.ts +++ b/src/utils/initiativeUtils.ts @@ -14,6 +14,14 @@ export function parseLocalDate(dateStr: string): Date { return new Date(year, month - 1, day); } +// Formats a Date as the local calendar day "YYYY-MM-DD" (the value format of +// ). toISOString() would give the UTC day, which is +// tomorrow's date for an evening user west of UTC. +export function toLocalDateString(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + export function resolveInitiativeDeadline(initiative: Initiative): Date | null { if (initiative.deadlineType === "date") { return initiative.deadlineDate ? parseLocalDate(initiative.deadlineDate) : null;