Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
602 changes: 602 additions & 0 deletions apps/web/__tests__/ai-calendar-availability.test.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@ai-sdk/react": "2.0.28",
"@asteasolutions/zod-to-openapi": "8.1.0",
"@better-auth/sso": "1.3.7",
"@date-fns/tz": "1.4.1",
"@dub/analytics": "0.0.30",
"@formkit/auto-animate": "0.8.4",
"@googleapis/calendar": "^11.0.1",
Expand Down
50 changes: 48 additions & 2 deletions apps/web/utils/ai/calendar/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,20 @@ export async function aiGetCalendarAvailability({
include: {
calendars: {
where: { isEnabled: true },
select: { calendarId: true },
select: {
calendarId: true,
timezone: true,
primary: true,
},
},
},
});

// Determine user's primary timezone from calendars
const userTimezone = getUserTimezone(calendarConnections);

logger.trace("Determined user timezone", { userTimezone });

const system = `You are an AI assistant that analyzes email threads to determine if they contain meeting or scheduling requests, and if yes, returns the suggested times for the meeting.

Your task is to:
Expand All @@ -63,7 +72,9 @@ If the email thread is not about scheduling, return isRelevant: false.

You can only call "returnSuggestedTimes" once.
Your suggested times should be in the format of "YYYY-MM-DD HH:MM".
IMPORTANT: Another agent is responsible for drafting the final email reply. You just need to reply with the suggested times.`;
IMPORTANT: Another agent is responsible for drafting the final email reply. You just need to reply with the suggested times.

TIMEZONE CONTEXT: The user's primary timezone is ${userTimezone}. When interpreting times mentioned in emails (like "6pm"), assume they refer to this timezone unless explicitly stated otherwise.`;

