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) {