Add time clock feature with MCP access

- New beenvoice_time_entry DB table with migration (startedAt/endedAt, hours, rate, clientId)
- tRPC router with clockIn, clockOut, getRunning, getAll, getSummary, create, update, delete
- Dashboard page at /dashboard/time-clock with live elapsed timer, entry list, and manual entry form
- 5 MCP tools: time_clock_in, time_clock_out, time_get_running, time_entries_list, time_entries_create
- Sidebar navigation entry

Timer state is stored in PostgreSQL (endedAt IS NULL = running), suitable for serverless/Coolify deployment.
This commit is contained in:
Claude
2026-06-06 17:05:17 +00:00
parent 413eb3e3c0
commit 29630ebc1f
7 changed files with 1025 additions and 0 deletions
+96
View File
@@ -407,6 +407,102 @@ const tools = {
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.businesses.delete(input),
}),
time_clock_in: defineTool({
description:
"Start a time clock entry for the authenticated user. Fails if a timer is already running.",
inputSchema: {
type: "object",
properties: {
description: { type: "string", maxLength: 500 },
clientId: { type: "string" },
rate: { type: "number", minimum: 0 },
},
additionalProperties: false,
},
schema: z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
rate: z.number().min(0).optional(),
}),
handler: async (input, caller) => caller.timeEntries.clockIn(input),
}),
time_clock_out: defineTool({
description:
"Stop the currently running timer for the authenticated user. Returns the completed time entry with computed hours.",
inputSchema: {
type: "object",
properties: {
description: { type: "string", maxLength: 500 },
},
additionalProperties: false,
},
schema: z.object({
description: z.string().max(500).optional(),
}),
handler: async (input, caller) => caller.timeEntries.clockOut(input),
}),
time_get_running: defineTool({
description: "Get the currently running timer, if any. Returns null if no timer is running.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.timeEntries.getRunning(),
}),
time_entries_list: defineTool({
description: "List completed time entries for the authenticated user.",
inputSchema: {
type: "object",
properties: {
clientId: { type: "string" },
from: { type: "string", format: "date-time" },
to: { type: "string", format: "date-time" },
},
additionalProperties: false,
},
schema: z.object({
clientId: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
}),
handler: async (input, caller) =>
caller.timeEntries.getAll({
clientId: input.clientId,
from: input.from ? parseDate(input.from, "from") : undefined,
to: input.to ? parseDate(input.to, "to") : undefined,
}),
}),
time_entries_create: defineTool({
description:
"Create a manual time entry (for backdating or importing existing records). Hours are auto-computed from startedAt/endedAt if not provided.",
inputSchema: {
type: "object",
properties: {
description: { type: "string", maxLength: 500 },
clientId: { type: "string" },
startedAt: { type: "string", format: "date-time" },
endedAt: { type: "string", format: "date-time" },
hours: { type: "number", minimum: 0 },
rate: { type: "number", minimum: 0 },
notes: { type: "string", maxLength: 500 },
},
required: ["startedAt"],
additionalProperties: false,
},
schema: z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
startedAt: dateString,
endedAt: dateString.optional(),
hours: z.number().min(0).optional(),
rate: z.number().min(0).optional(),
notes: z.string().max(500).optional(),
}),
handler: async (input, caller) =>
caller.timeEntries.create({
...input,
startedAt: parseDate(input.startedAt, "startedAt"),
endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined,
}),
}),
} satisfies Record<string, ToolDefinition>;
function rpcResult(id: JsonRpcId, result: unknown, init?: ResponseInit) {
+575
View File
@@ -0,0 +1,575 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { NumberInput } from "~/components/ui/number-input";
import { DatePicker } from "~/components/ui/date-picker";
import { toast } from "sonner";
import { Clock, Play, Square, Plus, Pencil, Trash2 } from "lucide-react";
import { formatCurrency } from "~/lib/currency";
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 formatDuration(hours: number | null | undefined) {
if (!hours) return "—";
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
interface ManualEntryForm {
description: string;
clientId: string;
startedAt: Date;
endedAt: Date | undefined;
rate: number;
notes: string;
}
const defaultManualForm: ManualEntryForm = {
description: "",
clientId: "",
startedAt: new Date(),
endedAt: undefined,
rate: 0,
notes: "",
};
export default function TimeClockPage() {
const utils = api.useUtils();
const { data: running, isLoading: runningLoading } =
api.timeEntries.getRunning.useQuery(undefined, { refetchInterval: 30_000 });
const { data: entries = [], isLoading: entriesLoading } =
api.timeEntries.getAll.useQuery();
const { data: summary } = api.timeEntries.getSummary.useQuery();
const { data: clients = [] } = api.clients.getAll.useQuery();
const [clockInDesc, setClockInDesc] = useState("");
const [clockInClientId, setClockInClientId] = useState("");
const [clockInRate, setClockInRate] = useState(0);
const [elapsed, setElapsed] = useState(0);
const [manualOpen, setManualOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [manualForm, setManualForm] = useState<ManualEntryForm>(defaultManualForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (running) {
const tick = () => {
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
};
tick();
intervalRef.current = setInterval(tick, 1000);
} else {
setElapsed(0);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [running]);
const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: () => {
toast.success("Timer started");
void utils.timeEntries.getRunning.invalidate();
void utils.timeEntries.getAll.invalidate();
setClockInDesc("");
setClockInClientId("");
setClockInRate(0);
},
onError: (e) => toast.error(e.message),
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: () => {
toast.success("Timer stopped");
void utils.timeEntries.getRunning.invalidate();
void utils.timeEntries.getAll.invalidate();
void utils.timeEntries.getSummary.invalidate();
},
onError: (e) => toast.error(e.message),
});
const create = api.timeEntries.create.useMutation({
onSuccess: () => {
toast.success("Entry added");
void utils.timeEntries.getAll.invalidate();
void utils.timeEntries.getSummary.invalidate();
setManualOpen(false);
setManualForm(defaultManualForm);
setEditId(null);
},
onError: (e) => toast.error(e.message),
});
const update = api.timeEntries.update.useMutation({
onSuccess: () => {
toast.success("Entry updated");
void utils.timeEntries.getAll.invalidate();
void utils.timeEntries.getSummary.invalidate();
setManualOpen(false);
setManualForm(defaultManualForm);
setEditId(null);
},
onError: (e) => toast.error(e.message),
});
const del = api.timeEntries.delete.useMutation({
onSuccess: () => {
toast.success("Entry deleted");
void utils.timeEntries.getAll.invalidate();
void utils.timeEntries.getSummary.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message),
});
function handleEdit(entry: (typeof entries)[0]) {
setEditId(entry.id);
setManualForm({
description: entry.description,
clientId: entry.clientId ?? "",
startedAt: new Date(entry.startedAt),
endedAt: entry.endedAt ? new Date(entry.endedAt) : undefined,
rate: entry.rate ?? 0,
notes: entry.notes ?? "",
});
setManualOpen(true);
}
function handleManualSubmit() {
if (!manualForm.description.trim()) {
toast.error("Description is required");
return;
}
const payload = {
description: manualForm.description,
clientId: manualForm.clientId || undefined,
startedAt: manualForm.startedAt,
endedAt: manualForm.endedAt,
rate: manualForm.rate || undefined,
notes: manualForm.notes || undefined,
};
if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload);
}
const completedEntries = entries.filter((e) => e.endedAt !== null);
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
title="Time Clock"
description="Track billable hours with a live timer"
variant="gradient"
>
<Button
onClick={() => {
setEditId(null);
setManualForm(defaultManualForm);
setManualOpen(true);
}}
variant="outline"
>
<Plus className="mr-2 h-4 w-4" /> Add Manual Entry
</Button>
</PageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Total Hours
</p>
<p className="mt-1 text-2xl font-bold">
{formatDuration(summary?.totalHours)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Earnings
</p>
<p className="text-primary mt-1 text-2xl font-bold">
{formatCurrency(summary?.totalEarnings ?? 0)}
</p>
</CardContent>
</Card>
<Card className="col-span-2 sm:col-span-1">
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Entries
</p>
<p className="mt-1 text-2xl font-bold">{summary?.count ?? 0}</p>
</CardContent>
</Card>
</div>
{/* Active timer */}
<Card className={running ? "border-primary/30 bg-primary/5" : ""}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
{running ? "Timer Running" : "Start Timer"}
</CardTitle>
</CardHeader>
<CardContent>
{runningLoading ? (
<div className="text-muted-foreground text-sm">Loading</div>
) : running ? (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-primary font-mono text-4xl font-bold tabular-nums">
{formatElapsed(elapsed)}
</span>
<div className="flex-1">
{running.description && (
<p className="font-medium">{running.description}</p>
)}
{running.client && (
<p className="text-muted-foreground text-sm">{running.client.name}</p>
)}
<p className="text-muted-foreground text-xs">
Started{" "}
{new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(new Date(running.startedAt))}
</p>
</div>
<Button
variant="destructive"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
>
<Square className="mr-2 h-4 w-4" />
{clockOut.isPending ? "Stopping…" : "Stop Timer"}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>What are you working on?</Label>
<Input
value={clockInDesc}
onChange={(e) => setClockInDesc(e.target.value)}
placeholder="e.g. Frontend development"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
clockIn.mutate({
description: clockInDesc,
clientId: clockInClientId || undefined,
rate: clockInRate || undefined,
});
}
}}
/>
</div>
<div className="space-y-2">
<Label>Client (optional)</Label>
<Select
value={clockInClientId || "none"}
onValueChange={(v) => {
const id = v === "none" ? "" : v;
setClockInClientId(id);
if (id) {
const client = clients.find((c) => c.id === id);
if (client?.defaultHourlyRate && clockInRate === 0) {
setClockInRate(client.defaultHourlyRate);
}
}
}}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end gap-3">
<div className="w-40 space-y-2">
<Label>Hourly Rate (optional)</Label>
<NumberInput
value={clockInRate}
onChange={(v) => setClockInRate(v)}
min={0}
step={0.01}
placeholder="0.00"
/>
</div>
<Button
onClick={() =>
clockIn.mutate({
description: clockInDesc,
clientId: clockInClientId || undefined,
rate: clockInRate || undefined,
})
}
disabled={clockIn.isPending}
className="hover-lift shadow-md"
>
<Play className="mr-2 h-4 w-4" />
{clockIn.isPending ? "Starting…" : "Start Timer"}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Entry list */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" /> Time Entries
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{entriesLoading ? (
<div className="text-muted-foreground p-6 text-center text-sm">Loading</div>
) : completedEntries.length === 0 ? (
<div className="p-8 text-center">
<Clock className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">
No completed entries yet. Start a timer or add a manual entry.
</p>
</div>
) : (
<div className="divide-y">
{completedEntries.map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between gap-3 p-4"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">
{entry.description || (
<span className="text-muted-foreground italic">No description</span>
)}
</p>
{entry.client && (
<Badge variant="secondary" className="text-xs">
{entry.client.name}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(entry.startedAt))}
{entry.endedAt
? `${new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }).format(new Date(entry.endedAt))}`
: ""}
</p>
{entry.notes && (
<p className="text-muted-foreground mt-1 text-xs">{entry.notes}</p>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<div className="text-right">
<p className="font-semibold tabular-nums">
{formatDuration(entry.hours)}
</p>
{entry.rate && entry.hours ? (
<p className="text-muted-foreground text-xs">
{formatCurrency(entry.hours * entry.rate)}
</p>
) : null}
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleEdit(entry)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => setDeleteId(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Manual entry dialog */}
<Dialog open={manualOpen} onOpenChange={setManualOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? "Edit Entry" : "Add Manual Entry"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Description</Label>
<Input
value={manualForm.description}
onChange={(e) =>
setManualForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="What did you work on?"
/>
</div>
<div className="space-y-2">
<Label>Client (optional)</Label>
<Select
value={manualForm.clientId || "none"}
onValueChange={(v) =>
setManualForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Start Date</Label>
<DatePicker
date={manualForm.startedAt}
onDateChange={(d) =>
setManualForm((p) => ({ ...p, startedAt: d ?? new Date() }))
}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>End Date (optional)</Label>
<DatePicker
date={manualForm.endedAt}
onDateChange={(d) =>
setManualForm((p) => ({ ...p, endedAt: d ?? undefined }))
}
className="w-full"
/>
</div>
</div>
<div className="space-y-2">
<Label>Hourly Rate (optional)</Label>
<NumberInput
value={manualForm.rate}
onChange={(v) => setManualForm((p) => ({ ...p, rate: v }))}
min={0}
step={0.01}
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Input
value={manualForm.notes}
onChange={(e) =>
setManualForm((p) => ({ ...p, notes: e.target.value }))
}
placeholder="Additional details…"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setManualOpen(false)}>
Cancel
</Button>
<Button
onClick={handleManualSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Add Entry"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Entry</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2
View File
@@ -8,6 +8,7 @@ import {
BarChart2,
Shield,
RefreshCw,
Clock,
} from "lucide-react";
export interface NavLink {
@@ -31,6 +32,7 @@ export const navigationConfig: NavSection[] = [
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
{ name: "Time Clock", href: "/dashboard/time-clock", icon: Clock },
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
],
},
+2
View File
@@ -9,6 +9,7 @@ import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
import { paymentsRouter } from "~/server/api/routers/payments";
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
payments: paymentsRouter,
recurringInvoices: recurringInvoicesRouter,
apiKeys: apiKeysRouter,
timeEntries: timeEntriesRouter,
});
// export type definition of API
+263
View File
@@ -0,0 +1,263 @@
import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
const createSchema = z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
startedAt: z.date(),
endedAt: z.date().optional(),
hours: z.number().min(0).optional(),
rate: z.number().min(0).optional(),
notes: z.string().max(500).optional().or(z.literal("")),
});
const updateSchema = createSchema.partial().extend({ id: z.string() });
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);
}
export const timeEntriesRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
z
.object({
clientId: z.string().optional(),
from: z.date().optional(),
to: z.date().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
if (input?.clientId) conditions.push(eq(timeEntries.clientId, input.clientId));
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
return ctx.db.query.timeEntries.findMany({
where: and(...conditions),
with: { client: true },
orderBy: [desc(timeEntries.startedAt)],
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, input.id),
eq(timeEntries.createdById, ctx.session.user.id),
),
with: { client: true },
});
if (!entry) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
return entry;
}),
getRunning: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
with: { client: true },
});
}),
clockIn: protectedProcedure
.input(
z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
rate: z.number().min(0).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const running = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
});
if (running) {
throw new TRPCError({
code: "CONFLICT",
message: "A timer is already running. Stop it before clocking in.",
});
}
const clientId = input.clientId?.trim() || null;
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
});
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
const [entry] = await ctx.db
.insert(timeEntries)
.values({
description: input.description,
clientId,
startedAt: new Date(),
rate: input.rate ?? null,
createdById: ctx.session.user.id,
})
.returning();
return entry;
}),
clockOut: protectedProcedure
.input(
z.object({
id: z.string().optional(),
description: z.string().max(500).optional(),
}).optional(),
)
.mutation(async ({ ctx, input }) => {
const conditions = [
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
];
if (input?.id) conditions.push(eq(timeEntries.id, input.id));
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(...conditions),
});
if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
}
const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt);
const description = input?.description?.trim() ?? entry.description;
const [updated] = await ctx.db
.update(timeEntries)
.set({ endedAt, hours, description, updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id))
.returning();
return updated;
}),
create: protectedProcedure
.input(createSchema)
.mutation(async ({ ctx, input }) => {
const clientId = input.clientId?.trim() || null;
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
});
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
let hours = input.hours ?? null;
if (!hours && input.endedAt) {
hours = computeHours(input.startedAt, input.endedAt);
}
const [entry] = await ctx.db
.insert(timeEntries)
.values({
description: input.description,
clientId,
startedAt: input.startedAt,
endedAt: input.endedAt ?? null,
hours,
rate: input.rate ?? null,
notes: input.notes?.trim() || null,
createdById: ctx.session.user.id,
})
.returning();
return entry;
}),
update: protectedProcedure
.input(updateSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const existing = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, id),
eq(timeEntries.createdById, ctx.session.user.id),
),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
const clientId =
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
});
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
await ctx.db
.update(timeEntries)
.set({
...data,
clientId,
notes: data.notes?.trim() || null,
updatedAt: new Date(),
})
.where(eq(timeEntries.id, id));
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, input.id),
eq(timeEntries.createdById, ctx.session.user.id),
),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
return { success: true };
}),
getSummary: protectedProcedure
.input(
z.object({
from: z.date().optional(),
to: z.date().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [
eq(timeEntries.createdById, ctx.session.user.id),
isNotNull(timeEntries.endedAt),
];
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
const entries = await ctx.db.query.timeEntries.findMany({
where: and(...conditions),
with: { client: true },
});
const totalHours = entries.reduce((sum, e) => sum + (e.hours ?? 0), 0);
const totalEarnings = entries.reduce(
(sum, e) => sum + (e.hours ?? 0) * (e.rate ?? 0),
0,
);
return { totalHours, totalEarnings, count: entries.length };
}),
});
+50
View File
@@ -89,6 +89,7 @@ export const usersRelations = relations(users, ({ many }) => ({
expenses: many(expenses),
invoiceTemplates: many(invoiceTemplates),
recurringInvoices: many(recurringInvoices),
timeEntries: many(timeEntries),
}));
export const accounts = createTable(
@@ -282,6 +283,7 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
references: [users.id],
}),
invoices: many(invoices),
timeEntries: many(timeEntries),
}));
export const businesses = createTable(
@@ -679,3 +681,51 @@ export const recurringInvoiceItemsRelations = relations(
}),
}),
);
// ─── Time Entries ─────────────────────────────────────────────────────────────
export const timeEntries = createTable(
"time_entry",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
description: d.varchar({ length: 500 }).notNull().default(""),
clientId: d
.varchar({ length: 255 })
.references(() => clients.id, { onDelete: "set null" }),
startedAt: d.timestamp().notNull(),
endedAt: d.timestamp(), // null = currently running
hours: d.real(), // stored when stopped
rate: d.real(),
notes: d.varchar({ length: 500 }),
createdById: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("time_entry_created_by_idx").on(t.createdById),
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),
],
);
export const timeEntriesRelations = relations(timeEntries, ({ one }) => ({
client: one(clients, {
fields: [timeEntries.clientId],
references: [clients.id],
}),
createdBy: one(users, {
fields: [timeEntries.createdById],
references: [users.id],
}),
}));