106 lines
3.0 KiB
TypeScript
106 lines
3.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
const IDLE_THRESHOLD_MS = 30_000; // 30 seconds of inactivity = idle
|
|
const TICK_INTERVAL_MS = 1_000; // Update display every second
|
|
|
|
const ACTIVITY_EVENTS = [
|
|
"mousemove",
|
|
"mousedown",
|
|
"keydown",
|
|
"scroll",
|
|
"touchstart",
|
|
] as const;
|
|
|
|
export function useActiveTimer() {
|
|
const [activeMs, setActiveMs] = useState(0);
|
|
const [totalMs, setTotalMs] = useState(0);
|
|
const [isIdle, setIsIdle] = useState(false);
|
|
|
|
// Refs for values that change frequently but shouldn't trigger re-renders
|
|
const activeMsRef = useRef(0);
|
|
const totalMsRef = useRef(0);
|
|
const lastActivityRef = useRef(Date.now());
|
|
const lastTickRef = useRef(Date.now());
|
|
const idleRef = useRef(false);
|
|
const startedRef = useRef(Date.now());
|
|
|
|
const reset = useCallback(() => {
|
|
const now = Date.now();
|
|
activeMsRef.current = 0;
|
|
totalMsRef.current = 0;
|
|
lastActivityRef.current = now;
|
|
lastTickRef.current = now;
|
|
startedRef.current = now;
|
|
idleRef.current = false;
|
|
setActiveMs(0);
|
|
setTotalMs(0);
|
|
setIsIdle(false);
|
|
}, []);
|
|
|
|
// Mark activity on user interaction
|
|
useEffect(() => {
|
|
function onActivity() {
|
|
lastActivityRef.current = Date.now();
|
|
if (idleRef.current) {
|
|
idleRef.current = false;
|
|
setIsIdle(false);
|
|
// Reset the tick baseline so we don't count the idle gap as active
|
|
lastTickRef.current = Date.now();
|
|
}
|
|
}
|
|
|
|
for (const event of ACTIVITY_EVENTS) {
|
|
window.addEventListener(event, onActivity, { passive: true });
|
|
}
|
|
// Also track when the tab regains focus
|
|
window.addEventListener("focus", onActivity);
|
|
|
|
return () => {
|
|
for (const event of ACTIVITY_EVENTS) {
|
|
window.removeEventListener(event, onActivity);
|
|
}
|
|
window.removeEventListener("focus", onActivity);
|
|
};
|
|
}, []);
|
|
|
|
// Tick loop: accumulate active time and detect idle transitions
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
const now = Date.now();
|
|
const elapsed = now - lastTickRef.current;
|
|
lastTickRef.current = now;
|
|
|
|
// Always accumulate wall-clock time
|
|
totalMsRef.current = now - startedRef.current;
|
|
setTotalMs(totalMsRef.current);
|
|
|
|
const timeSinceActivity = now - lastActivityRef.current;
|
|
|
|
if (timeSinceActivity >= IDLE_THRESHOLD_MS) {
|
|
// User is idle — don't accumulate active time
|
|
if (!idleRef.current) {
|
|
idleRef.current = true;
|
|
setIsIdle(true);
|
|
}
|
|
} else {
|
|
// User is active — accumulate
|
|
activeMsRef.current += elapsed;
|
|
setActiveMs(activeMsRef.current);
|
|
}
|
|
}, TICK_INTERVAL_MS);
|
|
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
return {
|
|
/** Milliseconds the user was actively engaged (idle time excluded) */
|
|
activeMs,
|
|
/** Total wall-clock milliseconds since last reset */
|
|
totalMs,
|
|
/** Whether the user is currently idle */
|
|
isIdle,
|
|
/** Reset both counters (call when loading a new paragraph) */
|
|
reset,
|
|
};
|
|
}
|