fix(timezone): fix timer clock-skew
This commit is contained in:
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user