From 37345b7b1ae43ea8aa8c8268e8b2f6ec896fb63a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:43:26 +0000 Subject: [PATCH 1/2] fix: never record negative task durations or day end times endDay rounded the end time to 15 minutes while a running task's start was unrounded, so the rounded end could precede the task start. Clamp endDay to the latest recorded moment, route all duration subtraction through a zero-floored elapsedMs helper, and ignore legacy negative values in calculations and formatters. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UqjLGeuTJHemDQZo6PbAja --- AGENTS.md | 2 +- CHANGELOG.md | 2 + src/components/ArchiveEditDialog.tsx | 3 +- src/components/ArchivedTaskRow.tsx | 4 +- src/contexts/TimeTracking.test.tsx | 68 ++++++++++++++++++++++++++++ src/contexts/TimeTrackingContext.tsx | 29 +++++++++--- src/utils/calculationUtils.test.ts | 40 +++++++++++++++- src/utils/calculationUtils.ts | 11 +++-- src/utils/reportUtils.ts | 2 +- src/utils/timeUtil.test.ts | 21 +++++++++ src/utils/timeUtil.ts | 12 ++++- 11 files changed, 174 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59aa127..fb456c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ 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 (~400 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 | |---|---| diff --git a/CHANGELOG.md b/CHANGELOG.md index 463a118..d2d1dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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 diff --git a/src/components/ArchiveEditDialog.tsx b/src/components/ArchiveEditDialog.tsx index 27df56b..84a955f 100644 --- a/src/components/ArchiveEditDialog.tsx +++ b/src/components/ArchiveEditDialog.tsx @@ -63,6 +63,7 @@ import { X, } from "lucide-react"; import { + elapsedMs, formatDuration, formatDate, roundToNearest15Minutes, @@ -383,7 +384,7 @@ export const ArchiveEditDialog: React.FC = ({ startTime, endTime, duration: endTime - ? endTime.getTime() - startTime.getTime() + ? elapsedMs(startTime, endTime) : undefined, }; diff --git a/src/components/ArchivedTaskRow.tsx b/src/components/ArchivedTaskRow.tsx index 322a9c4..6e4e987 100644 --- a/src/components/ArchivedTaskRow.tsx +++ b/src/components/ArchivedTaskRow.tsx @@ -14,7 +14,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { Edit, Trash2, X } from "lucide-react"; -import { formatDuration, formatTimeForInput } from "@/utils/timeUtil"; +import { elapsedMs, formatDuration, formatTimeForInput } from "@/utils/timeUtil"; import { Task, Project } from "@/contexts/TimeTrackingContext"; import { TaskCategory } from "@/config/categories"; @@ -118,7 +118,7 @@ export const ArchivedTaskRow: React.FC = React.memo(({ startTime: newStartTime, endTime: newEndTime, duration: newEndTime - ? newEndTime.getTime() - newStartTime.getTime() + ? elapsedMs(newStartTime, newEndTime) : task.duration, }; diff --git a/src/contexts/TimeTracking.test.tsx b/src/contexts/TimeTracking.test.tsx index a17df84..aae0206 100644 --- a/src/contexts/TimeTracking.test.tsx +++ b/src/contexts/TimeTracking.test.tsx @@ -113,6 +113,47 @@ describe("TimeTrackingContext", () => { ); }); + it("never records a negative duration or an end time before the running task's start when rounding pulls the end back", async () => { + try { + const { result } = renderHook(() => useTimeTracking(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + vi.setSystemTime(new Date(2024, 11, 3, 9, 0, 0)); + await act(async () => { + result.current.startDay(new Date(2024, 11, 3, 9, 0, 0)); + }); + await act(async () => { + result.current.startNewTask("First"); + }); + + // The second task starts at an unrounded 09:20:30... + vi.setSystemTime(new Date(2024, 11, 3, 9, 20, 30)); + await act(async () => { + result.current.startNewTask("Second"); + }); + + // ...and the day ends at 09:21, which rounds back to 09:15 — before it began. + await act(async () => { + result.current.endDay(new Date(2024, 11, 3, 9, 21, 0)); + }); + await act(async () => { + result.current.postDay(); + }); + await waitFor(() => expect(result.current.archivedDays.length).toBe(1)); + + const archived = result.current.archivedDays[0]; + archived.tasks.forEach(task => { + expect(task.duration ?? 0).toBeGreaterThanOrEqual(0); + expect(task.endTime!.getTime()).toBeGreaterThanOrEqual(task.startTime.getTime()); + }); + expect(archived.totalDuration).toBeGreaterThanOrEqual(0); + expect(archived.endTime.getTime()).toBeGreaterThanOrEqual(archived.tasks[1].startTime.getTime()); + } finally { + // Restore the global mock date from src/test-setup.ts. + vi.setSystemTime(new Date("2024-12-03T10:00:00.000Z")); + } + }); + it("should calculate total day duration correctly", async () => { const { result } = renderHook(() => useTimeTracking(), { wrapper }); @@ -1454,6 +1495,33 @@ describe("TimeTrackingContext", () => { }); }); + describe("adjustTaskTime with an end before the start", () => { + it("collapses to a zero-length task instead of a negative duration", async () => { + const { result } = renderHook(() => useTimeTracking(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + result.current.startDay(new Date("2024-12-03T09:00:00Z")); + result.current.startNewTask("Backwards task"); + }); + const taskId = result.current.currentTask!.id; + + await act(async () => { + result.current.adjustTaskTime( + taskId, + new Date("2024-12-03T10:00:00Z"), + new Date("2024-12-03T09:00:00Z") + ); + }); + + await waitFor(() => { + const task = result.current.tasks.find(t => t.id === taskId); + expect(task?.duration).toBe(0); + expect(task?.endTime?.getTime()).toBe(task?.startTime.getTime()); + }); + }); + }); + describe("adjustTaskTime and the day start", () => { const setupDayWithTask = async () => { const hook = renderHook(() => useTimeTracking(), { wrapper }); diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index bfe4c86..5236dc2 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -28,7 +28,7 @@ import { parseCSVImport } from '@/utils/exportUtils'; import { parseTaskChecklist } from '@/utils/checklistUtils'; -import { roundToNearest15Minutes, formatDate } from '@/utils/timeUtil'; +import { roundToNearest15Minutes, formatDate, elapsedMs } from '@/utils/timeUtil'; import { SCHEMA_VERSION } from '@/services/localStorageService'; import { useElectronBackup } from '@/hooks/useElectronBackup'; @@ -758,10 +758,21 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // that displays it (ArchiveEditDialog, TaskEditDialog, etc.) rounds to // the nearest 15 minutes, producing a mismatch between what's shown // and what was actually persisted. - const effectiveEndTime = roundToNearest15Minutes(endDateTime ?? new Date()); + // + // Rounding can land before the running task's (unrounded) start or the + // day start, which would yield a negative duration — never end earlier + // than the latest moment already recorded. + const roundedEndTime = roundToNearest15Minutes(endDateTime ?? new Date()); + const earliestEndMs = Math.max( + dayStartTime?.getTime() ?? 0, + currentTask?.startTime.getTime() ?? 0, + ...tasks.map(t => t.endTime?.getTime() ?? 0) + ); + const effectiveEndTime = + roundedEndTime.getTime() < earliestEndMs ? new Date(earliestEndMs) : roundedEndTime; let finalTasks = tasks; if (currentTask) { - const duration = effectiveEndTime.getTime() - currentTask.startTime.getTime(); + const duration = elapsedMs(currentTask.startTime, effectiveEndTime); const endedTask = { ...currentTask, endTime: effectiveEndTime, @@ -819,7 +830,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // End current task if exists and compute the updated task list synchronously let baseTasks = tasks; if (currentTask) { - const duration = now.getTime() - currentTask.startTime.getTime(); + const duration = elapsedMs(currentTask.startTime, now); const endedTask = { ...currentTask, endTime: now, @@ -1397,7 +1408,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Time adjustment function (rounds to nearest 15 minutes) const adjustTaskTime = useCallback((taskId: string, startTime: Date, endTime?: Date) => { const roundedStartTime = roundToNearest15Minutes(startTime); - const roundedEndTime = endTime ? roundToNearest15Minutes(endTime) : undefined; + // An end time before the start (typed by the user, or produced by rounding) + // collapses to a zero-length task rather than a negative one. + const roundedEndTime = endTime + ? new Date(Math.max(roundToNearest15Minutes(endTime).getTime(), roundedStartTime.getTime())) + : undefined; const updatedTasks = tasks.map(task => task.id === taskId @@ -1406,7 +1421,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ startTime: roundedStartTime, endTime: roundedEndTime, duration: roundedEndTime - ? roundedEndTime.getTime() - roundedStartTime.getTime() + ? elapsedMs(roundedStartTime, roundedEndTime) : task.duration } : task @@ -1576,7 +1591,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const isLinked = plannedTask?.timeEntries.some(e => e.taskId === activeTask.id); if (isLinked) { const endTime = new Date(); - const duration = endTime.getTime() - activeTask.startTime.getTime(); + const duration = elapsedMs(activeTask.startTime, endTime); const endedTask = { ...activeTask, endTime, duration }; const updatedTasks = latestStateRef.current.tasks.map(t => t.id === activeTask.id ? endedTask : t diff --git a/src/utils/calculationUtils.test.ts b/src/utils/calculationUtils.test.ts index 990b29f..44ae999 100644 --- a/src/utils/calculationUtils.test.ts +++ b/src/utils/calculationUtils.test.ts @@ -6,7 +6,9 @@ import { getBillableHoursForDay, getNonBillableHoursForDay, getTotalHoursForPeriod, - getRevenueForPeriod + getRevenueForPeriod, + getCurrentTaskDuration, + getTotalDayDuration } from "@/utils/calculationUtils"; import type { DayRecord, Project } from "@/contexts/TimeTrackingContext"; import type { TaskCategory } from "@/config/categories"; @@ -63,6 +65,42 @@ function makeDay(overrides: Partial = {}): DayRecord { } describe("calculationUtils", () => { + describe("negative durations", () => { + const negativeTask = { + id: "neg", + title: "Legacy negative", + startTime: new Date("2024-12-01T09:20:00Z"), + endTime: new Date("2024-12-01T09:15:00Z"), + duration: -300_000, + project: "Project A", + client: "Client X", + category: "cat1" + }; + + it("getCurrentTaskDuration never goes negative when the task starts after now", () => { + const task = { id: "t", title: "T", startTime: new Date("2024-12-01T09:15:00Z") }; + expect(getCurrentTaskDuration(task, new Date("2024-12-01T09:00:00Z"))).toBe(0); + }); + + it("getTotalDayDuration ignores stored negative durations", () => { + expect(getTotalDayDuration([negativeTask], null, new Date())).toBe(0); + }); + + it("getHoursWorkedForDay and getDayStats ignore stored negative durations", () => { + const day = makeDay({ tasks: [negativeTask] }); + expect(getHoursWorkedForDay(day)).toBe(0); + const stats = getDayStats(day, new Map([[project.name, project]]), new Map([[category.id, category]])); + expect(stats).toEqual({ hoursWorked: 0, billableHours: 0, nonBillableHours: 0, revenue: 0 }); + }); + + it("getTotalHoursForPeriod ignores a stored negative day total", () => { + const days = [makeDay({ totalDuration: -300_000 })]; + expect( + getTotalHoursForPeriod(days, new Date("2024-11-01"), new Date("2024-12-31")) + ).toBe(0); + }); + }); + describe("getHoursWorkedForDay", () => { it("sums task durations and converts to hours", () => { expect(getHoursWorkedForDay(makeDay())).toBe(1); diff --git a/src/utils/calculationUtils.ts b/src/utils/calculationUtils.ts index db453ff..7cba420 100644 --- a/src/utils/calculationUtils.ts +++ b/src/utils/calculationUtils.ts @@ -1,5 +1,6 @@ import { Task, DayRecord, Project } from "@/contexts/TimeTrackingContext"; import { TaskCategory } from "@/config/categories"; +import { elapsedMs } from "@/utils/timeUtil"; /** * Returns true if a task is billable based on its project and category settings. @@ -40,7 +41,7 @@ export function getDayStats( let revenue = 0; day.tasks.forEach(task => { - if (!task.duration) return; + if (!task.duration || task.duration < 0) return; totalMs += task.duration; if (task.project && task.category) { if (isTaskBillable(task, projectMap, categoryMap)) { @@ -70,7 +71,7 @@ export function getDayStats( */ export function getCurrentTaskDuration(currentTask: Task | null, now: Date): number { if (!currentTask) return 0; - return now.getTime() - currentTask.startTime.getTime(); + return elapsedMs(currentTask.startTime, now); } /** @@ -79,7 +80,7 @@ export function getCurrentTaskDuration(currentTask: Task | null, now: Date): num */ export function getTotalDayDuration(tasks: Task[], currentTask: Task | null, now: Date): number { const completedTasksDuration = tasks - .filter(task => task.duration) + .filter(task => task.duration && task.duration > 0) .reduce((total, task) => total + (task.duration || 0), 0); return completedTasksDuration + getCurrentTaskDuration(currentTask, now); @@ -88,7 +89,7 @@ export function getTotalDayDuration(tasks: Task[], currentTask: Task | null, now export function getHoursWorkedForDay(day: DayRecord): number { let totalTaskDuration = 0; day.tasks.forEach(task => { - if (task.duration) totalTaskDuration += task.duration; + if (task.duration && task.duration > 0) totalTaskDuration += task.duration; }); return Math.round((totalTaskDuration / (1000 * 60 * 60)) * 100) / 100; } @@ -167,7 +168,7 @@ export function getTotalHoursForPeriod( return dayDate >= startDate && dayDate <= endDate; }); - const totalMs = filteredDays.reduce((total, day) => total + day.totalDuration, 0); + const totalMs = filteredDays.reduce((total, day) => total + Math.max(0, day.totalDuration), 0); return Math.round((totalMs / (1000 * 60 * 60)) * 100) / 100; } diff --git a/src/utils/reportUtils.ts b/src/utils/reportUtils.ts index 7e4a5ad..af91711 100644 --- a/src/utils/reportUtils.ts +++ b/src/utils/reportUtils.ts @@ -232,7 +232,7 @@ function formatWeekLabel(start: Date, end: Date): string { /** Converts milliseconds to a human-readable "Xh Ym" string. */ export function formatDuration(ms: number): string { - const totalMinutes = Math.round(ms / 60000); + const totalMinutes = Math.round(Math.max(0, ms) / 60000); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours === 0) return `${minutes}m`; diff --git a/src/utils/timeUtil.test.ts b/src/utils/timeUtil.test.ts index 725fec2..3111034 100644 --- a/src/utils/timeUtil.test.ts +++ b/src/utils/timeUtil.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { + elapsedMs, formatDuration, + formatDurationLong, formatDate, formatTime, formatHoursDecimal, @@ -32,6 +34,11 @@ describe("timeUtil", () => { expect(formatDuration(-3600000)).toBe("0:00"); }); + it("should treat sub-hour negative durations as zero", () => { + expect(formatDuration(-1800000)).toBe("0:00"); + expect(formatDurationLong(-1800000)).toBe("0 minutes"); + }); + it("should round partial minutes correctly", () => { // 1 hour 30 minutes 30 seconds expect(formatDuration(5430000)).toBe("1:30"); @@ -41,6 +48,20 @@ describe("timeUtil", () => { }); }); + describe("elapsedMs", () => { + it("returns the span between two moments", () => { + expect( + elapsedMs(new Date("2024-12-03T09:00:00Z"), new Date("2024-12-03T10:30:00Z")) + ).toBe(5_400_000); + }); + + it("floors at zero when the end precedes the start", () => { + expect( + elapsedMs(new Date("2024-12-03T09:20:00Z"), new Date("2024-12-03T09:15:00Z")) + ).toBe(0); + }); + }); + describe("formatHoursDecimal", () => { it("should convert milliseconds to decimal hours", () => { // 1 hour = 3,600,000 ms diff --git a/src/utils/timeUtil.ts b/src/utils/timeUtil.ts index d8fa183..1642314 100644 --- a/src/utils/timeUtil.ts +++ b/src/utils/timeUtil.ts @@ -1,5 +1,13 @@ +/** + * Elapsed milliseconds between two moments, floored at zero. Rounding one end + * of a span (e.g. snapping a day's end time to 15 minutes) can push it before + * the other, and tracked time is never negative. + */ +export const elapsedMs = (start: Date, end: Date): number => + Math.max(0, end.getTime() - start.getTime()); + export const formatDuration = (milliseconds: number): string => { - const totalSeconds = Math.floor(milliseconds / 1000); + const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); @@ -11,7 +19,7 @@ export const formatDuration = (milliseconds: number): string => { }; export const formatDurationLong = (milliseconds: number): string => { - const totalSeconds = Math.floor(milliseconds / 1000); + const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); From 52f549583c3c3cfe0eb9d6b0b589b5bdedadd6e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:03:58 +0000 Subject: [PATCH 2/2] fix: reject end time before start time in archive editors ArchivedTaskRow, the ArchiveEditDialog Add Task form, and the day summary form now flag an end before the start inline and block saving. Adds isEndBeforeStart/END_BEFORE_START_MESSAGE, documents the invariant in AGENTS.md, pitfalls, README, README-EXT, and CHANGELOG. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UqjLGeuTJHemDQZo6PbAja --- AGENTS.md | 25 ++++++--- CHANGELOG.md | 2 + README-EXT.md | 6 +- README.md | 4 +- agents/pitfalls.md | 13 +++++ src/components/ArchiveEditDialog.test.tsx | 68 ++++++++++++++++++++++- src/components/ArchiveEditDialog.tsx | 21 ++++++- src/components/ArchivedTaskRow.test.tsx | 57 ++++++++++++++++++- src/components/ArchivedTaskRow.tsx | 19 ++++++- src/components/ui/scroll-time-picker.tsx | 1 + src/utils/timeUtil.test.ts | 17 ++++++ src/utils/timeUtil.ts | 11 ++++ 12 files changed, 225 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb456c6..3e75d29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). @@ -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 (~400 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) | @@ -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 | @@ -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 `