From f01d72c2bb378343bd18a391fc83a4dbaf658c02 Mon Sep 17 00:00:00 2001 From: Gabriel Santana de Menezes Date: Mon, 18 May 2026 10:59:16 -0400 Subject: [PATCH] Fix task timer desync between server and browser clocks Calculate elapsed time server-side and return it to client. Timer now adds elapsedSeconds from server to local increments since response received, instead of calculating from startAt with potentially mismatched browser clock. Backend changes: - TaskHistoryItem: add getElapsedSeconds() to calculate time since startAt - TaskHistoryItemOutput: add elapsedSeconds field - CreateTaskResponse.TaskHistoryItemResponse: add elapsedSeconds field - CreateTaskMapper: map elapsedSeconds in toTaskHistoryItemOutput() Frontend changes: - TaskTimer: track responseTimestamp when running item changes - Calculate: accumulatedSeconds + elapsedSeconds + (now - responseTimestamp) - Remove parseISO import (no longer needed) - TaskHistoryItem type: add optional elapsedSeconds field This fixes the issue where timer doesn't update until task is stopped if server and browser clocks are out of sync. Co-Authored-By: Claude Haiku 4.5 --- .../entities/task/TaskHistoryItem.java | 7 ++++++ .../controllers/task/CreateTaskResponse.java | 3 ++- .../task/TaskControllerMapper.java | 2 +- .../usecase/task/create/CreateTaskMapper.java | 3 ++- .../task/create/TaskHistoryItemOutput.java | 3 ++- frontend/src/components/TaskTimer.tsx | 23 ++++++++++++------- frontend/src/types/task.ts | 1 + 7 files changed, 30 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java b/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java index 623535e..c95d9ab 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java @@ -1,6 +1,7 @@ package net.beetechgroup.beetask.entities.task; import java.time.LocalDateTime; +import java.time.Duration; import java.util.Objects; public class TaskHistoryItem { @@ -35,4 +36,10 @@ public class TaskHistoryItem { public boolean isRunning() { return Objects.isNull(endAt); } + + public long getElapsedSeconds() { + if (Objects.isNull(startAt)) return 0; + LocalDateTime endTime = Objects.nonNull(endAt) ? endAt : LocalDateTime.now(); + return Duration.between(startAt, endTime).toSeconds(); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java index 64573a5..5e8f74d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java @@ -24,5 +24,6 @@ public record CreateTaskResponse( record TaskHistoryItemResponse( Long id, LocalDateTime startAt, - LocalDateTime endAt) { + LocalDateTime endAt, + Long elapsedSeconds) { } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java index 4a584b6..fb61c43 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java @@ -69,7 +69,7 @@ public class TaskControllerMapper { : null, output.finishedAt(), output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse( - taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList() + taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList() ); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java index a3f6d5d..f229aa3 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java @@ -43,6 +43,7 @@ public class CreateTaskMapper { return new TaskHistoryItemOutput( taskHistoryItem.getId(), taskHistoryItem.getStartAt(), - taskHistoryItem.getEndAt()); + taskHistoryItem.getEndAt(), + taskHistoryItem.getElapsedSeconds()); } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/TaskHistoryItemOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/TaskHistoryItemOutput.java index ddbb822..37cbf5a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/TaskHistoryItemOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/TaskHistoryItemOutput.java @@ -5,5 +5,6 @@ import java.time.LocalDateTime; public record TaskHistoryItemOutput( Long id, LocalDateTime startAt, - LocalDateTime endAt) { + LocalDateTime endAt, + Long elapsedSeconds) { } diff --git a/frontend/src/components/TaskTimer.tsx b/frontend/src/components/TaskTimer.tsx index 72e0c65..0067c05 100644 --- a/frontend/src/components/TaskTimer.tsx +++ b/frontend/src/components/TaskTimer.tsx @@ -1,4 +1,4 @@ -import { differenceInSeconds, parseISO } from 'date-fns' +import { differenceInSeconds } from 'date-fns' import { Clock, Play, Square } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' @@ -26,6 +26,7 @@ function formatDuration(seconds: number) { export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) { const [isActionLoading, setIsActionLoading] = useState(false) + const [responseTimestamp, setResponseTimestamp] = useState(null) const history = task.history ?? [] const runningItem = useMemo( @@ -36,7 +37,7 @@ export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) const accumulatedSeconds = useMemo(() => { return history.reduce((acc, item) => { if (item.endAt) { - return acc + differenceInSeconds(parseISO(item.endAt), parseISO(item.startAt)) + return acc + (item.elapsedSeconds ?? 0) } return acc }, 0) @@ -44,12 +45,17 @@ export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) const [displaySeconds, setDisplaySeconds] = useState(0) + useEffect(() => { + setResponseTimestamp(new Date()) + }, [runningItem]) + useEffect(() => { let interval: ReturnType | null = null - if (runningItem) { + if (runningItem && responseTimestamp) { interval = setInterval(() => { - const runningSeconds = Math.max(0, differenceInSeconds(new Date(), parseISO(runningItem.startAt))) + const elapsedSinceResponse = Math.max(0, differenceInSeconds(new Date(), responseTimestamp)) + const runningSeconds = (runningItem.elapsedSeconds ?? 0) + elapsedSinceResponse setDisplaySeconds(accumulatedSeconds + runningSeconds) }, 1000) } else { @@ -59,17 +65,18 @@ export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) return () => { if (interval) clearInterval(interval) } - }, [runningItem, accumulatedSeconds]) + }, [runningItem, accumulatedSeconds, responseTimestamp]) // Initial calculation useEffect(() => { - if (runningItem) { - const runningSeconds = Math.max(0, differenceInSeconds(new Date(), parseISO(runningItem.startAt))) + if (runningItem && responseTimestamp) { + const elapsedSinceResponse = Math.max(0, differenceInSeconds(new Date(), responseTimestamp)) + const runningSeconds = (runningItem.elapsedSeconds ?? 0) + elapsedSinceResponse setDisplaySeconds(accumulatedSeconds + runningSeconds) } else { setDisplaySeconds(accumulatedSeconds) } - }, [runningItem, accumulatedSeconds]) + }, [runningItem, accumulatedSeconds, responseTimestamp]) async function handleStart(e: React.MouseEvent) { e.stopPropagation() diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 57411cd..8647324 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -28,6 +28,7 @@ export type TaskHistoryItem = { id?: number startAt: string endAt: string | null + elapsedSeconds?: number } export type TaskProject = {