From 24c5326293c622d5e4ba31f1bd6366d49850f164 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:26:09 +0000 Subject: [PATCH 1/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/timer/start-timer-button.tsx | 57 ++++++ src/components/timer/timer-widget.tsx | 173 ++++++++++++++++++ src/lib/timer.tsx | 189 ++++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 src/components/timer/start-timer-button.tsx create mode 100644 src/components/timer/timer-widget.tsx create mode 100644 src/lib/timer.tsx 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..f687b49 --- /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 time entry.
+ )} +
+
+ {isRunning ? ( + + ) : ( + + )} + +
+
+ + + + + Save time entry + {subtitle} + +
+
+
+ + setForm({ ...form, work_date: e.target.value })} required /> +
+
+ + setForm({ ...form, hours: e.target.value })} required /> +
+
+
+ + setForm({ ...form, rate: e.target.value })} required /> +
+
+ +