Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`; 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; `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 |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ 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`)
— `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`
Expand Down
6 changes: 3 additions & 3 deletions README-EXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions server/repositories/initiatives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export async function ensureSchema(db: Knex): Promise<void> {
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
Expand All @@ -136,5 +137,10 @@ export async function ensureSchema(db: Knex): Promise<void> {
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");
});
}
}
109 changes: 109 additions & 0 deletions src/components/InitiativeSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -51,6 +52,7 @@ describe("InitiativeSheet", () => {
it("requires a deadline date when the deadline type is date", async () => {
render(<InitiativeSheet open onOpenChange={() => {}} 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();
Expand Down Expand Up @@ -97,6 +99,113 @@ describe("InitiativeSheet", () => {
});
});

it("pre-fills the deadline date with today's local date in add mode", () => {
render(<InitiativeSheet open onOpenChange={() => {}} 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(<InitiativeSheet open onOpenChange={() => {}} 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(<InitiativeSheet open onOpenChange={() => {}} 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(<InitiativeSheet open onOpenChange={() => {}} 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(
<InitiativeSheet open onOpenChange={() => {}} 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(<InitiativeSheet open onOpenChange={() => {}} 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(<InitiativeSheet open onOpenChange={() => {}} 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(<InitiativeSheet open onOpenChange={() => {}} 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(<InitiativeSheet open onOpenChange={() => {}} 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(
<InitiativeSheet
open
onOpenChange={() => {}}
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(<InitiativeSheet open onOpenChange={() => {}} mode="add" projectId="proj-1" />);
expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument();
Expand Down
Loading
Loading