From ef3eaf1206c86af08293f5f6a2b752f262990b6c Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 26 May 2026 13:00:35 +0330 Subject: [PATCH] fix(timezone): fix timer clock-skew --- src/api/timeEntries.ts | 5 ++ src/pages/Timesheet.tsx | 124 +++++++++++++++++++++++++--------------- 2 files changed, 82 insertions(+), 47 deletions(-) diff --git a/src/api/timeEntries.ts b/src/api/timeEntries.ts index 91b67f6..89cb7cc 100644 --- a/src/api/timeEntries.ts +++ b/src/api/timeEntries.ts @@ -23,7 +23,10 @@ export interface TimeEntry { project_details: TimeEntryProjectDetails | null; description: string; start_time: string; + start_time_ms: number; end_time: string | null; + end_time_ms: number | null; + server_now_ms: number; duration: string | null; tags: string[]; tag_details: TimeEntryTagDetails[]; @@ -56,6 +59,8 @@ interface GroupedTimeEntryResponse { offset: number; next_offset: number | null; has_more: boolean; + server_now_ms: number; + server_now: string; groups: TimeEntryGroupWeek[]; } diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 6cdf8fd..77af790 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -48,12 +48,17 @@ interface EntryFormState { tags: string[]; } -interface TimerDraftState { +interface TimerDraftState { description: string; projectId: string; isBillable: boolean; tags: string[]; -} +} + +interface TimerClockAnchor { + serverNowMs: number; + performanceNowMs: number; +} const EMPTY_FORM: EntryFormState = { description: "", @@ -196,9 +201,14 @@ const formatDateOnly = (value: string, locale: "en" | "fa") => { }).format(parsed); }; -const formatDuration = (entry: TimeEntry, now = Date.now()) => { - const start = parseApiDateTime(entry.start_time)?.getTime(); - const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now; +const resolveEntryStartMs = (entry: TimeEntry) => entry.start_time_ms ?? parseApiDateTime(entry.start_time)?.getTime(); + +const resolveEntryEndMs = (entry: TimeEntry, now = Date.now()) => + entry.end_time_ms ?? (entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now); + +const formatDuration = (entry: TimeEntry, now = Date.now()) => { + const start = resolveEntryStartMs(entry); + const end = resolveEntryEndMs(entry, now); if (!start || !end) return "00:00:00"; @@ -210,12 +220,15 @@ const formatDuration = (entry: TimeEntry, now = Date.now()) => { return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":"); }; -const getEntryDurationMs = (entry: TimeEntry, now = Date.now()) => { - const start = parseApiDateTime(entry.start_time)?.getTime(); - const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now; - if (!start || !end) return 0; - return Math.max(0, end - start); -}; +const getEntryDurationMs = (entry: TimeEntry, now = Date.now()) => { + const start = resolveEntryStartMs(entry); + const end = resolveEntryEndMs(entry, now); + if (!start || !end) return 0; + return Math.max(0, end - start); +}; + +const getAnchoredServerNowMs = (anchor: TimerClockAnchor | null, performanceNow: number) => + anchor ? anchor.serverNowMs + Math.max(0, performanceNow - anchor.performanceNowMs) : Date.now(); const formatDurationMs = (durationMs: number) => { const totalSeconds = Math.max(0, Math.floor(durationMs / 1000)); @@ -2080,10 +2093,11 @@ export default function Timesheet() { filters.startedAfter || filters.startedBefore, ); - const [hasMoreHistory, setHasMoreHistory] = useState(false); - const [nextOffset, setNextOffset] = useState(0); - const [limit] = useState(20); - const [ticker, setTicker] = useState(Date.now()); + const [hasMoreHistory, setHasMoreHistory] = useState(false); + const [nextOffset, setNextOffset] = useState(0); + const [limit] = useState(20); + const [ticker, setTicker] = useState(() => performance.now()); + const [timerClockAnchor, setTimerClockAnchor] = useState(null); const [modalMode, setModalMode] = useState(null); const [formState, setFormState] = useState(EMPTY_FORM); @@ -2115,7 +2129,8 @@ export default function Timesheet() { }); const [isDiscardingTimer, setIsDiscardingTimer] = useState(false); - const runningEntry = activeRunningEntry; + const runningEntry = activeRunningEntry; + const runningTimerNowMs = getAnchoredServerNowMs(timerClockAnchor, ticker); const deletedProjectLabel = extendedTimesheet.deletedProjectLabel || "Deleted project"; const deletedTagLabel = extendedTimesheet.deletedTagLabel || "Deleted tag"; const runningTimerProjects = useMemo( @@ -2135,12 +2150,12 @@ export default function Timesheet() { [editingEntry, formState.tags, tags], ); - useEffect(() => { - if (!runningEntry) return; - - const intervalId = window.setInterval(() => setTicker(Date.now()), 1000); - return () => window.clearInterval(intervalId); - }, [runningEntry]); + useEffect(() => { + if (!runningEntry) return; + + const intervalId = window.setInterval(() => setTicker(performance.now()), 1000); + return () => window.clearInterval(intervalId); + }, [runningEntry]); useEffect(() => { if (!activeWorkspace?.id) return; @@ -2241,24 +2256,33 @@ export default function Timesheet() { } }, [activeWorkspace?.id, debouncedSearchQuery, filters, limit, t.timesheet?.fetchError]); - const loadRunningEntry = useCallback(async () => { - if (!activeWorkspace?.id) { - setActiveRunningEntry(null); - return; - } - - try { + const loadRunningEntry = useCallback(async () => { + if (!activeWorkspace?.id) { + setActiveRunningEntry(null); + setTimerClockAnchor(null); + return; + } + + try { const data = await getTimeEntries(activeWorkspace.id, { limit: 1, offset: 0, status: "running", - }); - const entry = data.groups?.[0]?.days?.[0]?.entries?.[0] || null; - setActiveRunningEntry(entry); - } catch (error) { - console.error(error); - } - }, [activeWorkspace?.id]); + }); + const entry = data.groups?.[0]?.days?.[0]?.entries?.[0] || null; + setActiveRunningEntry(entry); + setTimerClockAnchor( + entry + ? { + serverNowMs: data.server_now_ms || entry.server_now_ms || Date.now(), + performanceNowMs: performance.now(), + } + : null, + ); + } catch (error) { + console.error(error); + } + }, [activeWorkspace?.id]); useEffect(() => { if (!activeWorkspace?.id) return; @@ -2275,8 +2299,9 @@ export default function Timesheet() { }, [loadRunningEntry]); useEffect(() => { - if (!runningEntry) { - timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); + if (!runningEntry) { + setTimerClockAnchor(null); + timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); pendingTimerSignatureRef.current = timerDraftSignatureRef.current; setTimerDraft(EMPTY_TIMER_DRAFT); return; @@ -2323,10 +2348,14 @@ export default function Timesheet() { const syncedDraft = buildTimerDraftState(updatedEntry); const syncedSignature = serializeTimerDraft(syncedDraft); timerDraftSignatureRef.current = syncedSignature; - pendingTimerSignatureRef.current = syncedSignature; - setTimerDraft(syncedDraft); - setActiveRunningEntry(updatedEntry); - toast.success(saveSuccessText); + pendingTimerSignatureRef.current = syncedSignature; + setTimerDraft(syncedDraft); + setActiveRunningEntry(updatedEntry); + setTimerClockAnchor({ + serverNowMs: updatedEntry.server_now_ms || Date.now(), + performanceNowMs: performance.now(), + }); + toast.success(saveSuccessText); return true; } catch (error) { console.error(error); @@ -2637,9 +2666,10 @@ export default function Timesheet() { setIsDiscardingTimer(true); await deleteTimeEntry(discardTimerModal.entry.id); - timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); - setTimerDraft(EMPTY_TIMER_DRAFT); - setActiveRunningEntry(null); + timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); + setTimerDraft(EMPTY_TIMER_DRAFT); + setActiveRunningEntry(null); + setTimerClockAnchor(null); setDiscardTimerModal({ isOpen: false, entry: null }); toast.success(t.timesheet?.deleteSuccess || "Time entry deleted"); await loadHistory(); @@ -2733,7 +2763,7 @@ export default function Timesheet() {
- {runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"} + {runningEntry ? formatDuration(runningEntry, runningTimerNowMs) : "00:00:00"}
@@ -2813,7 +2843,7 @@ export default function Timesheet() { />
- {runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"} + {runningEntry ? formatDuration(runningEntry, runningTimerNowMs) : "00:00:00"}