Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 00:32:51 +00:00
co-authored by renee-png
parent add07a8450
commit 18545bc093
2 changed files with 329 additions and 83 deletions
+249
View File
@@ -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<ClientOption[]>([]);
const [cases, setCases] = useState<CaseOption[]>([]);
const [profileRate, setProfileRate] = useState<number | null>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 font-mono tabular-nums"
aria-label="Open timer"
>
<span className={`inline-block h-2 w-2 rounded-full ${dotClass}`} />
<TimerIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{formatHMS(elapsedMs)}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[360px] p-0">
<div className="px-4 py-3 border-b flex items-center justify-between">
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Time tracker</div>
<div className="font-mono text-3xl tabular-nums leading-tight mt-0.5">
{formatHMS(elapsedMs)}
</div>
</div>
<div className="flex items-center gap-1">
{isRunning ? (
<Button size="icon" variant="secondary" onClick={pause} aria-label="Pause">
<Pause className="h-4 w-4" />
</Button>
) : (
<Button size="icon" onClick={hasStarted ? resume : start} aria-label="Start">
<Play className="h-4 w-4" />
</Button>
)}
{hasStarted && (
<Button size="icon" variant="ghost" onClick={handleDiscard} aria-label="Discard">
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="p-4 space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Client</Label>
<Select
value={timer.clientId ?? ""}
onValueChange={(v) => setClient(v || null)}
>
<SelectTrigger>
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Case</Label>
<Select
value={timer.caseId ?? ""}
onValueChange={(v) => setCase(v || null)}
disabled={!timer.clientId}
>
<SelectTrigger>
<SelectValue placeholder={timer.clientId ? "Select case" : "Pick a client first"} />
</SelectTrigger>
<SelectContent>
{filteredCases.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No cases for this client.</div>
) : (
filteredCases.map((c) => (
<SelectItem key={c.id} value={c.id}>
<span className="text-muted-foreground mr-2">{c.case_number}</span>
{c.title}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea
rows={3}
placeholder="What are you working on?"
value={timer.description}
onChange={(e) => setDescription(e.target.value)}
maxLength={1000}
/>
</div>
<div className="grid grid-cols-2 gap-3 pt-1">
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Rate</div>
<div className="text-sm font-medium mt-0.5">
{effectiveRate ? `${formatCurrency(effectiveRate)}/hr` : "—"}
</div>
<div className="text-[10px] text-muted-foreground">
{activeCase?.default_hourly_rate ? "From case" : profileRate ? "Your default" : "Set rate in profile"}
</div>
</div>
<label className="flex items-end gap-2 text-sm pb-1">
<Checkbox checked={billable} onCheckedChange={(c) => setBillable(!!c)} />
Billable
</label>
</div>
<p className="text-[10px] text-muted-foreground">
Time saves rounded up to the nearest 1/10 hour (6 minutes).
</p>
<Button
className="w-full"
onClick={handleSave}
disabled={saving || !timer.caseId || !timer.description.trim() || elapsedMs < 1000}
>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
<Square className="h-4 w-4 mr-2" />
Stop & save
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function formatCurrency(n: number): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0);
}
+80 -83
View File
@@ -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<TimerState | undefined>(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<PersistedTimer | null>(null);
const [timer, setTimer] = useState<PersistedTimer>(empty);
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));
}
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 (
<TimerContext.Provider
value={{
active,
timer,
elapsedMs,
isRunning: !!active?.runningSince,
isRunning: !!timer.runningSince,
hasStarted: timer.accumulatedMs > 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);
}