From 5c28b33e9f7235df0deeb11d266e4f5a9bdcaafe Mon Sep 17 00:00:00 2001
From: Sean O'Connor
Date: Wed, 17 Jun 2026 22:36:39 -0400
Subject: [PATCH] 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
---
.../_components/active-timer-widget.tsx | 80 ++--
.../[id]/_components/invoice-timer-card.tsx | 183 +---------
src/app/dashboard/invoices/[id]/page.tsx | 6 +-
src/app/dashboard/page.tsx | 5 -
src/app/dashboard/time-clock/page.tsx | 34 +-
src/components/forms/invoice-form.tsx | 1 +
src/components/forms/invoice-line-items.tsx | 30 +-
src/components/invoice/time-tracker.tsx | 136 -------
src/components/layout/dashboard-shell.tsx | 5 +-
.../time-clock/time-clock-panel.tsx | 342 ++++++++++++++++++
src/lib/navigation.ts | 2 +
src/lib/time-clock.ts | 41 +++
src/server/api/routers/invoices.ts | 32 ++
src/server/api/routers/time-entries.ts | 201 +++++++++-
src/server/db/schema.ts | 5 +-
15 files changed, 730 insertions(+), 373 deletions(-)
delete mode 100644 src/components/invoice/time-tracker.tsx
create mode 100644 src/components/time-clock/time-clock-panel.tsx
create mode 100644 src/lib/time-clock.ts
diff --git a/src/app/dashboard/_components/active-timer-widget.tsx b/src/app/dashboard/_components/active-timer-widget.tsx
index 1c2cd20..9378fb6 100644
--- a/src/app/dashboard/_components/active-timer-widget.tsx
+++ b/src/app/dashboard/_components/active-timer-widget.tsx
@@ -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 (
- {/* Pulse indicator */}
@@ -82,7 +88,8 @@ export function ActiveTimerWidget() {
{invoiceLabel ? (
- <>Tracking for{" "}
+ <>
+ Billing to{" "}
>
) : (
- <>Started{" "}
- {new Intl.DateTimeFormat("en-US", {
- hour: "numeric",
- minute: "2-digit",
- }).format(new Date(running.startedAt))}
- >
+ <>No invoice selected — open time clock to assign>
)}
+ {" · "}
+
+ Time clock
+
- {formatElapsed(elapsed)}
+ {formatElapsedSeconds(elapsed)}
- clockOut.mutate({})}
- disabled={clockOut.isPending}
- >
-
- {clockOut.isPending ? "Stopping…" : "Stop"}
-
+
+
+
+
+ Open
+
+
+ clockOut.mutate({})}
+ disabled={clockOut.isPending}
+ >
+
+ {clockOut.isPending ? "Stopping…" : "Stop"}
+
+
);
diff --git a/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx b/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx
index 00b033b..a2b685c 100644
--- a/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx
+++ b/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx
@@ -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 | 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 (
-
-
-
-
- Timer
-
-
-
-
- A timer is already running
- {running.invoice
- ? ` for ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
- : ""}
- . Stop it before starting a new one.
-
-
-
- );
- }
-
- if (isThisInvoice && running) {
- return (
-
-
-
-
-
-
-
- Timer Running
-
-
-
-
-
-
{running.description || No description }
-
-
- {formatElapsed(elapsed)}
-
-
- clockOut.mutate({})}
- disabled={clockOut.isPending}
- >
-
- {clockOut.isPending ? "Stopping…" : "Stop & Add to Invoice"}
-
-
-
- );
- }
-
+export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
return (
-
-
-
-
- Track Time
-
-
-
-
- What are you working on?
- 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 });
- }
- }}
- />
-
-
- Hourly rate
- setRate(v)}
- min={0}
- step={0.01}
- placeholder="0.00"
- />
-
-
- clockIn.mutate({ description, clientId, invoiceId, rate: rate || undefined })
- }
- disabled={clockIn.isPending}
- >
-
- {clockIn.isPending ? "Starting…" : "Start Timer"}
-
-
-
+
+
+
+
+ Open full time clock
+
+
+
+
);
}
diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx
index 992fb44..1abb732 100644
--- a/src/app/dashboard/invoices/[id]/page.tsx
+++ b/src/app/dashboard/invoices/[id]/page.tsx
@@ -523,11 +523,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */}
{effectiveStatus !== "paid" && (
-
+
)}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index b1e5a6f..86635d7 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -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"
/>
-
-
-
-
}>
diff --git a/src/app/dashboard/time-clock/page.tsx b/src/app/dashboard/time-clock/page.tsx
index 9363815..c5d8203 100644
--- a/src/app/dashboard/time-clock/page.tsx
+++ b/src/app/dashboard/time-clock/page.tsx
@@ -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 (
+
+
+
+
+
+
+ );
}
diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx
index b76686b..f71ea4e 100644
--- a/src/components/forms/invoice-form.tsx
+++ b/src/components/forms/invoice-form.tsx
@@ -806,6 +806,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onUpdateItem={updateItem}
onAddItemWithValues={addItemWithValues}
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
+ clientId={formData.clientId || undefined}
defaultRate={formData.items[0]?.rate}
/>
diff --git a/src/components/forms/invoice-line-items.tsx b/src/components/forms/invoice-line-items.tsx
index d6db28e..48d9b46 100644
--- a/src/components/forms/invoice-line-items.tsx
+++ b/src/components/forms/invoice-line-items.tsx
@@ -4,6 +4,7 @@ import { Plus, Timer, Trash2, Zap } from "lucide-react";
import * as React from "react";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
+import Link from "next/link";
import { Button } from "~/components/ui/button";
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
@@ -15,7 +16,6 @@ import {
useLineItemSuggestions,
type LineItemSuggestion,
} from "~/hooks/use-line-item-suggestions";
-import { TimeTracker } from "~/components/invoice/time-tracker";
interface InvoiceItem {
id: string;
@@ -37,6 +37,7 @@ interface InvoiceLineItemsProps {
) => void;
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
invoiceId?: string;
+ clientId?: string;
defaultRate?: number;
className?: string;
}
@@ -348,7 +349,8 @@ export function InvoiceLineItems({
onUpdateItem,
onAddItemWithValues,
invoiceId,
- defaultRate,
+ clientId,
+ defaultRate: _defaultRate,
className,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
@@ -422,18 +424,24 @@ export function InvoiceLineItems({
/>
))}
- {onAddItemWithValues && invoiceId && (
+ {invoiceId && (
- Time tracker
+ Time clock
-
{
- onAddItemWithValues({ description, hours, rate: defaultRate ?? 0 });
- }}
- />
+
+ Track time on the dedicated time clock — entries sync across devices and
+ bill directly to an invoice.
+
+
+
+ Open time clock
+
+
)}
{onAddItemWithValues && (
diff --git a/src/components/invoice/time-tracker.tsx b/src/components/invoice/time-tracker.tsx
deleted file mode 100644
index e8ac71d..0000000
--- a/src/components/invoice/time-tracker.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { Play, Square } from "lucide-react";
-import { Button } from "~/components/ui/button";
-import { Input } from "~/components/ui/input";
-
-interface TimeTrackerProps {
- invoiceId: string;
- onStop: (hours: number, description: string) => void;
- defaultRate?: number;
-}
-
-interface PersistedState {
- running: boolean;
- startedAt: number | null;
- description: string;
-}
-
-function storageKey(invoiceId: string) {
- return `time-tracker-${invoiceId}`;
-}
-
-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(":");
-}
-
-function readPersistedState(invoiceId: string): PersistedState {
- if (typeof window === "undefined") return { running: false, startedAt: null, description: "" };
- try {
- const raw = localStorage.getItem(storageKey(invoiceId));
- if (raw) return JSON.parse(raw) as PersistedState;
- } catch {
- // ignore
- }
- return { running: false, startedAt: null, description: "" };
-}
-
-export function TimeTracker({ invoiceId, onStop }: TimeTrackerProps) {
- const [running, setRunning] = useState(() => readPersistedState(invoiceId).running);
- const [startedAt, setStartedAt] = useState(
- () => readPersistedState(invoiceId).startedAt,
- );
- const [elapsed, setElapsed] = useState(() => {
- const s = readPersistedState(invoiceId);
- if (s.running && s.startedAt) {
- return Math.max(0, Math.floor((Date.now() - s.startedAt) / 1000));
- }
- return 0;
- });
- const [description, setDescription] = useState(
- () => readPersistedState(invoiceId).description,
- );
- const intervalRef = useRef | null>(null);
-
- useEffect(() => {
- if (running && startedAt !== null) {
- intervalRef.current = setInterval(() => {
- setElapsed(Math.floor((Date.now() - startedAt) / 1000));
- }, 1000);
- }
- return () => {
- if (intervalRef.current) clearInterval(intervalRef.current);
- };
- }, [running, startedAt]);
-
- function persist(state: PersistedState) {
- localStorage.setItem(storageKey(invoiceId), JSON.stringify(state));
- }
-
- function handleStart() {
- const now = Date.now();
- setStartedAt(now);
- setElapsed(0);
- setRunning(true);
- persist({ running: true, startedAt: now, description });
- }
-
- function handleStop() {
- if (intervalRef.current) clearInterval(intervalRef.current);
- const hours = Math.max(0.25, Math.ceil(elapsed / 900) * 0.25);
- setRunning(false);
- setStartedAt(null);
- setElapsed(0);
- localStorage.removeItem(storageKey(invoiceId));
- onStop(hours, description);
- setDescription("");
- }
-
- return (
-
- {running ? (
- <>
-
- {formatElapsed(elapsed)}
-
-
{
- setDescription(e.target.value);
- persist({ running: true, startedAt, description: e.target.value });
- }}
- placeholder="What are you working on?"
- className="flex-1"
- />
-
-
- Stop & add
-
- >
- ) : (
- <>
-
setDescription(e.target.value)}
- placeholder="What will you work on?"
- className="flex-1"
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- handleStart();
- }
- }}
- />
-
-
- Start timer
-
- >
- )}
-
- );
-}
diff --git a/src/components/layout/dashboard-shell.tsx b/src/components/layout/dashboard-shell.tsx
index 96eddab..2ace687 100644
--- a/src/components/layout/dashboard-shell.tsx
+++ b/src/components/layout/dashboard-shell.tsx
@@ -12,6 +12,7 @@ import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
+import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar();
@@ -68,10 +69,12 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
)}
>
- {/* Mobile header spacer is handled by pt-16 on mobile */}
{/* Mobile Breadcrumbs could go here or be part of the page */}
+
{children}
diff --git a/src/components/time-clock/time-clock-panel.tsx b/src/components/time-clock/time-clock-panel.tsx
new file mode 100644
index 0000000..72ba706
--- /dev/null
+++ b/src/components/time-clock/time-clock-panel.tsx
@@ -0,0 +1,342 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { api } from "~/trpc/react";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Clock, Play, Square, ExternalLink } from "lucide-react";
+import { toast } from "sonner";
+import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
+
+export type TimeClockPanelProps = {
+ defaultClientId?: string;
+ defaultInvoiceId?: string;
+ compact?: boolean;
+};
+
+export function TimeClockPanel({
+ defaultClientId = "",
+ defaultInvoiceId = "",
+ compact = false,
+}: TimeClockPanelProps) {
+ const utils = api.useUtils();
+ const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery(
+ undefined,
+ { refetchInterval: 30_000 },
+ );
+ const { data: clients } = api.clients.getAll.useQuery();
+
+ const [clientId, setClientId] = useState(defaultClientId);
+ const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
+ const [description, setDescription] = useState("");
+ const [rate, setRate] = useState(0);
+ const [elapsed, setElapsed] = useState(0);
+ const intervalRef = useRef | null>(null);
+
+ const draftClientId = running ? (running.clientId ?? "") : clientId;
+ const { data: billableInvoices } = api.invoices.getBillable.useQuery(
+ draftClientId ? { clientId: draftClientId } : undefined,
+ { enabled: Boolean(draftClientId) },
+ );
+
+ const todayStart = useMemo(() => {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return d;
+ }, []);
+
+ const { data: todayEntries } = api.timeEntries.getAll.useQuery({
+ from: todayStart,
+ });
+
+ useEffect(() => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ if (!running) return;
+
+ 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 clockIn = api.timeEntries.clockIn.useMutation({
+ onSuccess: () => {
+ toast.success("Timer started");
+ void utils.timeEntries.getRunning.invalidate();
+ },
+ onError: (e) => toast.error(e.message),
+ });
+
+ const clockOut = api.timeEntries.clockOut.useMutation({
+ onSuccess: (data) => {
+ 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("Time logged", {
+ description: message,
+ action: {
+ 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(message);
+ }
+
+ void utils.timeEntries.getRunning.invalidate();
+ void utils.timeEntries.getAll.invalidate();
+ void utils.invoices.getAll.invalidate();
+ void utils.invoices.getBillable.invalidate();
+ void utils.dashboard.getStats.invalidate();
+ setDescription("");
+ },
+ onError: (e) => toast.error(e.message),
+ });
+
+ function handleClientChange(value: string) {
+ setClientId(value);
+ setInvoiceId("");
+ const client = clients?.find((c) => c.id === value);
+ setRate(client?.defaultHourlyRate ?? 0);
+ }
+
+ if (runningLoading) {
+ return (
+
+ Loading timer…
+
+ );
+ }
+
+ const invoiceLabel = (inv: {
+ invoicePrefix: string | null;
+ invoiceNumber: string;
+ status: string;
+ }) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`;
+
+ const displayDescription = running ? running.description : description;
+ const displayRate = running ? (running.rate ?? 0) : rate;
+
+ return (
+
+
+
+
+ {running ? (
+
+
+
+
+ ) : (
+
+ )}
+ {running ? "Timer running" : "Time clock"}
+
+
+
+ {running ? (
+
+
+
+ {displayDescription || (
+ No description
+ )}
+
+
+ {running.client?.name ?? "No client"}
+ {running.invoice
+ ? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
+ : ""}
+ {displayRate ? ` · $${displayRate}/hr` : ""}
+
+
+
+ {formatElapsedSeconds(elapsed)}
+
+
+ ) : null}
+
+ {!running ? (
+ <>
+
+
+ Client
+
+
+
+
+
+ {clients?.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+
+
+ Invoice
+ setInvoiceId(v === "__none__" ? "" : v)}
+ disabled={!clientId}
+ >
+
+
+
+
+ No invoice — save entry only
+ {billableInvoices?.map((inv) => (
+
+ {invoiceLabel(inv)}
+
+ ))}
+
+
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="What are you working on?"
+ />
+
+
+
+
Hourly rate
+
+ {clientId && rate === 0 ? (
+
+ Set a rate or add a default on the client record.
+
+ ) : null}
+
+ >
+ ) : (
+
+ Update description on stop (optional)
+ setDescription(e.target.value)}
+ placeholder={running.description || "What did you work on?"}
+ />
+
+ )}
+
+ {running ? (
+ clockOut.mutate({ description: description || undefined })}
+ disabled={clockOut.isPending}
+ >
+
+ {clockOut.isPending ? "Stopping…" : "Stop & save"}
+
+ ) : (
+
+ clockIn.mutate({
+ description,
+ clientId: clientId || "",
+ invoiceId: invoiceId || undefined,
+ rate: rate || undefined,
+ })
+ }
+ disabled={clockIn.isPending}
+ >
+
+ {clockIn.isPending ? "Starting…" : "Start timer"}
+
+ )}
+
+
+
+ {!compact && todayEntries && todayEntries.length > 0 ? (
+
+
+ Today's entries
+
+
+ {todayEntries
+ .filter((e) => e.endedAt)
+ .map((entry) => (
+
+
+
+ {entry.description || (
+ No description
+ )}
+
+
+ {entry.client?.name ?? "No client"}
+ {entry.invoice
+ ? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
+ : entry.hours
+ ? " · not on invoice"
+ : ""}
+
+
+
+
{entry.hours ?? "—"}h
+ {entry.rate ? (
+
${entry.rate}/hr
+ ) : null}
+
+
+ ))}
+
+
+ ) : null}
+
+ {compact ? (
+
+
+ Open full time clock
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts
index 17b6497..50d57cd 100644
--- a/src/lib/navigation.ts
+++ b/src/lib/navigation.ts
@@ -8,6 +8,7 @@ import {
BarChart2,
Shield,
RefreshCw,
+ Clock,
} from "lucide-react";
export interface NavLink {
@@ -26,6 +27,7 @@ export const navigationConfig: NavSection[] = [
title: "Main",
links: [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
+ { name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
diff --git a/src/lib/time-clock.ts b/src/lib/time-clock.ts
new file mode 100644
index 0000000..0ef6094
--- /dev/null
+++ b/src/lib/time-clock.ts
@@ -0,0 +1,41 @@
+export type ClockOutOutcome =
+ | "linked_to_invoice"
+ | "saved_no_invoice"
+ | "saved_no_client"
+ | "zero_hours";
+
+export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
+ const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
+ return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
+}
+
+export function formatElapsedSeconds(seconds: number): string {
+ 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 describeClockOutOutcome(input: {
+ outcome: ClockOutOutcome;
+ hours: number;
+ rate: number;
+ invoice?: { invoicePrefix: string; invoiceNumber: string } | null;
+}): string {
+ const amount = input.hours * input.rate;
+
+ switch (input.outcome) {
+ case "linked_to_invoice":
+ if (input.invoice) {
+ const label = `${input.invoice.invoicePrefix}${input.invoice.invoiceNumber}`;
+ return `Added ${input.hours}h @ $${input.rate}/hr ($${amount.toFixed(2)}) to invoice ${label}`;
+ }
+ return `Added ${input.hours}h to invoice`;
+ case "saved_no_invoice":
+ return `Saved ${input.hours}h — no open invoice found for this client. Pick an invoice on the time clock or create one.`;
+ case "saved_no_client":
+ return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
+ case "zero_hours":
+ return "Timer stopped (less than minimum billable increment).";
+ }
+}
diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts
index c3fd806..41335ba 100644
--- a/src/server/api/routers/invoices.ts
+++ b/src/server/api/routers/invoices.ts
@@ -155,6 +155,38 @@ export const invoicesRouter = createTRPCRouter({
}
}),
+ /** Draft and sent invoices available for time-clock billing. */
+ getBillable: protectedProcedure
+ .input(z.object({ clientId: z.string().optional() }).optional())
+ .query(async ({ ctx, input }) => {
+ const conditions = [
+ eq(invoices.createdById, ctx.session.user.id),
+ inArray(invoices.status, ["draft", "sent"]),
+ ];
+ if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
+
+ return ctx.db.query.invoices.findMany({
+ where: and(...conditions),
+ columns: {
+ id: true,
+ invoiceNumber: true,
+ invoicePrefix: true,
+ status: true,
+ clientId: true,
+ issueDate: true,
+ totalAmount: true,
+ },
+ with: {
+ client: { columns: { id: true, name: true } },
+ },
+ orderBy: (invoices, { desc }) => [
+ desc(invoices.issueDate),
+ desc(invoices.dueDate),
+ desc(invoices.invoiceNumber),
+ ],
+ });
+ }),
+
getLineItemHistory: protectedProcedure.query(async ({ ctx }) => {
const userInvoices = await ctx.db
.select({ id: invoices.id })
diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts
index 2a686fe..c486f6f 100644
--- a/src/server/api/routers/time-entries.ts
+++ b/src/server/api/routers/time-entries.ts
@@ -4,6 +4,10 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db";
+import {
+ computeTrackedHours,
+ type ClockOutOutcome,
+} from "~/lib/time-clock";
type Db = typeof db;
@@ -19,9 +23,25 @@ const createSchema = z.object({
const updateSchema = createSchema.partial().extend({ id: z.string() });
+async function resolveHourlyRate(
+ database: Db,
+ userId: string,
+ clientId: string | null,
+ explicitRate?: number | null,
+): Promise {
+ if (explicitRate != null && explicitRate > 0) return explicitRate;
+ if (!clientId) return explicitRate ?? null;
+
+ const client = await database.query.clients.findFirst({
+ where: and(eq(clients.id, clientId), eq(clients.createdById, userId)),
+ columns: { defaultHourlyRate: true },
+ });
+
+ return client?.defaultHourlyRate ?? explicitRate ?? null;
+}
+
function computeHours(startedAt: Date, endedAt: Date): number {
- const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
- return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
+ return computeTrackedHours(startedAt, endedAt);
}
async function addEntryToInvoice(
@@ -156,7 +176,7 @@ export const timeEntriesRouter = createTRPCRouter({
}),
getRunning: protectedProcedure.query(async ({ ctx }) => {
- return ctx.db.query.timeEntries.findFirst({
+ const entry = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
@@ -166,6 +186,7 @@ export const timeEntriesRouter = createTRPCRouter({
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } },
},
});
+ return entry ?? null;
}),
clockIn: protectedProcedure
@@ -193,19 +214,40 @@ export const timeEntriesRouter = createTRPCRouter({
}
const clientId = input.clientId?.trim() ?? null;
+ let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) {
- const client = await ctx.db.query.clients.findFirst({
+ const found = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+ columns: { defaultHourlyRate: true },
});
- if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
+ if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
+ clientRecord = found;
}
const invoiceId = input.invoiceId ?? null;
+ let resolvedClientId = clientId;
if (invoiceId) {
const invoice = await ctx.db.query.invoices.findFirst({
- where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id)),
+ where: and(
+ eq(invoices.id, invoiceId),
+ eq(invoices.createdById, ctx.session.user.id),
+ or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
+ ),
+ columns: { id: true, clientId: true },
});
- if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
+ if (!invoice) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Invoice not found or not open for time tracking",
+ });
+ }
+ if (resolvedClientId && invoice.clientId !== resolvedClientId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Selected invoice does not belong to this client",
+ });
+ }
+ resolvedClientId = resolvedClientId ?? invoice.clientId;
}
const startedAt = input.startedAt ?? new Date();
@@ -213,14 +255,32 @@ export const timeEntriesRouter = createTRPCRouter({
throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" });
}
+ if (!clientRecord && resolvedClientId) {
+ const found = await ctx.db.query.clients.findFirst({
+ where: and(
+ eq(clients.id, resolvedClientId),
+ eq(clients.createdById, ctx.session.user.id),
+ ),
+ columns: { defaultHourlyRate: true },
+ });
+ clientRecord = found ?? null;
+ }
+
+ const rate = await resolveHourlyRate(
+ ctx.db,
+ ctx.session.user.id,
+ resolvedClientId,
+ input.rate ?? clientRecord?.defaultHourlyRate,
+ );
+
const [entry] = await ctx.db
.insert(timeEntries)
.values({
description: input.description,
- clientId,
+ clientId: resolvedClientId,
invoiceId,
startedAt,
- rate: input.rate ?? null,
+ rate,
createdById: ctx.session.user.id,
})
.returning();
@@ -228,6 +288,109 @@ export const timeEntriesRouter = createTRPCRouter({
return entry;
}),
+ updateRunning: protectedProcedure
+ .input(
+ z.object({
+ description: z.string().max(500).optional(),
+ clientId: z.string().optional().or(z.literal("")),
+ invoiceId: z.string().optional().or(z.literal("")),
+ rate: z.number().min(0).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const entry = await ctx.db.query.timeEntries.findFirst({
+ where: and(
+ eq(timeEntries.createdById, ctx.session.user.id),
+ isNull(timeEntries.endedAt),
+ ),
+ });
+
+ if (!entry) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
+ }
+
+ const updates: {
+ description?: string;
+ clientId?: string | null;
+ invoiceId?: string | null;
+ rate?: number | null;
+ updatedAt: Date;
+ } = { updatedAt: new Date() };
+
+ if (input.description !== undefined) {
+ updates.description = input.description;
+ }
+
+ let resolvedClientId = entry.clientId;
+
+ if (input.clientId !== undefined) {
+ const clientId = input.clientId.trim() || null;
+ if (clientId) {
+ const found = await ctx.db.query.clients.findFirst({
+ where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+ });
+ if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
+ }
+ resolvedClientId = clientId;
+ updates.clientId = clientId;
+ if (input.invoiceId === undefined) {
+ updates.invoiceId = null;
+ }
+ }
+
+ if (input.invoiceId !== undefined) {
+ const invoiceId = input.invoiceId.trim() || null;
+ if (invoiceId) {
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: and(
+ eq(invoices.id, invoiceId),
+ eq(invoices.createdById, ctx.session.user.id),
+ or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
+ ),
+ columns: { id: true, clientId: true },
+ });
+ if (!invoice) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Invoice not found or not open for time tracking",
+ });
+ }
+ if (resolvedClientId && invoice.clientId !== resolvedClientId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Selected invoice does not belong to this client",
+ });
+ }
+ resolvedClientId = resolvedClientId ?? invoice.clientId;
+ updates.clientId = resolvedClientId;
+ }
+ updates.invoiceId = invoiceId;
+ }
+
+ if (input.rate !== undefined) {
+ updates.rate = input.rate;
+ } else if (input.clientId !== undefined && resolvedClientId) {
+ updates.rate = await resolveHourlyRate(
+ ctx.db,
+ ctx.session.user.id,
+ resolvedClientId,
+ entry.rate,
+ );
+ }
+
+ const [updated] = await ctx.db
+ .update(timeEntries)
+ .set(updates)
+ .where(eq(timeEntries.id, entry.id))
+ .returning();
+
+ if (!updated) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
+ }
+
+ return updated;
+ }),
+
clockOut: protectedProcedure
.input(
z.object({
@@ -253,6 +416,7 @@ export const timeEntriesRouter = createTRPCRouter({
const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt);
const description = input?.description?.trim() ?? entry.description;
+ const rate = entry.rate ?? 0;
const [updated] = await ctx.db
.update(timeEntries)
@@ -263,6 +427,8 @@ export const timeEntriesRouter = createTRPCRouter({
if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" });
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
+ let outcome: ClockOutOutcome = "zero_hours";
+
if (hours > 0) {
if (entry.invoiceId) {
linkedInvoice = await addEntryToSpecificInvoice(
@@ -272,9 +438,10 @@ export const timeEntriesRouter = createTRPCRouter({
updated.id,
description,
hours,
- entry.rate ?? 0,
+ rate,
endedAt,
);
+ outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice";
} else if (entry.clientId) {
linkedInvoice = await addEntryToLatestInvoice(
ctx.db,
@@ -283,13 +450,23 @@ export const timeEntriesRouter = createTRPCRouter({
updated.id,
description,
hours,
- entry.rate ?? 0,
+ rate,
endedAt,
);
+ outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice";
+ } else {
+ outcome = "saved_no_client";
}
}
- return { entry: updated, invoice: linkedInvoice };
+ return {
+ entry: updated,
+ invoice: linkedInvoice,
+ outcome,
+ hours,
+ rate,
+ amount: hours * rate,
+ };
}),
create: protectedProcedure
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 0435d0b..657e6e8 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -1,5 +1,5 @@
import { relations, sql } from "drizzle-orm";
-import { index, pgTableCreator } from "drizzle-orm/pg-core";
+import { index, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
@@ -720,6 +720,9 @@ export const timeEntries = createTable(
index("time_entry_client_id_idx").on(t.clientId),
index("time_entry_started_at_idx").on(t.startedAt),
index("time_entry_ended_at_idx").on(t.endedAt),
+ uniqueIndex("time_entry_one_running_per_user_idx")
+ .on(t.createdById)
+ .where(sql`${t.endedAt} is null`),
],
);