diff --git a/CHANGELOG.md b/CHANGELOG.md index 3480e40..efdf0c2 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 +- Manually editing a current-day task's end time (via `TaskEditDialog`) no longer leaves a linked planned task's "Time Spent" stale. `adjustTaskTime` previously updated only the day task's own `duration`; it now also calls `accumulatePlannedTaskTime` so the planned task's `timeSpent` and matching `timeEntries` duration resync to the edited time + — `src/contexts/TimeTrackingContext.tsx`, `src/contexts/TimeTracking.test.tsx` - 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` diff --git a/src/contexts/TimeTracking.test.tsx b/src/contexts/TimeTracking.test.tsx index aae0206..a071c67 100644 --- a/src/contexts/TimeTracking.test.tsx +++ b/src/contexts/TimeTracking.test.tsx @@ -1586,6 +1586,48 @@ describe("TimeTrackingContext", () => { }); }); + describe("adjustTaskTime and a linked planned task", () => { + it("resyncs timeSpent after editing the ended task's time", 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")); + }); + await act(async () => { + result.current.addPlannedTask({ title: "Board task" }); + }); + let plannedId: string; + await waitFor(() => { plannedId = result.current.plannedTasks[0].id; }); + + await act(async () => { + result.current.pullPlannedTaskToDay(plannedId); + }); + let taskId: string; + await waitFor(() => { taskId = result.current.currentTask!.id; }); + + await act(async () => { + result.current.endDay(new Date("2024-12-03T11:30:00Z")); + }); + await waitFor(() => { + expect(result.current.plannedTasks[0].timeSpent).toBe(9_000_000); // 2h30m + }); + + await act(async () => { + result.current.adjustTaskTime( + taskId, + new Date("2024-12-03T09:00:00Z"), + new Date("2024-12-03T11:45:00Z") + ); + }); + + await waitFor(() => { + expect(result.current.plannedTasks[0].timeSpent).toBe(9_900_000); // 2h45m + expect(result.current.plannedTasks[0].timeEntries[0].duration).toBe(9_900_000); + }); + }); + }); + describe("addBackdatedDay", () => { it("adds a backdated day to the archive", async () => { const { result } = renderHook(() => useTimeTracking(), { wrapper }); diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 5236dc2..1fc9796 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -1451,7 +1451,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ .then(() => setLastSyncTime(new Date())) .catch(error => console.error("❌ Error saving after adjusting task time:", error)); } - }, [tasks, currentTask, dayStartTime, isDayStarted, dataService]); + if (roundedEndTime) { + const updatedTask = updatedTasks.find(t => t.id === taskId); + if (updatedTask) accumulatePlannedTaskTime(taskId, updatedTask.duration); + } + }, [tasks, currentTask, dayStartTime, isDayStarted, dataService, accumulatePlannedTaskTime]); const getTotalHoursForPeriod = useCallback((startDate: Date, endDate: Date): number => calcTotalHoursForPeriod(archivedDays, startDate, endDate), [archivedDays]);