fixed timer desync issues

This commit is contained in:
Oliver Bryan
2026-01-20 22:31:41 +00:00
parent 1a98ec96da
commit 1fa85ef22b
3 changed files with 27 additions and 30 deletions

View File

@@ -1,5 +1,6 @@
import type { TimerState } from "@sprint/shared"; import type { TimerState } from "@sprint/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getWorkTimeMs } from "@/components/timer-display";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useEndTimer, useTimerState, useToggleTimer } from "@/lib/query/hooks"; import { useEndTimer, useTimerState, useToggleTimer } from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
@@ -9,39 +10,30 @@ export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data:
const { data: timerState, error } = useTimerState(issueId); const { data: timerState, error } = useTimerState(issueId);
const toggleTimer = useToggleTimer(); const toggleTimer = useToggleTimer();
const endTimer = useEndTimer(); const endTimer = useEndTimer();
const [displayTime, setDisplayTime] = useState(0); const [tick, setTick] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (timerState) {
setDisplayTime(timerState.workTimeMs);
}
}, [timerState]);
useEffect(() => { useEffect(() => {
if (!timerState?.isRunning) return; if (!timerState?.isRunning) return;
const startTime = Date.now();
const baseTime = timerState.workTimeMs;
const interval = setInterval(() => { const interval = setInterval(() => {
setDisplayTime(baseTime + (Date.now() - startTime)); setTick((t) => t + 1);
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [timerState?.isRunning, timerState?.workTimeMs]); }, [timerState?.isRunning]);
useEffect(() => { useEffect(() => {
if (!error) return; if (!error) return;
setErrorMessage(parseError(error as Error)); setErrorMessage(parseError(error as Error));
}, [error]); }, [error]);
void tick;
const displayTime = getWorkTimeMs(timerState?.timestamps);
const handleToggle = async () => { const handleToggle = async () => {
try { try {
const data = await toggleTimer.mutateAsync({ issueId }); await toggleTimer.mutateAsync({ issueId });
if (data) {
setDisplayTime(data.workTimeMs);
}
setErrorMessage(null); setErrorMessage(null);
} catch (err) { } catch (err) {
setErrorMessage(parseError(err as Error)); setErrorMessage(parseError(err as Error));
@@ -52,7 +44,6 @@ export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data:
try { try {
const data = await endTimer.mutateAsync({ issueId }); const data = await endTimer.mutateAsync({ issueId });
if (data) { if (data) {
setDisplayTime(data.workTimeMs);
onEnd?.(data); onEnd?.(data);
} }
setErrorMessage(null); setErrorMessage(null);

View File

@@ -1,3 +1,4 @@
import { calculateWorkTimeMs } from "@sprint/shared";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useInactiveTimers, useTimerState } from "@/lib/query/hooks"; import { useInactiveTimers, useTimerState } from "@/lib/query/hooks";
@@ -7,6 +8,12 @@ import { formatTime } from "@/lib/utils";
const FALLBACK_TIME = "--:--:--"; const FALLBACK_TIME = "--:--:--";
const REFRESH_INTERVAL_MS = 10000; const REFRESH_INTERVAL_MS = 10000;
export function getWorkTimeMs(timestamps: string[] | undefined): number {
if (!timestamps?.length) return 0;
const dates = timestamps.map((t) => new Date(t));
return calculateWorkTimeMs(dates);
}
export function TimerDisplay({ issueId }: { issueId: number }) { export function TimerDisplay({ issueId }: { issueId: number }) {
const { data: timerState, error: timerError } = useTimerState(issueId, { const { data: timerState, error: timerError } = useTimerState(issueId, {
refetchInterval: REFRESH_INTERVAL_MS, refetchInterval: REFRESH_INTERVAL_MS,
@@ -15,7 +22,7 @@ export function TimerDisplay({ issueId }: { issueId: number }) {
refetchInterval: REFRESH_INTERVAL_MS, refetchInterval: REFRESH_INTERVAL_MS,
}); });
const [workTimeMs, setWorkTimeMs] = useState(0); const [tick, setTick] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const combinedError = timerError ?? inactiveError; const combinedError = timerError ?? inactiveError;
@@ -30,27 +37,26 @@ export function TimerDisplay({ issueId }: { issueId: number }) {
return; return;
} }
setError(null); setError(null);
setWorkTimeMs(timerState?.workTimeMs ?? 0); }, [combinedError]);
}, [combinedError, timerState]);
useEffect(() => { useEffect(() => {
if (!timerState?.isRunning) return; if (!timerState?.isRunning) return;
const startTime = Date.now();
const baseWorkTime = timerState.workTimeMs;
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
setWorkTimeMs(baseWorkTime + (Date.now() - startTime)); setTick((t) => t + 1);
}, 1000); }, 1000);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [timerState?.isRunning, timerState?.workTimeMs]); }, [timerState?.isRunning]);
const inactiveWorkTimeMs = useMemo( const inactiveWorkTimeMs = useMemo(
() => inactiveTimers.reduce((total, session) => total + (session?.workTimeMs ?? 0), 0), () => inactiveTimers.reduce((total, session) => total + getWorkTimeMs(session?.timestamps), 0),
[inactiveTimers], [inactiveTimers],
); );
const totalWorkTimeMs = inactiveWorkTimeMs + workTimeMs; void tick;
const currentWorkTimeMs = getWorkTimeMs(timerState?.timestamps);
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs); const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs);
return ( return (

View File

@@ -29,8 +29,8 @@ export function useToggleTimer() {
return useMutation<TimerState, Error, TimerToggleRequest>({ return useMutation<TimerState, Error, TimerToggleRequest>({
mutationKey: ["timers", "toggle"], mutationKey: ["timers", "toggle"],
mutationFn: timer.toggle, mutationFn: timer.toggle,
onSuccess: (_data, variables) => { onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.active(variables.issueId) }); queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
}, },
}); });
@@ -42,8 +42,8 @@ export function useEndTimer() {
return useMutation<TimerState, Error, TimerEndRequest>({ return useMutation<TimerState, Error, TimerEndRequest>({
mutationKey: ["timers", "end"], mutationKey: ["timers", "end"],
mutationFn: timer.end, mutationFn: timer.end,
onSuccess: (_data, variables) => { onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.active(variables.issueId) }); queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
}, },
}); });