Midday / apps /api /src /ai /tools /create-tracker-entry.ts
Jules
Final deployment with all fixes and verified content
c09f67c
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"),
});
/**
* Parse duration string to seconds using parse-duration library
* Supports: "8h", "2h", "30m", "480m", "8.5h" (decimal hours), "2h 30m" (compound)
*/
function parseDurationToSeconds(durationStr: string): number {
const trimmed = durationStr.trim();
// Handle bare decimal numbers (e.g., "8.5" treated as hours)
if (/^\d+\.?\d*$/.test(trimmed)) {
const hours = Number.parseFloat(trimmed);
if (!Number.isNaN(hours)) {
return Math.round(hours * 3600);
}
}
// Use parse-duration for all other formats
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 {
// Find project by name or use provided projectId
let finalProjectId: string | null = projectId || null;
if (!finalProjectId && searchProjectName) {
// Search for project by name
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;
}
// Use the first matching project
finalProjectId = projectsResult.data[0]?.id || null;
// If multiple matches, mention it but use the first one
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;
}
// Parse duration
let durationSeconds: number;
try {
durationSeconds = parseDurationToSeconds(duration);
} catch (error) {
yield {
text:
error instanceof Error ? error.message : "Invalid duration format.",
};
return;
}
// Determine date (default to today)
const entryDate = date || new Date().toISOString().split("T")[0];
if (!entryDate) {
yield {
text: "Invalid date format.",
};
return;
}
// Calculate start and stop times (9 AM in user's timezone + duration)
const userTimezone = appContext.timezone || "UTC";
let startTime: Date;
if (userTimezone && userTimezone !== "UTC") {
try {
// Create a date at 9 AM in the user's timezone
const createTZDate = tz(userTimezone);
const baseDate = createTZDate(new Date(`${entryDate}T00:00:00`));
startTime = setMinutes(setHours(baseDate, 9), 0);
} catch (_error) {
// Fallback to UTC if timezone conversion fails
startTime = new Date(`${entryDate}T09:00:00.000Z`);
}
} else {
// Use UTC if no timezone provided
startTime = new Date(`${entryDate}T09:00:00.000Z`);
}
const stopTime = new Date(startTime.getTime() + durationSeconds * 1000);
const startTimeISO = startTime.toISOString();
const stopTimeISO = stopTime.toISOString();
// Create the entry
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"}`,
};
}
},
});