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:
2026-06-17 22:36:39 -04:00
co-authored by Cursor
parent 81a0ce33a4
commit 5c28b33e9f
15 changed files with 730 additions and 373 deletions
+32
View File
@@ -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 })
+189 -12
View File
@@ -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<number | null> {
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