diff --git a/src/components/protected-layout.tsx b/src/components/protected-layout.tsx
index 96b2bc6..5ac716c 100644
--- a/src/components/protected-layout.tsx
+++ b/src/components/protected-layout.tsx
@@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/app-shell";
+import { TimerWidget } from "@/components/timer/timer-widget";
import { Loader2 } from "lucide-react";
export function ProtectedLayout({ children, adminOnly }: { children: ReactNode; adminOnly?: boolean }) {
@@ -25,5 +26,10 @@ export function ProtectedLayout({ children, adminOnly }: { children: ReactNode;
);
}
- return {children};
+ return (
+
+ {children}
+
+
+ );
}
diff --git a/src/components/timer/start-timer-button.tsx b/src/components/timer/start-timer-button.tsx
new file mode 100644
index 0000000..da1e588
--- /dev/null
+++ b/src/components/timer/start-timer-button.tsx
@@ -0,0 +1,57 @@
+import { useState } from "react";
+import { Button, type ButtonProps } from "@/components/ui/button";
+import { useTimer, type TimerTarget } from "@/lib/timer";
+import { Play, Timer as TimerIcon } from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { formatHMS } from "@/lib/timer";
+
+interface Props extends Omit {
+ target: TimerTarget;
+ label?: string;
+}
+
+export function StartTimerButton({ target, label = "Start timer", ...rest }: Props) {
+ const { active, elapsedMs, start } = useTimer();
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ const handleClick = () => {
+ if (active) setConfirmOpen(true);
+ else start(target);
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Replace running timer?
+
+
+ A timer is already running ({formatHMS(elapsedMs)}) on{" "}
+ {active?.caseNumber ?? active?.clientName}. Starting a new
+ one will discard it without saving.
+
+
+
+ Keep current
+ start(target)}>Discard & start new
+
+
+
+ >
+ );
+}
diff --git a/src/components/timer/timer-widget.tsx b/src/components/timer/timer-widget.tsx
new file mode 100644
index 0000000..9ec855c
--- /dev/null
+++ b/src/components/timer/timer-widget.tsx
@@ -0,0 +1,173 @@
+import { useState } from "react";
+import { useTimer, formatHMS } from "@/lib/timer";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
+import { Pause, Play, Square, Timer as TimerIcon, Loader2 } from "lucide-react";
+import { useAuth } from "@/lib/auth";
+import { supabase } from "@/integrations/supabase/client";
+import { toast } from "sonner";
+import { Link } from "@tanstack/react-router";
+
+export function TimerWidget() {
+ const { active, elapsedMs, isRunning, pause, resume, setDescription, stopAndConsume, reset } = useTimer();
+ const { user, session } = useAuth();
+ const [stopOpen, setStopOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [form, setForm] = useState({ hours: "", rate: "", description: "", billable: true, work_date: "" });
+
+ if (!session || !active) return null;
+
+ const openStop = () => {
+ if (isRunning) pause();
+ const snapshot = {
+ hours: (elapsedMs / 3600000).toFixed(2),
+ rate: active.defaultHourlyRate?.toString() ?? "",
+ description: active.description,
+ billable: true,
+ work_date: new Date().toISOString().slice(0, 10),
+ };
+ setForm(snapshot);
+ setStopOpen(true);
+ };
+
+ const cancelStop = () => setStopOpen(false);
+
+ const discard = () => {
+ if (!confirm("Discard this timer without saving?")) return;
+ reset();
+ setStopOpen(false);
+ };
+
+ const saveEntry = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!user?.id) return;
+ if (!active.caseId) {
+ toast.error("Attach the timer to a case before saving a time entry.");
+ return;
+ }
+ const hours = parseFloat(form.hours);
+ const rate = parseFloat(form.rate || "0");
+ if (!hours || hours <= 0) return toast.error("Hours must be greater than 0");
+ if (!form.description.trim()) return toast.error("Description required");
+ setSaving(true);
+ const { error } = await supabase.from("time_entries").insert({
+ case_id: active.caseId,
+ user_id: user.id,
+ work_date: form.work_date,
+ hours,
+ hourly_rate: rate,
+ description: form.description.trim(),
+ billable: form.billable,
+ });
+ setSaving(false);
+ if (error) return toast.error(error.message);
+ toast.success("Time entry saved");
+ stopAndConsume();
+ setStopOpen(false);
+ };
+
+ const subtitle = active.caseId
+ ? `${active.caseNumber ?? ""} · ${active.caseTitle ?? ""}`
+ : `${active.clientName} (no case)`;
+
+ return (
+ <>
+
+
+
+ Active timer
+
+
+
+
{formatHMS(elapsedMs)}
+
+ {active.caseId ? (
+
+ {subtitle}
+
+ ) : (
+
+ {subtitle}
+
+ )}
+
+ {!active.caseId && (
+
Attach to a case to save as a time entry.
+ )}
+
+
+ {isRunning ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/lib/timer.tsx b/src/lib/timer.tsx
new file mode 100644
index 0000000..2506bfa
--- /dev/null
+++ b/src/lib/timer.tsx
@@ -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(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(null);
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const intervalRef = useRef(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 (
+
+ {children}
+
+ );
+}
+
+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")}`;
+}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index de705b2..be672d6 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,5 +1,6 @@
import { Outlet, Link, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { AuthProvider } from "@/lib/auth";
+import { TimerProvider } from "@/lib/timer";
import appCss from "../styles.css?url";
@@ -70,7 +71,9 @@ function RootShell({ children }: { children: React.ReactNode }) {
function RootComponent() {
return (
-
+
+
+
);
}
diff --git a/src/routes/cases.$caseId.tsx b/src/routes/cases.$caseId.tsx
index 27741b1..19aecdb 100644
--- a/src/routes/cases.$caseId.tsx
+++ b/src/routes/cases.$caseId.tsx
@@ -18,6 +18,7 @@ import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
+import { StartTimerButton } from "@/components/timer/start-timer-button";
export const Route = createFileRoute("/cases/$caseId")({
component: () => (
@@ -105,27 +106,41 @@ function CaseDetail() {
{" · "}{data.practice_area || "—"}{" · "}opened {formatDate(data.opened_at)}
- {canManage && (
-
-
-
-
- )}
+
+
+ {canManage && (
+ <>
+
+
+ >
+ )}
+
{data.description && (
diff --git a/src/routes/clients.$clientId.tsx b/src/routes/clients.$clientId.tsx
index 72ec9d9..9102758 100644
--- a/src/routes/clients.$clientId.tsx
+++ b/src/routes/clients.$clientId.tsx
@@ -10,6 +10,7 @@ import { ClientFormDialog } from "@/components/clients/client-form-dialog";
import { useAuth } from "@/lib/auth";
import { ArrowLeft, Edit, Plus, Building2, User, Briefcase as Building, MapPin, Mail, Phone, Users } from "lucide-react";
import { formatDate, statusBadgeClass } from "@/lib/format";
+import { StartTimerButton } from "@/components/timer/start-timer-button";
import { toast } from "sonner";
export const Route = createFileRoute("/clients/$clientId")({
@@ -79,6 +80,11 @@ function ClientDetail() {
description={client.management_company || client.client_type.toUpperCase()}
actions={
<>
+
{canEdit && (