Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
2a260e48ed
commit
24c5326293
@@ -0,0 +1,189 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
const STORAGE_KEY = "counsel.timer.v1";
|
||||
|
||||
export interface TimerTarget {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
caseId?: string | null;
|
||||
caseNumber?: string | null;
|
||||
caseTitle?: string | null;
|
||||
defaultHourlyRate?: number | null;
|
||||
}
|
||||
|
||||
interface PersistedTimer extends TimerTarget {
|
||||
// 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;
|
||||
description: string;
|
||||
startedAt: string; // ISO when first started
|
||||
}
|
||||
|
||||
interface TimerState {
|
||||
active: PersistedTimer | null;
|
||||
elapsedMs: number;
|
||||
isRunning: boolean;
|
||||
start: (target: TimerTarget, description?: string) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
reset: () => void;
|
||||
setDescription: (d: string) => void;
|
||||
// Returns elapsed hours rounded to 2 decimals; clears timer
|
||||
stopAndConsume: () => { hours: number; description: string; target: TimerTarget; startedAt: string } | null;
|
||||
}
|
||||
|
||||
const TimerContext = createContext<TimerState | undefined>(undefined);
|
||||
|
||||
function read(): PersistedTimer | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as PersistedTimer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(t: PersistedTimer | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!t) localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
|
||||
}
|
||||
|
||||
function computeElapsed(t: PersistedTimer | null): number {
|
||||
if (!t) return 0;
|
||||
return t.accumulatedMs + (t.runningSince ? Date.now() - t.runningSince : 0);
|
||||
}
|
||||
|
||||
export function TimerProvider({ children }: { children: ReactNode }) {
|
||||
const [active, setActive] = useState<PersistedTimer | null>(null);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
// Hydrate from localStorage on mount
|
||||
useEffect(() => {
|
||||
const t = read();
|
||||
if (t) {
|
||||
setActive(t);
|
||||
setElapsedMs(computeElapsed(t));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tick every second when running
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (active?.runningSince) {
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setElapsedMs(computeElapsed(active));
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
// Cross-tab sync
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
const t = read();
|
||||
setActive(t);
|
||||
setElapsedMs(computeElapsed(t));
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
const persist = (t: PersistedTimer | null) => {
|
||||
write(t);
|
||||
setActive(t);
|
||||
setElapsedMs(computeElapsed(t));
|
||||
};
|
||||
|
||||
const start: TimerState["start"] = (target, description = "") => {
|
||||
const t: PersistedTimer = {
|
||||
...target,
|
||||
accumulatedMs: 0,
|
||||
runningSince: Date.now(),
|
||||
description,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
persist(t);
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (!active?.runningSince) return;
|
||||
const accumulatedMs = active.accumulatedMs + (Date.now() - active.runningSince);
|
||||
persist({ ...active, accumulatedMs, runningSince: null });
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (!active || active.runningSince) return;
|
||||
persist({ ...active, runningSince: Date.now() });
|
||||
};
|
||||
|
||||
const reset = () => persist(null);
|
||||
|
||||
const setDescription = (description: string) => {
|
||||
if (!active) return;
|
||||
persist({ ...active, description });
|
||||
};
|
||||
|
||||
const stopAndConsume: TimerState["stopAndConsume"] = () => {
|
||||
if (!active) return null;
|
||||
const ms = computeElapsed(active);
|
||||
const hours = Math.max(0.01, Math.round((ms / 3600000) * 100) / 100);
|
||||
const result = {
|
||||
hours,
|
||||
description: active.description,
|
||||
target: {
|
||||
clientId: active.clientId,
|
||||
clientName: active.clientName,
|
||||
caseId: active.caseId,
|
||||
caseNumber: active.caseNumber,
|
||||
caseTitle: active.caseTitle,
|
||||
defaultHourlyRate: active.defaultHourlyRate,
|
||||
},
|
||||
startedAt: active.startedAt,
|
||||
};
|
||||
persist(null);
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<TimerContext.Provider
|
||||
value={{
|
||||
active,
|
||||
elapsedMs,
|
||||
isRunning: !!active?.runningSince,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
reset,
|
||||
setDescription,
|
||||
stopAndConsume,
|
||||
}}
|
||||
>
|
||||
{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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user