| import type { AppContext } from "@api/ai/agents/config/shared"; |
| import { tz } from "@date-fns/tz"; |
| import { db } from "@midday/db/client"; |
| import { getTrackerProjects, upsertTrackerEntries } from "@midday/db/queries"; |
| import { getAppUrl } from "@midday/utils/envs"; |
| import { formatDate } from "@midday/utils/format"; |
| import { tool } from "ai"; |
| import { formatDistance, setHours, setMinutes } from "date-fns"; |
| import parseDuration from "parse-duration"; |
| import { z } from "zod"; |
|
|
| const createTrackerEntrySchema = z.object({ |
| projectName: z.string().nullable().optional().describe("Project name"), |
| projectId: z.string().nullable().optional().describe("Project ID"), |
| duration: z.string().describe("Duration (e.g. '8h', '2h 30m', '480m')"), |
| date: z.string().nullable().optional().describe("Date (YYYY-MM-DD)"), |
| description: z.string().nullable().optional().describe("Description"), |
| }); |
|
|
| |
| |
| |
| |
| function parseDurationToSeconds(durationStr: string): number { |
| const trimmed = durationStr.trim(); |
|
|
| |
| if (/^\d+\.?\d*$/.test(trimmed)) { |
| const hours = Number.parseFloat(trimmed); |
| if (!Number.isNaN(hours)) { |
| return Math.round(hours * 3600); |
| } |
| } |
|
|
| |
| const milliseconds = parseDuration(trimmed); |
| if (milliseconds === null || milliseconds === undefined) { |
| throw new Error( |
| `Invalid duration format: ${durationStr}. Use formats like '8h', '2h 30m', '480m', or '8.5h'`, |
| ); |
| } |
|
|
| return Math.round(milliseconds / 1000); |
| } |
|
|
| export const createTrackerEntryTool = tool({ |
| description: |
| "Create a time entry for a tracker project - supports finding projects by name and flexible duration formats.", |
| inputSchema: createTrackerEntrySchema, |
| execute: async function* ( |
| { projectName, projectId, duration, date, description }, |
| executionOptions, |
| ) { |
| const appContext = executionOptions.experimental_context as AppContext; |
| const teamId = appContext.teamId as string; |
| const userId = appContext.userId || null; |
| const searchProjectName = projectName; |
|
|
| if (!teamId) { |
| yield { |
| text: "Unable to create tracker entry: Team ID not found in context.", |
| }; |
| return; |
| } |
|
|
| try { |
| |
| let finalProjectId: string | null = projectId || null; |
|
|
| if (!finalProjectId && searchProjectName) { |
| |
| const projectsResult = await getTrackerProjects(db, { |
| teamId, |
| q: searchProjectName, |
| pageSize: 5, |
| }); |
|
|
| if (projectsResult.data.length === 0) { |
| yield { |
| text: `No project found matching "${searchProjectName}". Please check the project name or provide a project ID.`, |
| }; |
| return; |
| } |
|
|
| |
| finalProjectId = projectsResult.data[0]?.id || null; |
|
|
| |
| if (projectsResult.data.length > 1 && projectsResult.data[0]) { |
| const projectNames = projectsResult.data |
| .map((p) => p.name) |
| .join(", "); |
| yield { |
| text: `Multiple projects found matching "${searchProjectName}". Using "${projectsResult.data[0].name}". Other matches: ${projectNames}`, |
| }; |
| } |
| } |
|
|
| if (!finalProjectId) { |
| yield { |
| text: "Please provide either a projectName or projectId to create a time entry.", |
| }; |
| return; |
| } |
|
|
| |
| let durationSeconds: number; |
| try { |
| durationSeconds = parseDurationToSeconds(duration); |
| } catch (error) { |
| yield { |
| text: |
| error instanceof Error ? error.message : "Invalid duration format.", |
| }; |
| return; |
| } |
|
|
| |
| const entryDate = date || new Date().toISOString().split("T")[0]; |
| if (!entryDate) { |
| yield { |
| text: "Invalid date format.", |
| }; |
| return; |
| } |
|
|
| |
| const userTimezone = appContext.timezone || "UTC"; |
| let startTime: Date; |
|
|
| if (userTimezone && userTimezone !== "UTC") { |
| try { |
| |
| const createTZDate = tz(userTimezone); |
| const baseDate = createTZDate(new Date(`${entryDate}T00:00:00`)); |
| startTime = setMinutes(setHours(baseDate, 9), 0); |
| } catch (_error) { |
| |
| startTime = new Date(`${entryDate}T09:00:00.000Z`); |
| } |
| } else { |
| |
| startTime = new Date(`${entryDate}T09:00:00.000Z`); |
| } |
|
|
| const stopTime = new Date(startTime.getTime() + durationSeconds * 1000); |
|
|
| const startTimeISO = startTime.toISOString(); |
| const stopTimeISO = stopTime.toISOString(); |
|
|
| |
| const result = await upsertTrackerEntries(db, { |
| teamId, |
| projectId: finalProjectId, |
| start: startTimeISO, |
| stop: stopTimeISO, |
| dates: [entryDate], |
| duration: durationSeconds, |
| assignedId: userId, |
| description: description || null, |
| }); |
|
|
| if (!result || result.length === 0) { |
| yield { |
| text: "Failed to create tracker entry.", |
| }; |
| return; |
| } |
|
|
| const entry = result[0]; |
| if (!entry) { |
| yield { |
| text: "Failed to create tracker entry.", |
| }; |
| return; |
| } |
|
|
| const start = new Date(0); |
| const end = new Date(durationSeconds * 1000); |
| const formattedDuration = formatDistance(start, end, { |
| includeSeconds: false, |
| }); |
| const projectName = entry.trackerProject?.name || "Unknown"; |
|
|
| const response = `Successfully created time entry:\n\n**Project:** ${projectName}\n**Date:** ${formatDate(entryDate)}\n**Duration:** ${formattedDuration}\n**Description:** ${entry.description || "None"}`; |
|
|
| yield { |
| text: response, |
| link: { |
| text: "View tracker", |
| url: `${getAppUrl()}/tracker`, |
| }, |
| }; |
| } catch (error) { |
| yield { |
| text: `Failed to create tracker entry: ${error instanceof Error ? error.message : "Unknown error"}`, |
| }; |
| } |
| }, |
| }); |
|
|