fix(timezone): fix timer clock-skew
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-26 13:00:35 +03:30
parent 177b20e8ea
commit ef3eaf1206
2 changed files with 82 additions and 47 deletions

View File

@@ -23,7 +23,10 @@ export interface TimeEntry {
project_details: TimeEntryProjectDetails | null; project_details: TimeEntryProjectDetails | null;
description: string; description: string;
start_time: string; start_time: string;
start_time_ms: number;
end_time: string | null; end_time: string | null;
end_time_ms: number | null;
server_now_ms: number;
duration: string | null; duration: string | null;
tags: string[]; tags: string[];
tag_details: TimeEntryTagDetails[]; tag_details: TimeEntryTagDetails[];
@@ -56,6 +59,8 @@ interface GroupedTimeEntryResponse {
offset: number; offset: number;
next_offset: number | null; next_offset: number | null;
has_more: boolean; has_more: boolean;
server_now_ms: number;
server_now: string;
groups: TimeEntryGroupWeek[]; groups: TimeEntryGroupWeek[];
} }

View File

@@ -48,12 +48,17 @@ interface EntryFormState {
tags: string[]; tags: string[];
} }
interface TimerDraftState { interface TimerDraftState {
description: string; description: string;
projectId: string; projectId: string;
isBillable: boolean; isBillable: boolean;
tags: string[]; tags: string[];
} }
interface TimerClockAnchor {
serverNowMs: number;
performanceNowMs: number;
}
const EMPTY_FORM: EntryFormState = { const EMPTY_FORM: EntryFormState = {
description: "", description: "",
@@ -196,9 +201,14 @@ const formatDateOnly = (value: string, locale: "en" | "fa") => {
}).format(parsed); }).format(parsed);
}; };
const formatDuration = (entry: TimeEntry, now = Date.now()) => { const resolveEntryStartMs = (entry: TimeEntry) => entry.start_time_ms ?? parseApiDateTime(entry.start_time)?.getTime();
const start = parseApiDateTime(entry.start_time)?.getTime();
const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now; 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"; 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(":"); return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
}; };
const getEntryDurationMs = (entry: TimeEntry, now = Date.now()) => { const getEntryDurationMs = (entry: TimeEntry, now = Date.now()) => {
const start = parseApiDateTime(entry.start_time)?.getTime(); const start = resolveEntryStartMs(entry);
const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now; const end = resolveEntryEndMs(entry, now);
if (!start || !end) return 0; if (!start || !end) return 0;
return Math.max(0, end - start); 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 formatDurationMs = (durationMs: number) => {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000)); const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
@@ -2080,10 +2093,11 @@ export default function Timesheet() {
filters.startedAfter || filters.startedAfter ||
filters.startedBefore, filters.startedBefore,
); );
const [hasMoreHistory, setHasMoreHistory] = useState(false); const [hasMoreHistory, setHasMoreHistory] = useState(false);
const [nextOffset, setNextOffset] = useState<number | null>(0); const [nextOffset, setNextOffset] = useState<number | null>(0);
const [limit] = useState(20); const [limit] = useState(20);
const [ticker, setTicker] = useState(Date.now()); const [ticker, setTicker] = useState(() => performance.now());
const [timerClockAnchor, setTimerClockAnchor] = useState<TimerClockAnchor | null>(null);
const [modalMode, setModalMode] = useState<EntryModalMode>(null); const [modalMode, setModalMode] = useState<EntryModalMode>(null);
const [formState, setFormState] = useState<EntryFormState>(EMPTY_FORM); const [formState, setFormState] = useState<EntryFormState>(EMPTY_FORM);
@@ -2115,7 +2129,8 @@ export default function Timesheet() {
}); });
const [isDiscardingTimer, setIsDiscardingTimer] = useState(false); const [isDiscardingTimer, setIsDiscardingTimer] = useState(false);
const runningEntry = activeRunningEntry; const runningEntry = activeRunningEntry;
const runningTimerNowMs = getAnchoredServerNowMs(timerClockAnchor, ticker);
const deletedProjectLabel = extendedTimesheet.deletedProjectLabel || "Deleted project"; const deletedProjectLabel = extendedTimesheet.deletedProjectLabel || "Deleted project";
const deletedTagLabel = extendedTimesheet.deletedTagLabel || "Deleted tag"; const deletedTagLabel = extendedTimesheet.deletedTagLabel || "Deleted tag";
const runningTimerProjects = useMemo( const runningTimerProjects = useMemo(
@@ -2135,12 +2150,12 @@ export default function Timesheet() {
[editingEntry, formState.tags, tags], [editingEntry, formState.tags, tags],
); );
useEffect(() => { useEffect(() => {
if (!runningEntry) return; if (!runningEntry) return;
const intervalId = window.setInterval(() => setTicker(Date.now()), 1000); const intervalId = window.setInterval(() => setTicker(performance.now()), 1000);
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, [runningEntry]); }, [runningEntry]);
useEffect(() => { useEffect(() => {
if (!activeWorkspace?.id) return; if (!activeWorkspace?.id) return;
@@ -2241,24 +2256,33 @@ export default function Timesheet() {
} }
}, [activeWorkspace?.id, debouncedSearchQuery, filters, limit, t.timesheet?.fetchError]); }, [activeWorkspace?.id, debouncedSearchQuery, filters, limit, t.timesheet?.fetchError]);
const loadRunningEntry = useCallback(async () => { const loadRunningEntry = useCallback(async () => {
if (!activeWorkspace?.id) { if (!activeWorkspace?.id) {
setActiveRunningEntry(null); setActiveRunningEntry(null);
return; setTimerClockAnchor(null);
} return;
}
try {
try {
const data = await getTimeEntries(activeWorkspace.id, { const data = await getTimeEntries(activeWorkspace.id, {
limit: 1, limit: 1,
offset: 0, offset: 0,
status: "running", status: "running",
}); });
const entry = data.groups?.[0]?.days?.[0]?.entries?.[0] || null; const entry = data.groups?.[0]?.days?.[0]?.entries?.[0] || null;
setActiveRunningEntry(entry); setActiveRunningEntry(entry);
} catch (error) { setTimerClockAnchor(
console.error(error); entry
} ? {
}, [activeWorkspace?.id]); serverNowMs: data.server_now_ms || entry.server_now_ms || Date.now(),
performanceNowMs: performance.now(),
}
: null,
);
} catch (error) {
console.error(error);
}
}, [activeWorkspace?.id]);
useEffect(() => { useEffect(() => {
if (!activeWorkspace?.id) return; if (!activeWorkspace?.id) return;
@@ -2275,8 +2299,9 @@ export default function Timesheet() {
}, [loadRunningEntry]); }, [loadRunningEntry]);
useEffect(() => { useEffect(() => {
if (!runningEntry) { if (!runningEntry) {
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); setTimerClockAnchor(null);
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
pendingTimerSignatureRef.current = timerDraftSignatureRef.current; pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
setTimerDraft(EMPTY_TIMER_DRAFT); setTimerDraft(EMPTY_TIMER_DRAFT);
return; return;
@@ -2323,10 +2348,14 @@ export default function Timesheet() {
const syncedDraft = buildTimerDraftState(updatedEntry); const syncedDraft = buildTimerDraftState(updatedEntry);
const syncedSignature = serializeTimerDraft(syncedDraft); const syncedSignature = serializeTimerDraft(syncedDraft);
timerDraftSignatureRef.current = syncedSignature; timerDraftSignatureRef.current = syncedSignature;
pendingTimerSignatureRef.current = syncedSignature; pendingTimerSignatureRef.current = syncedSignature;
setTimerDraft(syncedDraft); setTimerDraft(syncedDraft);
setActiveRunningEntry(updatedEntry); setActiveRunningEntry(updatedEntry);
toast.success(saveSuccessText); setTimerClockAnchor({
serverNowMs: updatedEntry.server_now_ms || Date.now(),
performanceNowMs: performance.now(),
});
toast.success(saveSuccessText);
return true; return true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -2637,9 +2666,10 @@ export default function Timesheet() {
setIsDiscardingTimer(true); setIsDiscardingTimer(true);
await deleteTimeEntry(discardTimerModal.entry.id); await deleteTimeEntry(discardTimerModal.entry.id);
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
setTimerDraft(EMPTY_TIMER_DRAFT); setTimerDraft(EMPTY_TIMER_DRAFT);
setActiveRunningEntry(null); setActiveRunningEntry(null);
setTimerClockAnchor(null);
setDiscardTimerModal({ isOpen: false, entry: null }); setDiscardTimerModal({ isOpen: false, entry: null });
toast.success(t.timesheet?.deleteSuccess || "Time entry deleted"); toast.success(t.timesheet?.deleteSuccess || "Time entry deleted");
await loadHistory(); await loadHistory();
@@ -2733,7 +2763,7 @@ export default function Timesheet() {
</div> </div>
<div className="flex h-12 shrink-0 items-center px-5 text-lg font-semibold text-slate-900 dark:text-white"> <div className="flex h-12 shrink-0 items-center px-5 text-lg font-semibold text-slate-900 dark:text-white">
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"} {runningEntry ? formatDuration(runningEntry, runningTimerNowMs) : "00:00:00"}
</div> </div>
<div className="ms-2 flex shrink-0 items-center gap-2"> <div className="ms-2 flex shrink-0 items-center gap-2">
@@ -2813,7 +2843,7 @@ export default function Timesheet() {
/> />
<div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"> <div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white">
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"} {runningEntry ? formatDuration(runningEntry, runningTimerNowMs) : "00:00:00"}
</div> </div>
</div> </div>