diff --git a/src/app/dashboard/_components/active-timer-widget.tsx b/src/app/dashboard/_components/active-timer-widget.tsx new file mode 100644 index 0000000..843b04f --- /dev/null +++ b/src/app/dashboard/_components/active-timer-widget.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { api } from "~/trpc/react"; +import { Card, CardContent } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Square } from "lucide-react"; +import { toast } from "sonner"; +import Link from "next/link"; + +function formatElapsed(seconds: number) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":"); +} + +export function ActiveTimerWidget() { + const utils = api.useUtils(); + const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, { + refetchInterval: 30_000, + }); + + const [elapsed, setElapsed] = useState(0); + const intervalRef = useRef | null>(null); + + useEffect(() => { + if (intervalRef.current) clearInterval(intervalRef.current); + if (running) { + const tick = () => + setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); + tick(); + intervalRef.current = setInterval(tick, 1000); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [running]); + + const clockOut = api.timeEntries.clockOut.useMutation({ + onSuccess: (data) => { + if (data.invoice) { + const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`; + toast.success("Timer stopped", { + description: `Added to invoice ${label}`, + action: { + label: "View Invoice", + onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), + }, + }); + } else { + toast.success("Timer stopped"); + } + void utils.timeEntries.getRunning.invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + + if (isLoading || !running) return null; + + return ( + + + {/* Pulse indicator */} + + + + + +
+

+ {running.description || ( + No description + )} + {running.client && ( + · {running.client.name} + )} +

+

+ Started{" "} + {new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }).format(new Date(running.startedAt))} +

+
+ + + {formatElapsed(elapsed)} + + +
+ + +
+
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5299a1b..9fe6ec6 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -492,6 +492,7 @@ function CardSkeleton() { } import { DashboardPageHeader } from "~/components/layout/page-header"; +import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; // ... imports @@ -503,6 +504,7 @@ export default async function DashboardPage() { // Fetch stats centrally const stats = await api.dashboard.getStats(); + void api.timeEntries.getRunning.prefetch(); return (
@@ -511,6 +513,10 @@ export default async function DashboardPage() { description="Here's what's happening with your business today" /> + + + + }>