const prompt = `
${
Expand Down Expand Up @@ -137,6 +148,11 @@ ${threadContent}
calendarIds,
startDate,
endDate,
timezone: userTimezone,
});

logger.trace("Calendar availability data", {
availabilityData,
Comment thread
elie222 marked this conversation as resolved.
});

return availabilityData;
Expand All @@ -163,3 +179,33 @@ ${threadContent}

return result ? { suggestedTimes: result } : null;
}

function getUserTimezone(
calendarConnections: Array<{
calendars: Array<{
calendarId: string;
timezone: string | null;
primary: boolean;
}>;
}>,
): string {
// First, try to find the primary calendar's timezone
for (const connection of calendarConnections) {
const primaryCalendar = connection.calendars.find((cal) => cal.primary);
if (primaryCalendar?.timezone) {
return primaryCalendar.timezone;
}
}

// If no primary calendar found, find any calendar with a timezone
for (const connection of calendarConnections) {
for (const calendar of connection.calendars) {
if (calendar.timezone) {
return calendar.timezone;
}
}
}

// Fallback to UTC if no timezone information is available
return "UTC";
}
5 changes: 4 additions & 1 deletion apps/web/utils/ai/choose-rule/run-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createScopedLogger } from "@/utils/logger";
import type { MatchReason } from "@/utils/ai/choose-rule/types";
import { sanitizeActionFields } from "@/utils/action-item";
import { extractEmailAddress } from "@/utils/email";
import { filterNullProperties } from "@/utils";
import { analyzeSenderPattern } from "@/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api";
import {
scheduleDelayedActions,
Expand Down Expand Up @@ -69,7 +70,9 @@ export async function runRules({
emailAccountId: emailAccount.id,
});

logger.trace("Matching rule", { result });
logger.trace("Matching rule", () => ({
result: filterNullProperties(result),
}));

if (result.rule) {
return await executeMatchedRule(
Expand Down
Empty file.
21 changes: 19 additions & 2 deletions apps/web/utils/calendar/availability.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { calendar_v3 } from "@googleapis/calendar";
import { TZDate } from "@date-fns/tz";
import { getCalendarClientWithRefresh } from "./client";
import { createScopedLogger } from "@/utils/logger";
import { startOfDay, endOfDay } from "date-fns";
Expand Down Expand Up @@ -49,6 +50,8 @@ async function fetchCalendarBusyPeriods({
}
}

logger.trace("Calendar busy periods", { busyPeriods, timeMin, timeMax });

return busyPeriods;
} catch (error) {
logger.error("Error fetching calendar busy periods", { error });
Expand All @@ -64,6 +67,7 @@ export async function getCalendarAvailability({
calendarIds,
startDate,
endDate,
timezone = "UTC",
}: {
accessToken?: string | null;
refreshToken: string | null;
Expand All @@ -72,6 +76,7 @@ export async function getCalendarAvailability({
calendarIds: string[];
startDate: Date;
endDate: Date;
timezone?: string;
}): Promise<BusyPeriod[]> {
const calendarClient = await getCalendarClientWithRefresh({
accessToken,
Expand All @@ -80,8 +85,20 @@ export async function getCalendarAvailability({
emailAccountId,
});

const timeMin = startOfDay(startDate).toISOString();
const timeMax = endOfDay(endDate).toISOString();
// Compute day boundaries directly in the user's timezone using TZDate
const startDateInTZ = new TZDate(startDate, timezone);
const endDateInTZ = new TZDate(endDate, timezone);

const timeMin = startOfDay(startDateInTZ).toISOString();
const timeMax = endOfDay(endDateInTZ).toISOString();

logger.trace("Calendar availability request with timezone", {
timezone,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
timeMin,
timeMax,
});

return await fetchCalendarBusyPeriods({
calendarClient,
Expand Down
24 changes: 16 additions & 8 deletions apps/web/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ export function createScopedLogger(scope: string) {
console.error(formatMessage("error", message, args)),
warn: (message: string, ...args: unknown[]) =>
console.warn(formatMessage("warn", message, args)),
trace: (message: string, ...args: unknown[]) => {
if (env.ENABLE_DEBUG_LOGS) {
console.log(formatMessage("trace", message, args));
}
trace: (
message: string,
...args: Array<unknown> | [() => unknown] | [() => unknown[]]
) => {
if (!env.ENABLE_DEBUG_LOGS) return;
const first = args[0];
const resolved = typeof first === "function" ? first() : args;
const finalArgs = Array.isArray(resolved) ? resolved : [resolved];
console.log(formatMessage("trace", message, finalArgs));
},
with: (newFields: Record<string, unknown>) =>
createLogger({ ...fields, ...newFields }),
Expand All @@ -74,10 +79,13 @@ function createAxiomLogger(scope: string) {
log.error(message, { scope, ...fields, ...formatError(args) }),
warn: (message: string, args?: Record<string, unknown>) =>
log.warn(message, { scope, ...fields, ...args }),
trace: (message: string, args?: Record<string, unknown>) => {
if (env.ENABLE_DEBUG_LOGS) {
log.debug(message, { scope, ...fields, ...args });
}
trace: (
message: string,
args?: Record<string, unknown> | (() => Record<string, unknown>),
Comment thread
elie222 marked this conversation as resolved.
) => {
Comment thread
elie222 marked this conversation as resolved.
if (!env.ENABLE_DEBUG_LOGS) return;
const resolved = typeof args === "function" ? args() : args;
log.debug(message, { scope, ...fields, ...resolved });
},
with: (newFields: Record<string, unknown>) =>
createLogger({ ...fields, ...newFields }),
Expand Down
41 changes: 1 addition & 40 deletions apps/web/utils/reply-tracker/generate-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { internalDateToDate } from "@/utils/date";
import { getEmailForLLM } from "@/utils/get-email-from-message";
import { aiDraftWithKnowledge } from "@/utils/ai/reply/draft-with-knowledge";
import { getReply, saveReply } from "@/utils/redis/reply";
import { getEmailAccountWithAi, getWritingStyle } from "@/utils/user/get";
import { getWritingStyle } from "@/utils/user/get";
import type { EmailAccountWithAI } from "@/utils/llms/types";
import { createScopedLogger } from "@/utils/logger";
import prisma from "@/utils/prisma";
Expand All @@ -19,45 +19,6 @@ import { env } from "@/env";

const logger = createScopedLogger("generate-reply");

export async function generateDraft({
emailAccountId,
client,
message,
}: {
emailAccountId: string;
client: EmailProvider;
message: ParsedMessage;
}) {
const logger = createScopedLogger("generate-reply").with({
emailAccountId,
messageId: message.id,
threadId: message.threadId,
});

logger.info("Generating draft");

const emailAccount = await getEmailAccountWithAi({ emailAccountId });
if (!emailAccount) throw new Error("User not found");

// 1. Draft with AI
const result = await fetchMessagesAndGenerateDraft(
emailAccount,
message.threadId,
client,
);

logger.info("Draft generated", { result });

if (typeof result !== "string") {
throw new Error("Draft result is not a string");
}

// 2. Create draft
await client.draftEmail(message, { content: result }, emailAccount.email);

logger.info("Draft created");
}

/**
* Fetches thread messages and generates draft content in one step
*/
Expand Down
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading