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:26:09 +00:00
co-authored by renee-png
parent 2a260e48ed
commit 24c5326293
3 changed files with 419 additions and 0 deletions
@@ -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<ButtonProps, "onClick"> {
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 (
<>
<Button {...rest} onClick={handleClick}>
<Play className="h-4 w-4 mr-1.5" />
{label}
</Button>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<TimerIcon className="h-4 w-4" /> Replace running timer?
</AlertDialogTitle>
<AlertDialogDescription>
A timer is already running ({formatHMS(elapsedMs)}) on{" "}
<span className="font-medium">{active?.caseNumber ?? active?.clientName}</span>. Starting a new
one will discard it without saving.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep current</AlertDialogCancel>
<AlertDialogAction onClick={() => start(target)}>Discard & start new</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+173
View File
@@ -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 (
<>
<div className="fixed bottom-4 right-4 z-50 w-[320px] rounded-lg border bg-card text-card-foreground shadow-lg">
<div className="flex items-center gap-2 px-3 py-2 border-b">
<TimerIcon className="h-4 w-4 text-primary" />
<span className="text-xs uppercase tracking-wider text-muted-foreground">Active timer</span>
<span className={`ml-auto inline-block h-2 w-2 rounded-full ${isRunning ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40"}`} />
</div>
<div className="px-3 pt-3 pb-2">
<div className="font-mono text-3xl tabular-nums tracking-tight">{formatHMS(elapsedMs)}</div>
<div className="mt-1 text-xs text-muted-foreground truncate">
{active.caseId ? (
<Link to="/cases/$caseId" params={{ caseId: active.caseId }} className="hover:text-primary underline-offset-4 hover:underline">
{subtitle}
</Link>
) : (
<Link to="/clients/$clientId" params={{ clientId: active.clientId }} className="hover:text-primary underline-offset-4 hover:underline">
{subtitle}
</Link>
)}
</div>
{!active.caseId && (
<div className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">Attach to a case to save as time entry.</div>
)}
</div>
<div className="flex gap-2 px-3 pb-3">
{isRunning ? (
<Button size="sm" variant="secondary" className="flex-1" onClick={pause}>
<Pause className="h-4 w-4 mr-1.5" /> Pause
</Button>
) : (
<Button size="sm" className="flex-1" onClick={resume}>
<Play className="h-4 w-4 mr-1.5" /> Resume
</Button>
)}
<Button size="sm" variant="outline" className="flex-1" onClick={openStop}>
<Square className="h-4 w-4 mr-1.5" /> Stop
</Button>
</div>
</div>
<Dialog open={stopOpen} onOpenChange={setStopOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save time entry</DialogTitle>
<DialogDescription>{subtitle}</DialogDescription>
</DialogHeader>
<form onSubmit={saveEntry} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
<Input type="date" value={form.work_date} onChange={(e) => setForm({ ...form, work_date: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hours</Label>
<Input type="number" step="0.01" min="0.01" value={form.hours} onChange={(e) => setForm({ ...form, hours: e.target.value })} required />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly rate ($)</Label>
<Input type="number" step="0.01" min="0" value={form.rate} onChange={(e) => setForm({ ...form, rate: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea
rows={3}
value={form.description}
onChange={(e) => {
setForm({ ...form, description: e.target.value });
setDescription(e.target.value);
}}
required
maxLength={1000}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
<DialogFooter className="gap-2 sm:justify-between">
<Button type="button" variant="ghost" className="text-destructive" onClick={discard}>
Discard
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={cancelStop}>Keep timing</Button>
<Button type="submit" disabled={saving || !active.caseId}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save entry
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
+189
View File
@@ -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")}`;
}