Unify time tracking on server-backed entries with updateRunning.
Consolidates dashboard and invoice timers onto shared time-entry APIs so clock-in state, invoice linking, and clock-out flow stay consistent across surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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 { Square, Clock } 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(":");
|
||||
}
|
||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
||||
|
||||
export function ActiveTimerWidget() {
|
||||
const utils = api.useUtils();
|
||||
@@ -39,19 +33,32 @@ export function ActiveTimerWidget() {
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.invoice) {
|
||||
const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`;
|
||||
const message = describeClockOutOutcome({
|
||||
outcome: data.outcome,
|
||||
hours: data.hours,
|
||||
rate: data.rate,
|
||||
invoice: data.invoice,
|
||||
});
|
||||
|
||||
if (data.outcome === "linked_to_invoice" && data.invoice) {
|
||||
toast.success("Timer stopped", {
|
||||
description: `Added to invoice ${label}`,
|
||||
description: message,
|
||||
action: {
|
||||
label: "View Invoice",
|
||||
onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
||||
label: "View invoice",
|
||||
onClick: () =>
|
||||
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
||||
},
|
||||
});
|
||||
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") {
|
||||
toast.warning("Time saved", { description: message });
|
||||
} else {
|
||||
toast.success("Timer stopped");
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
void utils.timeEntries.getAll.invalidate();
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
@@ -65,7 +72,6 @@ export function ActiveTimerWidget() {
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
{/* Pulse indicator */}
|
||||
<span className="relative flex h-3 w-3 flex-shrink-0">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
|
||||
@@ -82,7 +88,8 @@ export function ActiveTimerWidget() {
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{invoiceLabel ? (
|
||||
<>Tracking for{" "}
|
||||
<>
|
||||
Billing to{" "}
|
||||
<Link
|
||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||
className="text-primary hover:underline"
|
||||
@@ -91,29 +98,36 @@ export function ActiveTimerWidget() {
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>Started{" "}
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(running.startedAt))}
|
||||
</>
|
||||
<>No invoice selected — open time clock to assign</>
|
||||
)}
|
||||
{" · "}
|
||||
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||
Time clock
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
||||
{formatElapsed(elapsed)}
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => clockOut.mutate({})}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop"}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/dashboard/time-clock">
|
||||
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => clockOut.mutate({})}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,178 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Clock, Play, Square } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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(":");
|
||||
}
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
interface InvoiceTimerCardProps {
|
||||
invoiceId: string;
|
||||
clientId: string;
|
||||
defaultRate?: number | null;
|
||||
}
|
||||
|
||||
export function InvoiceTimerCard({ invoiceId, clientId, defaultRate }: InvoiceTimerCardProps) {
|
||||
const utils = api.useUtils();
|
||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const [description, setDescription] = useState("");
|
||||
const [rate, setRate] = useState(defaultRate ?? 0);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isThisInvoice = running?.invoiceId === invoiceId;
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (isThisInvoice && 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);
|
||||
};
|
||||
}, [isThisInvoice, running]);
|
||||
|
||||
const clockIn = api.timeEntries.clockIn.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.invoice) {
|
||||
toast.success("Time added to invoice");
|
||||
} else {
|
||||
toast.success("Timer stopped");
|
||||
}
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
// Another timer is running for a different invoice
|
||||
if (running && !isThisInvoice) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Clock className="h-4 w-4" />
|
||||
Timer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
A timer is already running
|
||||
{running.invoice
|
||||
? ` for ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: ""}
|
||||
. Stop it before starting a new one.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isThisInvoice && running) {
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
|
||||
</span>
|
||||
Timer Running
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{running.description || <span className="text-muted-foreground italic">No description</span>}</p>
|
||||
</div>
|
||||
<span className="text-primary font-mono text-3xl font-bold tabular-nums">
|
||||
{formatElapsed(elapsed)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => clockOut.mutate({})}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop & Add to Invoice"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Clock className="h-4 w-4" />
|
||||
Track Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>What are you working on?</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Frontend development"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
clockIn.mutate({ description, clientId, invoiceId, rate: rate || undefined });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput
|
||||
value={rate}
|
||||
onChange={(v) => setRate(v)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
clockIn.mutate({ description, clientId, invoiceId, rate: rate || undefined })
|
||||
}
|
||||
disabled={clockIn.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{clockIn.isPending ? "Starting…" : "Start Timer"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-3">
|
||||
<TimeClockPanel
|
||||
compact
|
||||
defaultClientId={clientId}
|
||||
defaultInvoiceId={invoiceId}
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
||||
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
|
||||
Open full time clock
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -523,11 +523,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
{effectiveStatus !== "paid" && (
|
||||
<InvoiceTimerCard
|
||||
invoiceId={invoiceId}
|
||||
clientId={invoice.clientId}
|
||||
defaultRate={invoice.client?.defaultHourlyRate}
|
||||
/>
|
||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||
)}
|
||||
|
||||
<Card className="lg:sticky lg:top-6">
|
||||
|
||||
@@ -490,7 +490,6 @@ function CardSkeleton() {
|
||||
}
|
||||
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
|
||||
// ... imports
|
||||
|
||||
@@ -511,10 +510,6 @@ export default async function DashboardPage() {
|
||||
description="Here's what's happening with your business today"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<ActiveTimerWidget />
|
||||
</HydrateClient>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats stats={stats} />
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { HydrateClient, api } from "~/trpc/server";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
|
||||
export default function TimeClockPage() {
|
||||
redirect("/dashboard/invoices");
|
||||
export default async function TimeClockPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ clientId?: string; invoiceId?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
void api.timeEntries.getRunning.prefetch();
|
||||
void api.clients.getAll.prefetch();
|
||||
if (params.clientId) {
|
||||
void api.invoices.getBillable.prefetch({ clientId: params.clientId });
|
||||
} else {
|
||||
void api.invoices.getBillable.prefetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-enter mx-auto max-w-3xl space-y-6">
|
||||
<DashboardPageHeader
|
||||
title="Time clock"
|
||||
description="Track billable hours and save them directly to an invoice"
|
||||
/>
|
||||
<HydrateClient>
|
||||
<TimeClockPanel
|
||||
defaultClientId={params.clientId}
|
||||
defaultInvoiceId={params.invoiceId}
|
||||
/>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user