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
25 changes: 17 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md - AI Assistant Codebase Guide

**Last Updated:** 2026-09-18
**Last Updated:** 2026-10-19
**Version:** 2.8.0

Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and consultants, with dual storage (localStorage guest mode and optional Supabase cloud sync).
Expand All @@ -22,16 +22,16 @@ Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and cons
- When fixing a bug, add a regression test as part of the same change.
- Verify version alignment between related dev tools (e.g., vitest + @vitest/coverage) when test errors appear.

### Test File Inventory (~390 JS/TS tests across 37 files, plus 17 Rust unit tests across `src-tauri/src/backup.rs`/`menu.rs`/`quit_flush.rs` run via `cargo test`)
### Test File Inventory (~410 JS/TS tests across 37 files, plus 17 Rust unit tests across `src-tauri/src/backup.rs`/`menu.rs`/`quit_flush.rs` run via `cargo test`)

| File | Coverage |
|---|---|
| `src/utils/calculationUtils.test.ts` | `getDayStats`, hours/revenue/billable per-day and per-period |
| `src/utils/calculationUtils.test.ts` | `getDayStats`, hours/revenue/billable per-day and per-period, plus legacy negative durations being ignored |
| `src/utils/checklistUtils.test.ts` | `parseTaskChecklist`, `toggleDescriptionChecklistItem` |
| `src/utils/exportUtils.test.ts` | `exportToCSV`, `exportToJSON`, `generateInvoiceData`, `parseCSVImport` |
| `src/utils/reportUtils.test.ts` | Report generation utilities |
| `src/utils/timeUtil.test.ts` | Time formatting utilities |
| `src/contexts/TimeTracking.test.tsx` | Full context: day/task/archive management, todos, planned tasks, project CRUD, category CRUD, `discardDay`, `adjustTaskTime`, `addBackdatedDay`, client archive/restore |
| `src/utils/timeUtil.test.ts` | Time formatting utilities, `elapsedMs` zero floor, `isEndBeforeStart` |
| `src/contexts/TimeTracking.test.tsx` | Full context: day/task/archive management, todos, planned tasks, project CRUD, category CRUD, `discardDay`, `adjustTaskTime` (incl. end-before-start clamp), `endDay` never producing negative durations, `addBackdatedDay`, client archive/restore |
| `src/contexts/TimeTrackingContext.forceSync.test.tsx` | `forceSyncToDatabase` behavior |
| `src/services/dataService.test.ts` | `LocalStorageService` save/load round-trips |
| `src/services/supabaseService.test.ts` | Upsert-only bulk saves and single-row deletes (`saveProjects`, `deleteProject`, `saveCategories`, `deleteCategory`, `saveTodos`), plus `getCurrentDay`/`saveCurrentDay`, `getArchivedDays` task pagination past the 1000-row PostgREST cap, `checkNewSchema` schema detection, and `migrateFromLocalStorage` (no-op/no-existing-data/client-name-reconcile/error-swallow paths) |
Expand All @@ -50,8 +50,8 @@ Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and cons
| `src/components/NewTaskForm.test.tsx` | Form render, submit, validation, cancel, FAB toggle |
| `src/components/TaskItem.test.tsx` | Badge render, delete confirm, edit dialog |
| `src/components/PlannedTaskPicker.test.tsx` | Dashboard empty-day picker: ordering, filtering, start vs resume, list cap |
| `src/components/ArchiveEditDialog.test.tsx` | Archive edit dialog render and task editing flow |
| `src/components/ArchivedTaskRow.test.tsx` | Inline expandable archived task row editing |
| `src/components/ArchiveEditDialog.test.tsx` | Archive edit dialog render, task editing flow, and end-before-start validation (day form and Add Task) |
| `src/components/ArchivedTaskRow.test.tsx` | Inline expandable archived task row editing, including end-before-start validation |
| `src/components/dateParsing.test.ts` | Date parsing utilities |
| `src/components/BackgroundTimerNotifier.test.tsx` | Document title/OS notification updates while a timer runs in the background |
| `src/components/CommandPalette.test.tsx` | `Cmd/Ctrl+K` command palette actions and navigation |
Expand Down Expand Up @@ -172,7 +172,7 @@ export const MyComponent = () => {
| `src/hooks/useLongPress.ts` | 500 ms hold detector for context menu trigger on touch |
| `src/components/ui/responsive-select.tsx` | `ResponsiveSelect` — branches on `useIsMobile()` to render a native `<select>` on mobile (OS picker) vs the Radix `Select` on desktop; flat `options` array API shared by `NewTaskForm`, `ProjectSheet`, `BackdatedEntryDialog`, `PlannedTaskDialog`, `ArchivedTaskRow` |
| `src/components/PlannedTaskPicker.tsx` | Empty started-day card on the Dashboard: lists in-progress/to-do planned tasks with Start (`pullPlannedTaskToDay`) / Resume (`addPlannedTaskToDay`) buttons. Task Board cards and the Tasks page header can also start the day (`startDayAndPullPlannedTask`, "now" rounded to 15 min, no dialog) |
| `src/components/ArchivedTaskRow.tsx` | Inline expandable row for editing a single task within `ArchiveEditDialog` (replaces the old `TaskEditInArchiveDialog` modal) |
| `src/components/ArchivedTaskRow.tsx` | Inline expandable row for editing a single task within `ArchiveEditDialog` (replaces the old `TaskEditInArchiveDialog` modal); disables Save and shows an inline alert when the end time is before the start |
| `src/services/localStorageService/recovery.ts` | Lists/previews/restores localStorage schema-mismatch backup keys and desktop (Tauri) disk-backup snapshots; the read side of the write-only backup safety nets |
| `src/hooks/useDataRecovery.ts` | Unifies browser (`recovery.ts`) and desktop (`useElectronBackup`'s `listDiskBackups`/`readDiskBackup`, Tauri-backed) backup sources behind one list/preview/restore API |
| `src/components/DataRecoveryDialog.tsx` | Settings → "Data Recovery" (guest mode only): lists backups, previews entity-count diffs against current state, restores and reloads |
Expand All @@ -186,6 +186,15 @@ export const MyComponent = () => {

---

## Time Tracking Invariants

- **Tracked time is never negative.** Compute durations with `elapsedMs(start, end)` from `@/utils/timeUtil` (zero-floored), never a bare `end.getTime() - start.getTime()`. Rounding one end of a span to 15 minutes can push it before the other.
- `endDay` clamps its rounded end time to the latest of the day start, the running task's start, and the latest recorded task end.
- `adjustTaskTime` collapses an end-before-start edit to a zero-length task; calculation and format helpers ignore or zero any negative value already in storage.
- Archive editors (`ArchivedTaskRow`, the `ArchiveEditDialog` day form and Add Task form) reject an end before the start via `isEndBeforeStart` and `END_BEFORE_START_MESSAGE`. Equal times are allowed, since rounding can collapse a short task to zero length.

---

## Client Management

Clients are a managed entity (added in the client-management feature) that backs the project form's client dropdown.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Archive editors now reject an end time before the start time instead of silently saving a zero-length task. `ArchivedTaskRow` and the Add Task form in `ArchiveEditDialog` show an inline `role="alert"` message, set `aria-invalid`/`aria-describedby` on the end-time input, and disable Save/Add Task until corrected; the day summary form rejects it through its zod schema, and Save Changes reopens a collapsed summary editor so the error is visible. Equal start and end times remain allowed because 15-minute rounding can collapse a short task to zero length. New shared `isEndBeforeStart` and `END_BEFORE_START_MESSAGE` in `timeUtil`; `TimePicker` accepts `aria-invalid`
— `src/utils/timeUtil.ts`, `src/components/ArchivedTaskRow.tsx`, `src/components/ArchiveEditDialog.tsx`, `src/components/ui/scroll-time-picker.tsx`, `src/components/ArchivedTaskRow.test.tsx`, `src/components/ArchiveEditDialog.test.tsx`, `src/utils/timeUtil.test.ts`
- Task durations and a day's end time can no longer go negative. `endDay` rounds the end time to 15 minutes while a running task's start is unrounded, so ending a day shortly after starting a task (e.g. task started 9:20, day ended 9:21 → rounded to 9:15) produced a negative task duration and an end time before the task began. `endDay` now never ends earlier than the day start, the running task's start, or the latest recorded task end. Every duration subtraction (task end, `beginTask`, `adjustTaskTime`, planned-task completion, archive task edit/add) goes through a new zero-floored `elapsedMs` helper, and `adjustTaskTime` collapses an end-before-start edit to a zero-length task. Stored negative values from earlier versions are ignored by the day/period/hours/revenue calculations and display as zero in `formatDuration`/`formatDurationLong`
— `src/utils/timeUtil.ts`, `src/utils/calculationUtils.ts`, `src/utils/reportUtils.ts`, `src/contexts/TimeTrackingContext.tsx`, `src/components/ArchivedTaskRow.tsx`, `src/components/ArchiveEditDialog.tsx`, `src/contexts/TimeTracking.test.tsx`, `src/utils/timeUtil.test.ts`, `src/utils/calculationUtils.test.ts`
- Editing a current-day task's start time no longer leaves the day starting after its own first task: `adjustTaskTime` now pulls `dayStartTime` earlier when a task is moved before it (moving a task later leaves the day start alone). It also now persists the change through `saveCurrentDay` like the other task mutations, instead of only flagging unsaved changes and waiting for the next sync
— `src/contexts/TimeTrackingContext.tsx`, `src/contexts/TimeTracking.test.tsx`
- Archived-day editing (`ArchiveEditDialog`, `ArchivedTaskRow`) had several accessibility gaps and unmemoized re-render/re-parse patterns. The "Restore" button no longer grabs focus via `autoFocus` when the dialog opens, letting Radix's default dialog focus behavior take over instead of landing keyboard focus on a state-changing action before the user has read anything. The day-summary and per-task edit toggles now announce their expanded/collapsed state via `aria-expanded`/`aria-controls`. The tasks table's header cells gained `scope="col"` and the table itself an accessible name tied to the day being edited. Per-task delete now requires a same-row Confirm/Cancel step instead of removing the row on a single click, matching the destructive-action pattern used elsewhere (the task isn't persisted until "Save Changes," so this doesn't need the full undo-toast machinery). On the performance side, the day/task dirty-check (`tasksChanged`) and the `handleTaskSave`/`handleTaskDelete`/toggle-expand callbacks are now memoized (`useMemo`/`useCallback`), and `ArchivedTaskRow`/`MarkdownDisplay` are wrapped in `React.memo`, so expanding one task row no longer re-renders every sibling row or re-parses unrelated markdown
Expand Down
6 changes: 3 additions & 3 deletions README-EXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ For the main overview, see [README.md](README.md).

**Ongoing:**

- View archived days on the **Archive** page.
- View archived days on the **Archive** page. When editing an archived day, task, or new task, an end time before the start time is flagged inline and Save/Add Task stays disabled until it is corrected.
- Manage projects and rates via **Archive → Projects**.
- Customize categories on the **Categories** page.
- Export data via **Archive → Export**.
Expand All @@ -70,7 +70,7 @@ For the main overview, see [README.md](README.md).
- **Description** (optional) — detailed notes with markdown support
- **Project** (optional) — assign to a client/project
- **Category** (optional) — categorize the work type
3. Task duration calculates automatically.
3. Task duration calculates automatically. Durations are never negative: ending a day snaps its end time to the nearest 15 minutes but never earlier than the running task's start, and editing a task so it ends before it starts collapses it to zero length.
4. Create new tasks as you switch between work items.

**Evening:**
Expand Down Expand Up @@ -473,7 +473,7 @@ src/
│ ├── checklistUtils.ts # GFM checklist extraction
│ ├── exportUtils.ts # CSV import/export
│ ├── reportUtils.ts # Report grouping and formatting
│ └── timeUtil.ts # Time formatting
│ └── timeUtil.ts # Time formatting, 15-minute rounding, zero-floored `elapsedMs`, end-before-start check
├── App.css
├── App.tsx
├── index.css
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A Progressive Web App (PWA) for time tracking built with React, TypeScript, and
## Features

- **Daily Time Tracking** — start/stop your workday with clear daily boundaries
- **Task Management** — create, edit, and delete tasks with real-time duration tracking
- **Task Management** — create, edit, and delete tasks with real-time duration tracking; tracked time is never negative, and an end time earlier than its start is rejected in the archive editors
- **Projects & Clients** — organize work by project with per-project hourly rates; projects and clients can be archived and restored
- **Client Management** — maintain a managed client list with address and contact details; add, edit, and archive clients (blocked while they still have active projects); pick clients from a dropdown when creating projects
- **Revenue Tracking** — automatic earnings calculation based on hourly rates
Expand Down Expand Up @@ -60,7 +60,7 @@ pnpm preview # Preview production build

# Code Quality
pnpm lint # ESLint
pnpm test # Vitest unit tests (395 tests across 38 files)
pnpm test # Vitest unit tests (410 tests across 37 files)

# PWA Screenshots
pnpm screenshots:install # Install Playwright (first time)
Expand Down
13 changes: 13 additions & 0 deletions agents/pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ Any utility that needs archived days must consume the context or accept
`DayRecord[]` as a parameter. Use `dayRecordsToArchivedDays()` from
`src/utils/reportUtils.ts` to convert to the `ArchivedDay[]` shape.

### 7. Subtracting Timestamps Directly

```typescript
// ❌ WRONG — goes negative when rounding moves one end past the other
const duration = endTime.getTime() - startTime.getTime();

// ✅ CORRECT — zero-floored
import { elapsedMs } from "@/utils/timeUtil";
const duration = elapsedMs(startTime, endTime);
```

Time forms that take a start and an end should also reject an end before the start with `isEndBeforeStart`.

---

## Architecture Gotchas
Expand Down
68 changes: 67 additions & 1 deletion src/components/ArchiveEditDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { render, screen, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ArchiveEditDialog } from "@/components/ArchiveEditDialog";
import { TooltipProvider } from "@/components/ui/tooltip";
Expand Down Expand Up @@ -185,4 +185,70 @@ describe("ArchiveEditDialog", () => {
expect(lastTask.endTime.getHours()).toBe(17);
expect(lastTask.endTime.getMinutes()).toBe(45);
});

it("rejects a day end time before the start time and keeps the editor open with the error", async () => {
const user = userEvent.setup();
renderDialog({ day: buildDay(), isOpen: true, onClose: vi.fn() });

await user.click(screen.getByRole("button", { name: "Edit day summary" }));
fireEvent.change(screen.getByLabelText("Day end time"), {
target: { value: "08:00" },
});
await user.click(screen.getByRole("button", { name: /save changes/i }));

expect(
await screen.findByText("End time must be after start time.")
).toBeInTheDocument();
expect(updateArchivedDay).not.toHaveBeenCalled();
});

it("opens the day-summary editor to show the error when a collapsed day is invalid", async () => {
const user = userEvent.setup();
const day = buildDay();
// Legacy bad data: the day ends before it starts.
day.endTime = new Date(2026, 5, 15, 8, 0, 0);
renderDialog({ day, isOpen: true, onClose: vi.fn() });

// Stage an unrelated task edit so Save Changes is enabled.
const taskTwoRow = screen.getByText("Task Two").closest("tr")!;
await user.click(within(taskTwoRow).getByRole("button", { name: "Edit task" }));
const titleInput = screen.getByLabelText(/Task Title/);
await user.clear(titleInput);
await user.type(titleInput, "Task Two Updated");
await user.click(screen.getByRole("button", { name: "Save" }));

expect(screen.queryByLabelText("Day end time")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /save changes/i }));

expect(await screen.findByLabelText("Day end time")).toBeInTheDocument();
expect(
screen.getByText("End time must be after start time.")
).toBeInTheDocument();
expect(updateArchivedDay).not.toHaveBeenCalled();
});

it("disables Add Task and shows an error when the new task ends before it starts", async () => {
const user = userEvent.setup();
renderDialog({ day: buildDay(), isOpen: true, onClose: vi.fn() });

await user.click(screen.getByRole("button", { name: "Add Task" }));
await user.type(screen.getByLabelText(/Task Title/), "Late task");
fireEvent.change(screen.getByLabelText("Task start time"), {
target: { value: "14:00" },
});
fireEvent.change(screen.getByLabelText("Task end time"), {
target: { value: "13:00" },
});

expect(screen.getByRole("alert")).toHaveTextContent(
"End time must be after start time."
);
expect(screen.getByRole("button", { name: "Add Task" })).toBeDisabled();

fireEvent.change(screen.getByLabelText("Task end time"), {
target: { value: "15:00" },
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add Task" })).toBeEnabled();
});
});
Loading
Loading