Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
187 lines
5.2 KiB
TypeScript
187 lines
5.2 KiB
TypeScript
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
|
|
const STORAGE_KEY = "counsel.timer.v1";
|
|
|
|
interface PersistedTimer {
|
|
clientId: string | null;
|
|
caseId: string | null;
|
|
description: string;
|
|
// Total accumulated milliseconds while running, excluding current segment
|
|
accumulatedMs: number;
|
|
// Timestamp (ms) when the current running segment started, or null if paused
|
|
runningSince: number | null;
|
|
startedAt: string | null;
|
|
}
|
|
|
|
interface TimerState {
|
|
timer: PersistedTimer;
|
|
elapsedMs: number;
|
|
isRunning: boolean;
|
|
hasStarted: boolean;
|
|
setClient: (clientId: string | null) => void;
|
|
setCase: (caseId: string | null) => void;
|
|
setDescription: (description: string) => void;
|
|
start: () => void;
|
|
pause: () => void;
|
|
resume: () => void;
|
|
reset: () => void;
|
|
consume: () => { hours: number };
|
|
}
|
|
|
|
const empty: PersistedTimer = {
|
|
clientId: null,
|
|
caseId: null,
|
|
description: "",
|
|
accumulatedMs: 0,
|
|
runningSince: null,
|
|
startedAt: null,
|
|
};
|
|
|
|
const TimerContext = createContext<TimerState | undefined>(undefined);
|
|
|
|
function read(): PersistedTimer {
|
|
if (typeof window === "undefined") return empty;
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return empty;
|
|
return { ...empty, ...(JSON.parse(raw) as PersistedTimer) };
|
|
} catch {
|
|
return empty;
|
|
}
|
|
}
|
|
|
|
function write(t: PersistedTimer) {
|
|
if (typeof window === "undefined") return;
|
|
if (t.accumulatedMs === 0 && !t.runningSince && !t.clientId && !t.caseId && !t.description) {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
} else {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
|
|
}
|
|
}
|
|
|
|
function computeElapsed(t: PersistedTimer): number {
|
|
return t.accumulatedMs + (t.runningSince ? Date.now() - t.runningSince : 0);
|
|
}
|
|
|
|
export function TimerProvider({ children }: { children: ReactNode }) {
|
|
const [timer, setTimer] = useState<PersistedTimer>(empty);
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
const intervalRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const t = read();
|
|
setTimer(t);
|
|
setElapsedMs(computeElapsed(t));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
if (timer.runningSince) {
|
|
intervalRef.current = window.setInterval(() => {
|
|
setElapsedMs(computeElapsed(timer));
|
|
}, 1000);
|
|
}
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [timer]);
|
|
|
|
useEffect(() => {
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key !== STORAGE_KEY) return;
|
|
const t = read();
|
|
setTimer(t);
|
|
setElapsedMs(computeElapsed(t));
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
return () => window.removeEventListener("storage", onStorage);
|
|
}, []);
|
|
|
|
const persist = (t: PersistedTimer) => {
|
|
write(t);
|
|
setTimer(t);
|
|
setElapsedMs(computeElapsed(t));
|
|
};
|
|
|
|
const setClient = (clientId: string | null) =>
|
|
persist({ ...timer, clientId, caseId: clientId === timer.clientId ? timer.caseId : null });
|
|
const setCase = (caseId: string | null) => persist({ ...timer, caseId });
|
|
const setDescription = (description: string) => persist({ ...timer, description });
|
|
|
|
const start = () => {
|
|
if (timer.runningSince) return;
|
|
persist({
|
|
...timer,
|
|
runningSince: Date.now(),
|
|
startedAt: timer.startedAt ?? new Date().toISOString(),
|
|
});
|
|
};
|
|
|
|
const pause = () => {
|
|
if (!timer.runningSince) return;
|
|
const accumulatedMs = timer.accumulatedMs + (Date.now() - timer.runningSince);
|
|
persist({ ...timer, accumulatedMs, runningSince: null });
|
|
};
|
|
|
|
const resume = () => {
|
|
if (timer.runningSince) return;
|
|
persist({ ...timer, runningSince: Date.now() });
|
|
};
|
|
|
|
const reset = () => persist(empty);
|
|
|
|
const consume = () => {
|
|
const ms = computeElapsed(timer);
|
|
// Round UP to the nearest 0.1 hour (6 minute / 360,000 ms increments).
|
|
const increment = 6 * 60 * 1000;
|
|
const rounded = Math.max(increment, Math.ceil(ms / increment) * increment);
|
|
const hours = Math.round((rounded / 3600000) * 10) / 10;
|
|
persist(empty);
|
|
return { hours };
|
|
};
|
|
|
|
return (
|
|
<TimerContext.Provider
|
|
value={{
|
|
timer,
|
|
elapsedMs,
|
|
isRunning: !!timer.runningSince,
|
|
hasStarted: timer.accumulatedMs > 0 || !!timer.runningSince,
|
|
setClient,
|
|
setCase,
|
|
setDescription,
|
|
start,
|
|
pause,
|
|
resume,
|
|
reset,
|
|
consume,
|
|
}}
|
|
>
|
|
{children}
|
|
</TimerContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTimer() {
|
|
const ctx = useContext(TimerContext);
|
|
if (!ctx) throw new Error("useTimer must be used within TimerProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function formatHMS(ms: number): string {
|
|
const total = Math.floor(ms / 1000);
|
|
const h = Math.floor(total / 3600);
|
|
const m = Math.floor((total % 3600) / 60);
|
|
const s = total % 60;
|
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
|
|
/** Round hours UP to the nearest 0.1 (6-minute) increment, with 0.1 minimum. */
|
|
export function roundToTenth(hours: number): number {
|
|
if (!hours || hours <= 0) return 0;
|
|
return Math.max(0.1, Math.round(Math.ceil(hours * 10)) / 10);
|
|
}
|