From add07a84509d22a0e03a5af4846dc0dd08c74540 Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Fri, 17 Apr 2026 00:31:58 +0000
Subject: [PATCH 01/11] Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
---
src/integrations/supabase/types.ts | 3 +++
.../20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql | 1 +
2 files changed, 4 insertions(+)
create mode 100644 supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index b4820bd..2d88199 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -378,6 +378,7 @@ export type Database = {
created_at: string
email: string
full_name: string
+ hourly_rate: number | null
id: string
updated_at: string
}
@@ -385,6 +386,7 @@ export type Database = {
created_at?: string
email?: string
full_name?: string
+ hourly_rate?: number | null
id: string
updated_at?: string
}
@@ -392,6 +394,7 @@ export type Database = {
created_at?: string
email?: string
full_name?: string
+ hourly_rate?: number | null
id?: string
updated_at?: string
}
diff --git a/supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql b/supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql
new file mode 100644
index 0000000..1953cd4
--- /dev/null
+++ b/supabase/migrations/20260417003155_0138dec9-da46-4320-8c71-85da96bc4117.sql
@@ -0,0 +1 @@
+ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS hourly_rate numeric;
\ No newline at end of file
From 18545bc0930ca25f09fc441dcdd7a610b5e74770 Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Fri, 17 Apr 2026 00:32:51 +0000
Subject: [PATCH 02/11] Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
---
src/components/timer/header-timer.tsx | 249 ++++++++++++++++++++++++++
src/lib/timer.tsx | 163 +++++++++--------
2 files changed, 329 insertions(+), 83 deletions(-)
create mode 100644 src/components/timer/header-timer.tsx
diff --git a/src/components/timer/header-timer.tsx b/src/components/timer/header-timer.tsx
new file mode 100644
index 0000000..4da0e13
--- /dev/null
+++ b/src/components/timer/header-timer.tsx
@@ -0,0 +1,249 @@
+import { useEffect, useMemo, useState } from "react";
+import { useTimer, formatHMS } from "@/lib/timer";
+import { useAuth } from "@/lib/auth";
+import { supabase } from "@/integrations/supabase/client";
+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 {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Pause, Play, Square, Timer as TimerIcon, Loader2, X } from "lucide-react";
+import { toast } from "sonner";
+
+interface ClientOption {
+ id: string;
+ name: string;
+}
+interface CaseOption {
+ id: string;
+ case_number: string;
+ title: string;
+ client_id: string;
+ default_hourly_rate: number | null;
+}
+
+export function HeaderTimer() {
+ const { user } = useAuth();
+ const { timer, elapsedMs, isRunning, hasStarted, setClient, setCase, setDescription, start, pause, resume, reset, consume } = useTimer();
+
+ const [open, setOpen] = useState(false);
+ const [clients, setClients] = useState([]);
+ const [cases, setCases] = useState([]);
+ const [profileRate, setProfileRate] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [billable, setBillable] = useState(true);
+
+ // Load clients + cases the user can access (RLS handles filtering)
+ useEffect(() => {
+ if (!user?.id) return;
+ (async () => {
+ const [{ data: cs }, { data: ks }, { data: prof }] = await Promise.all([
+ supabase.from("clients").select("id, name").order("name"),
+ supabase
+ .from("cases")
+ .select("id, case_number, title, client_id, default_hourly_rate")
+ .order("case_number", { ascending: false }),
+ supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle(),
+ ]);
+ setClients(cs ?? []);
+ setCases(ks ?? []);
+ setProfileRate((prof as any)?.hourly_rate ?? null);
+ })();
+ }, [user?.id]);
+
+ const filteredCases = useMemo(
+ () => (timer.clientId ? cases.filter((c) => c.client_id === timer.clientId) : []),
+ [cases, timer.clientId],
+ );
+
+ const activeCase = useMemo(
+ () => cases.find((c) => c.id === timer.caseId),
+ [cases, timer.caseId],
+ );
+
+ const effectiveRate = activeCase?.default_hourly_rate ?? profileRate ?? 0;
+
+ const handleSave = async () => {
+ if (!user?.id) return toast.error("Not signed in");
+ if (!timer.caseId) return toast.error("Select a case before saving");
+ if (!timer.description.trim()) return toast.error("Description required");
+ if (elapsedMs < 1000) return toast.error("Timer hasn't recorded any time yet");
+ setSaving(true);
+ const { hours } = consume();
+ const { error } = await supabase.from("time_entries").insert({
+ case_id: timer.caseId,
+ user_id: user.id,
+ work_date: new Date().toISOString().slice(0, 10),
+ hours,
+ hourly_rate: effectiveRate,
+ description: timer.description.trim(),
+ billable,
+ });
+ setSaving(false);
+ if (error) {
+ toast.error(error.message);
+ return;
+ }
+ toast.success(`Saved ${hours.toFixed(1)} hr`, {
+ description: `${formatCurrency(hours * effectiveRate)} at ${formatCurrency(effectiveRate)}/hr`,
+ });
+ setBillable(true);
+ setOpen(false);
+ };
+
+ const handleDiscard = () => {
+ if (hasStarted && !confirm("Discard the running timer?")) return;
+ reset();
+ setBillable(true);
+ };
+
+ const dotClass = isRunning
+ ? "bg-emerald-500 animate-pulse"
+ : hasStarted
+ ? "bg-amber-500"
+ : "bg-muted-foreground/40";
+
+ return (
+
+
+
+
+
+
+
+
Time tracker
+
+ {formatHMS(elapsedMs)}
+
+
+
+ {isRunning ? (
+
+ ) : (
+
+ )}
+ {hasStarted && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Rate
+
+ {effectiveRate ? `${formatCurrency(effectiveRate)}/hr` : "—"}
+
+
+ {activeCase?.default_hourly_rate ? "From case" : profileRate ? "Your default" : "Set rate in profile"}
+
+
+
+
+
+
+ Time saves rounded up to the nearest 1/10 hour (6 minutes).
+
+
+
+
+
+
+ );
+}
+
+function formatCurrency(n: number): string {
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0);
+}
diff --git a/src/lib/timer.tsx b/src/lib/timer.tsx
index 2506bfa..d2e2ecf 100644
--- a/src/lib/timer.tsx
+++ b/src/lib/timer.tsx
@@ -2,171 +2,162 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
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 {
+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;
- description: string;
- startedAt: string; // ISO when first started
+ startedAt: string | null;
}
interface TimerState {
- active: PersistedTimer | null;
+ timer: PersistedTimer;
elapsedMs: number;
isRunning: boolean;
- start: (target: TimerTarget, description?: string) => void;
+ hasStarted: boolean;
+ setClient: (clientId: string | null) => void;
+ setCase: (caseId: string | null) => void;
+ setDescription: (description: string) => void;
+ start: () => 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;
+ consume: () => { hours: number };
}
+const empty: PersistedTimer = {
+ clientId: null,
+ caseId: null,
+ description: "",
+ accumulatedMs: 0,
+ runningSince: null,
+ startedAt: null,
+};
+
const TimerContext = createContext(undefined);
-function read(): PersistedTimer | null {
- if (typeof window === "undefined") return null;
+function read(): PersistedTimer {
+ if (typeof window === "undefined") return empty;
try {
const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return null;
- return JSON.parse(raw) as PersistedTimer;
+ if (!raw) return empty;
+ return { ...empty, ...(JSON.parse(raw) as PersistedTimer) };
} catch {
- return null;
+ return empty;
}
}
-function write(t: PersistedTimer | null) {
+function write(t: PersistedTimer) {
if (typeof window === "undefined") return;
- if (!t) localStorage.removeItem(STORAGE_KEY);
- else localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
+ 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 | null): number {
- if (!t) return 0;
+function computeElapsed(t: PersistedTimer): number {
return t.accumulatedMs + (t.runningSince ? Date.now() - t.runningSince : 0);
}
export function TimerProvider({ children }: { children: ReactNode }) {
- const [active, setActive] = useState(null);
+ const [timer, setTimer] = useState(empty);
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));
- }
+ setTimer(t);
+ setElapsedMs(computeElapsed(t));
}, []);
- // Tick every second when running
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
- if (active?.runningSince) {
+ if (timer.runningSince) {
intervalRef.current = window.setInterval(() => {
- setElapsedMs(computeElapsed(active));
+ setElapsedMs(computeElapsed(timer));
}, 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
- }, [active]);
+ }, [timer]);
- // Cross-tab sync
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key !== STORAGE_KEY) return;
const t = read();
- setActive(t);
+ setTimer(t);
setElapsedMs(computeElapsed(t));
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
- const persist = (t: PersistedTimer | null) => {
+ const persist = (t: PersistedTimer) => {
write(t);
- setActive(t);
+ setTimer(t);
setElapsedMs(computeElapsed(t));
};
- const start: TimerState["start"] = (target, description = "") => {
- const t: PersistedTimer = {
- ...target,
- accumulatedMs: 0,
+ 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(),
- description,
- startedAt: new Date().toISOString(),
- };
- persist(t);
+ startedAt: timer.startedAt ?? new Date().toISOString(),
+ });
};
const pause = () => {
- if (!active?.runningSince) return;
- const accumulatedMs = active.accumulatedMs + (Date.now() - active.runningSince);
- persist({ ...active, accumulatedMs, runningSince: null });
+ if (!timer.runningSince) return;
+ const accumulatedMs = timer.accumulatedMs + (Date.now() - timer.runningSince);
+ persist({ ...timer, accumulatedMs, runningSince: null });
};
const resume = () => {
- if (!active || active.runningSince) return;
- persist({ ...active, runningSince: Date.now() });
+ if (timer.runningSince) return;
+ persist({ ...timer, runningSince: Date.now() });
};
- const reset = () => persist(null);
+ const reset = () => persist(empty);
- 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;
+ 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 (
0 || !!timer.runningSince,
+ setClient,
+ setCase,
+ setDescription,
start,
pause,
resume,
reset,
- setDescription,
- stopAndConsume,
+ consume,
}}
>
{children}
@@ -187,3 +178,9 @@ export function formatHMS(ms: number): string {
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);
+}
From 225292af7f1a1f447b8bb730a4470b5bae794640 Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Fri, 17 Apr 2026 00:33:04 +0000
Subject: [PATCH 03/11] Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
---
src/components/protected-layout.tsx | 8 +-
src/components/timer/start-timer-button.tsx | 57 -------
src/components/timer/timer-widget.tsx | 173 --------------------
3 files changed, 1 insertion(+), 237 deletions(-)
delete mode 100644 src/components/timer/start-timer-button.tsx
delete mode 100644 src/components/timer/timer-widget.tsx
diff --git a/src/components/protected-layout.tsx b/src/components/protected-layout.tsx
index 5ac716c..96b2bc6 100644
--- a/src/components/protected-layout.tsx
+++ b/src/components/protected-layout.tsx
@@ -2,7 +2,6 @@ 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 }) {
@@ -26,10 +25,5 @@ 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
deleted file mode 100644
index da1e588..0000000
--- a/src/components/timer/start-timer-button.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-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
deleted file mode 100644
index 9ec855c..0000000
--- a/src/components/timer/timer-widget.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-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 ? (
-
- ) : (
-
- )}
-
-
-
-
-
- >
- );
-}
From 5499b5fca93dbcc6c7a4937e457a646938a568aa Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Fri, 17 Apr 2026 00:33:26 +0000
Subject: [PATCH 04/11] Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
---
src/routes/cases.$caseId.tsx | 57 ++++++++++++--------------------
src/routes/clients.$clientId.tsx | 6 ----
2 files changed, 21 insertions(+), 42 deletions(-)
diff --git a/src/routes/cases.$caseId.tsx b/src/routes/cases.$caseId.tsx
index 19aecdb..27741b1 100644
--- a/src/routes/cases.$caseId.tsx
+++ b/src/routes/cases.$caseId.tsx
@@ -18,7 +18,6 @@ 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: () => (
@@ -106,41 +105,27 @@ 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 9102758..72ec9d9 100644
--- a/src/routes/clients.$clientId.tsx
+++ b/src/routes/clients.$clientId.tsx
@@ -10,7 +10,6 @@ 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")({
@@ -80,11 +79,6 @@ function ClientDetail() {
description={client.management_company || client.client_type.toUpperCase()}
actions={
<>
-
{canEdit && (