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:
parent
3f67d34306
commit
f01d72c2bb
7 changed files with 30 additions and 12 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue