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 <noreply@anthropic.com>
This commit is contained in:
Gabriel Santana de Menezes 2026-05-18 10:59:16 -04:00
parent 3f67d34306
commit f01d72c2bb
7 changed files with 30 additions and 12 deletions

View file

@ -1,6 +1,7 @@
package net.beetechgroup.beetask.entities.task; package net.beetechgroup.beetask.entities.task;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.Duration;
import java.util.Objects; import java.util.Objects;
public class TaskHistoryItem { public class TaskHistoryItem {
@ -35,4 +36,10 @@ public class TaskHistoryItem {
public boolean isRunning() { public boolean isRunning() {
return Objects.isNull(endAt); 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();
}
} }

View file

@ -24,5 +24,6 @@ public record CreateTaskResponse(
record TaskHistoryItemResponse( record TaskHistoryItemResponse(
Long id, Long id,
LocalDateTime startAt, LocalDateTime startAt,
LocalDateTime endAt) { LocalDateTime endAt,
Long elapsedSeconds) {
} }

View file

@ -69,7 +69,7 @@ public class TaskControllerMapper {
: null, : null,
output.finishedAt(), output.finishedAt(),
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse( output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList() taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList()
); );
} }

View file

@ -43,6 +43,7 @@ public class CreateTaskMapper {
return new TaskHistoryItemOutput( return new TaskHistoryItemOutput(
taskHistoryItem.getId(), taskHistoryItem.getId(),
taskHistoryItem.getStartAt(), taskHistoryItem.getStartAt(),
taskHistoryItem.getEndAt()); taskHistoryItem.getEndAt(),
taskHistoryItem.getElapsedSeconds());
} }
} }

View file

@ -5,5 +5,6 @@ import java.time.LocalDateTime;
public record TaskHistoryItemOutput( public record TaskHistoryItemOutput(
Long id, Long id,
LocalDateTime startAt, LocalDateTime startAt,
LocalDateTime endAt) { LocalDateTime endAt,
Long elapsedSeconds) {
} }

View file

@ -1,4 +1,4 @@
import { differenceInSeconds, parseISO } from 'date-fns' import { differenceInSeconds } from 'date-fns'
import { Clock, Play, Square } from 'lucide-react' import { Clock, Play, Square } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
@ -26,6 +26,7 @@ function formatDuration(seconds: number) {
export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) { export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) {
const [isActionLoading, setIsActionLoading] = useState(false) const [isActionLoading, setIsActionLoading] = useState(false)
const [responseTimestamp, setResponseTimestamp] = useState<Date | null>(null)
const history = task.history ?? [] const history = task.history ?? []
const runningItem = useMemo( const runningItem = useMemo(
@ -36,7 +37,7 @@ export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps)
const accumulatedSeconds = useMemo(() => { const accumulatedSeconds = useMemo(() => {
return history.reduce((acc, item) => { return history.reduce((acc, item) => {
if (item.endAt) { if (item.endAt) {
return acc + differenceInSeconds(parseISO(item.endAt), parseISO(item.startAt)) return acc + (item.elapsedSeconds ?? 0)
} }
return acc return acc
}, 0) }, 0)
@ -44,12 +45,17 @@ export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps)
const [displaySeconds, setDisplaySeconds] = useState(0) const [displaySeconds, setDisplaySeconds] = useState(0)
useEffect(() => {
setResponseTimestamp(new Date())
}, [runningItem])
useEffect(() => { useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null let interval: ReturnType<typeof setInterval> | null = null
if (runningItem) { if (runningItem && responseTimestamp) {
interval = setInterval(() => { 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) setDisplaySeconds(accumulatedSeconds + runningSeconds)
}, 1000) }, 1000)
} else { } else {
@ -59,17 +65,18 @@ export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps)
return () => { return () => {
if (interval) clearInterval(interval) if (interval) clearInterval(interval)
} }
}, [runningItem, accumulatedSeconds]) }, [runningItem, accumulatedSeconds, responseTimestamp])
// Initial calculation // Initial calculation
useEffect(() => { useEffect(() => {
if (runningItem) { if (runningItem && responseTimestamp) {
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) setDisplaySeconds(accumulatedSeconds + runningSeconds)
} else { } else {
setDisplaySeconds(accumulatedSeconds) setDisplaySeconds(accumulatedSeconds)
} }
}, [runningItem, accumulatedSeconds]) }, [runningItem, accumulatedSeconds, responseTimestamp])
async function handleStart(e: React.MouseEvent) { async function handleStart(e: React.MouseEvent) {
e.stopPropagation() e.stopPropagation()

View file

@ -28,6 +28,7 @@ export type TaskHistoryItem = {
id?: number id?: number
startAt: string startAt: string
endAt: string | null endAt: string | null
elapsedSeconds?: number
} }
export type TaskProject = { export type TaskProject = {