diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AboutSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AboutSetting.tsx index 73765d004e..f4a99d4db0 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AboutSetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AboutSetting.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { SettingCard } from "@/components/SettingCard"; import { @@ -13,12 +14,14 @@ import { import { AboutSection } from "@/app/(app)/[emailAccountId]/settings/AboutSectionForm"; export function AboutSetting() { + const [open, setOpen] = useState(false); + return ( + } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/DigestSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/DigestSetting.tsx index 5a37b5019c..8de9f7e7d7 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/DigestSetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/DigestSetting.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { SettingCard } from "@/components/SettingCard"; import { @@ -13,12 +14,14 @@ import { import { DigestSettingsForm } from "@/app/(app)/[emailAccountId]/settings/DigestSettingsForm"; export function DigestSetting() { + const [open, setOpen] = useState(false); + return ( + } diff --git a/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx b/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx index 6c214d3053..eb5263ab9e 100644 --- a/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx +++ b/apps/web/app/(app)/[emailAccountId]/briefs/Onboarding.tsx @@ -7,7 +7,7 @@ import { TypographyH3, } from "@/components/Typography"; import { ConnectCalendar } from "@/app/(app)/[emailAccountId]/calendars/ConnectCalendar"; -import { UserIcon, MailIcon, LightbulbIcon } from "lucide-react"; +import { MailIcon, LightbulbIcon, UserSearchIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Item, @@ -47,9 +47,9 @@ export function BriefsOnboarding({ - + - + Attendee research @@ -58,7 +58,7 @@ export function BriefsOnboarding({ - + Email history @@ -67,7 +67,7 @@ export function BriefsOnboarding({ - + Key context diff --git a/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx index 49390eac9e..d0549acdd5 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx @@ -39,7 +39,7 @@ export function AboutSectionFull() { ); } -export function AboutSection() { +export function AboutSection({ onSuccess }: { onSuccess?: () => void }) { const { data, isLoading, error, mutate } = useEmailAccountFull(); return ( @@ -48,7 +48,11 @@ export function AboutSection() { error={error} loadingComponent={} > - + ); } @@ -56,9 +60,11 @@ export function AboutSection() { const AboutSectionForm = ({ about, mutate, + onSuccess, }: { about: string | null; mutate: () => void; + onSuccess?: () => void; }) => { const { register, @@ -78,6 +84,7 @@ const AboutSectionForm = ({ toastSuccess({ description: "Your profile has been updated!", }); + onSuccess?.(); }, onError: (error) => { toastError({ diff --git a/apps/web/app/(app)/[emailAccountId]/settings/DigestSettingsForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DigestSettingsForm.tsx index c0aa0adbf8..6f0e56f2d0 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/DigestSettingsForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/DigestSettingsForm.tsx @@ -58,7 +58,7 @@ const daysOfWeek = [ { value: "6", label: "Saturday" }, ]; -export function DigestSettingsForm() { +export function DigestSettingsForm({ onSuccess }: { onSuccess?: () => void }) { const { emailAccountId } = useAccount(); const { data: rules, @@ -224,6 +224,7 @@ export function DigestSettingsForm() { toastSuccess({ description: "Your digest settings have been updated!", }); + onSuccess?.(); } catch { toastError({ title: "Error updating digest settings", @@ -231,7 +232,7 @@ export function DigestSettingsForm() { }); } }, - [rules, executeItems, executeSchedule], + [rules, executeItems, executeSchedule, onSuccess], ); // Create options for MultiSelectFilter diff --git a/apps/web/utils/ai/meeting-briefs/generate-briefing.test.ts b/apps/web/utils/ai/meeting-briefs/generate-briefing.test.ts new file mode 100644 index 0000000000..7f226ad007 --- /dev/null +++ b/apps/web/utils/ai/meeting-briefs/generate-briefing.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from "vitest"; +import type { MeetingBriefingData } from "@/utils/meeting-briefs/gather-context"; + +vi.mock("server-only", () => ({})); +vi.mock("@/utils/llms/model", () => ({ getModel: vi.fn() })); +vi.mock("@/utils/llms", () => ({ createGenerateObject: vi.fn() })); +vi.mock("@/utils/stringify-email", () => ({ + stringifyEmailSimple: vi.fn( + (email) => + `From: ${email.from}\nSubject: ${email.subject}\nBody: ${email.content}`, + ), +})); +vi.mock("@/utils/get-email-from-message", () => ({ + getEmailForLLM: vi.fn((msg) => ({ + from: msg.headers?.from || "unknown", + subject: msg.headers?.subject || "no subject", + content: msg.textPlain || "no content", + })), +})); + +vi.doUnmock("@/utils/date"); + +import { buildPrompt } from "./generate-briefing"; + +describe("buildPrompt timezone handling", () => { + it("formats past meeting times in the user's timezone (not UTC)", () => { + // This test documents the timezone bug fix: + // - Calendar API stores times in UTC + // - A 4 PM BRT meeting is stored as 7 PM UTC + // - The prompt should show 4 PM (user's local time), not 7 PM (UTC) + + const meetingAt4pmBRT = new Date("2024-12-30T19:00:00Z"); // 7 PM UTC = 4 PM BRT + + const briefingData: MeetingBriefingData = { + event: { + id: "upcoming", + title: "Strategy Review", + description: "Discuss Q1 roadmap", + startTime: new Date("2024-12-31T21:00:00Z"), + endTime: new Date("2024-12-31T22:00:00Z"), + attendees: [ + { email: "user@company.com" }, + { email: "client@acme.com", name: "John Smith" }, + ], + }, + externalGuests: [{ email: "client@acme.com", name: "John Smith" }], + emailThreads: [], + pastMeetings: [ + { + id: "past-1", + title: "Previous Call", + description: "Discussed partnership opportunities", + startTime: meetingAt4pmBRT, + endTime: new Date("2024-12-30T20:00:00Z"), + attendees: [{ email: "client@acme.com", name: "John Smith" }], + }, + ], + }; + + const prompt = buildPrompt(briefingData, "America/Sao_Paulo"); + + // The past meeting should show "4:00 PM" (Brazil time), NOT "7:00 PM" (UTC) + expect(prompt).toMatchInlineSnapshot(` + "Please prepare a concise briefing for this meeting. + + + Title: Strategy Review + Description: Discuss Q1 roadmap + + + + + Name: John Smith + Email: client@acme.com + + + + Title: Previous Call + Date: Dec 30, 2024 at 4:00 PM + Description: Discussed partnership opportunities + + + + + + + + Return the briefing as a JSON object with a "guests" array containing structured information for each guest." + `); + }); + + it("shows no prior context for new contacts", () => { + const briefingData: MeetingBriefingData = { + event: { + id: "upcoming", + title: "Intro Meeting", + startTime: new Date("2024-12-31T21:00:00Z"), + endTime: new Date("2024-12-31T22:00:00Z"), + attendees: [ + { email: "user@company.com" }, + { email: "newcontact@other.com", name: "New Person" }, + ], + }, + externalGuests: [{ email: "newcontact@other.com", name: "New Person" }], + emailThreads: [], + pastMeetings: [], + }; + + const prompt = buildPrompt(briefingData, "America/Sao_Paulo"); + + expect(prompt).toMatchInlineSnapshot(` + "Please prepare a concise briefing for this meeting. + + + Title: Intro Meeting + + + + + + Name: New Person + Email: newcontact@other.com + + This appears to be a new contact with no prior email, meeting, or public profile history. + + + + + Return the briefing as a JSON object with a "guests" array containing structured information for each guest." + `); + }); +}); diff --git a/apps/web/utils/ai/meeting-briefs/generate-briefing.ts b/apps/web/utils/ai/meeting-briefs/generate-briefing.ts index 397ca1f911..2b730f3b1f 100644 --- a/apps/web/utils/ai/meeting-briefs/generate-briefing.ts +++ b/apps/web/utils/ai/meeting-briefs/generate-briefing.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { format } from "date-fns"; import { getModel } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; import type { EmailAccountWithAI } from "@/utils/llms/types"; @@ -8,6 +7,7 @@ import type { MeetingBriefingData } from "@/utils/meeting-briefs/gather-context" import { stringifyEmailSimple } from "@/utils/stringify-email"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import type { ParsedMessage } from "@/utils/types"; +import { formatDateTimeInUserTimezone } from "@/utils/date"; const guestBriefingSchema = z.object({ name: z.string().describe("The guest's name"), @@ -53,7 +53,7 @@ Return a structured JSON object with a "guests" array. Each guest should have: - "email": The guest's email address - "bullets": An array of brief bullet points about them (max 10 words each)`; - const prompt = buildPrompt(briefingData); + const prompt = buildPrompt(briefingData, emailAccount.timezone); const modelOptions = getModel(emailAccount.user); @@ -73,7 +73,11 @@ Return a structured JSON object with a "guests" array. Each guest should have: return result.object; } -function buildPrompt(briefingData: MeetingBriefingData): string { +// Exported for testing +export function buildPrompt( + briefingData: MeetingBriefingData, + timezone: string | null, +): string { const { event, externalGuests, emailThreads, pastMeetings } = briefingData; const allMessages = emailThreads.flatMap((t) => t.messages); @@ -85,6 +89,7 @@ function buildPrompt(briefingData: MeetingBriefingData): string { aiResearch: guest.aiResearch ?? undefined, recentEmails: selectRecentEmailsForGuest(allMessages, guest.email), recentMeetings: selectRecentMeetingsForGuest(pastMeetings, guest.email), + timezone, }), ); @@ -110,6 +115,7 @@ type GuestContextForPrompt = { recentEmails: ParsedMessage[]; recentMeetings: CalendarEvent[]; aiResearch?: string; + timezone: string | null; }; function formatGuestContext(guest: GuestContextForPrompt): string { @@ -121,8 +127,12 @@ function formatGuestContext(guest: GuestContextForPrompt): string { const hasEmails = recentEmails.length > 0; const hasMeetings = recentMeetings.length > 0; + const guestHeader = `${guest.name ? `Name: ${guest.name}\n` : ""}Email: ${guest.email}`; + if (!hasAiResearch && !hasEmails && !hasMeetings) { - return ` + return ` +${guestHeader} + This appears to be a new contact with no prior email, meeting, or public profile history. `; @@ -137,7 +147,7 @@ ${aiResearch} } if (hasEmails) { - sections.push(` + sections.push(` ${recentEmails .map( (email) => @@ -148,12 +158,14 @@ ${recentEmails } if (hasMeetings) { - sections.push(` -${recentMeetings.map(formatMeetingForContext).join("\n")} + sections.push(` +${recentMeetings.map((meeting) => formatMeetingForContext(meeting, guest.timezone)).join("\n")} `); } - return ` + return ` +${guestHeader} + ${sections.join("\n")} `; @@ -207,8 +219,12 @@ function getMessageTimestampMs(message: ParsedMessage): number { return Number.isFinite(parsed) ? parsed : 0; } -function formatMeetingForContext(meeting: CalendarEvent): string { - const dateStr = format(meeting.startTime, "MMM d, yyyy 'at' h:mm a"); +// Exported for testing +export function formatMeetingForContext( + meeting: CalendarEvent, + timezone: string | null, +): string { + const dateStr = formatDateTimeInUserTimezone(meeting.startTime, timezone); return ` Title: ${meeting.title} Date: ${dateStr} diff --git a/apps/web/utils/date.test.ts b/apps/web/utils/date.test.ts new file mode 100644 index 0000000000..ac45e5464c --- /dev/null +++ b/apps/web/utils/date.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { + formatInUserTimezone, + formatTimeInUserTimezone, + formatDateTimeInUserTimezone, +} from "./date"; + +describe("timezone formatting", () => { + // A fixed UTC timestamp: Dec 30, 2024 at 7:00 PM UTC + // This is the same moment in time, but displays differently in different timezones + const utcDate = new Date("2024-12-30T19:00:00Z"); + + describe("formatTimeInUserTimezone", () => { + it("should format time in Brazil timezone (UTC-3)", () => { + // 7 PM UTC = 4 PM BRT (UTC-3) + const result = formatTimeInUserTimezone(utcDate, "America/Sao_Paulo"); + expect(result).toBe("4:00 PM"); + }); + + it("should format time in US Eastern timezone (UTC-5)", () => { + // 7 PM UTC = 2 PM EST (UTC-5) + const result = formatTimeInUserTimezone(utcDate, "America/New_York"); + expect(result).toBe("2:00 PM"); + }); + + it("should format time in US Pacific timezone (UTC-8)", () => { + // 7 PM UTC = 11 AM PST (UTC-8) + const result = formatTimeInUserTimezone(utcDate, "America/Los_Angeles"); + expect(result).toBe("11:00 AM"); + }); + + it("should format time in Israel timezone (UTC+2)", () => { + // 7 PM UTC = 9 PM IST (UTC+2) + const result = formatTimeInUserTimezone(utcDate, "Asia/Jerusalem"); + expect(result).toBe("9:00 PM"); + }); + + it("should format time in Japan timezone (UTC+9)", () => { + // 7 PM UTC = 4 AM next day JST (UTC+9) + const result = formatTimeInUserTimezone(utcDate, "Asia/Tokyo"); + expect(result).toBe("4:00 AM"); + }); + + it("should default to UTC when timezone is null", () => { + const result = formatTimeInUserTimezone(utcDate, null); + expect(result).toBe("7:00 PM"); + }); + + it("should default to UTC when timezone is undefined", () => { + const result = formatTimeInUserTimezone(utcDate, undefined); + expect(result).toBe("7:00 PM"); + }); + }); + + describe("formatDateTimeInUserTimezone", () => { + it("should format date and time in Brazil timezone", () => { + // 7 PM UTC = 4 PM BRT on Dec 30 + const result = formatDateTimeInUserTimezone(utcDate, "America/Sao_Paulo"); + expect(result).toBe("Dec 30, 2024 at 4:00 PM"); + }); + + it("should format date and time in US Pacific timezone", () => { + // 7 PM UTC = 11 AM PST on Dec 30 + const result = formatDateTimeInUserTimezone( + utcDate, + "America/Los_Angeles", + ); + expect(result).toBe("Dec 30, 2024 at 11:00 AM"); + }); + + it("should handle date change when crossing midnight (Japan)", () => { + // 7 PM UTC on Dec 30 = 4 AM JST on Dec 31 + const result = formatDateTimeInUserTimezone(utcDate, "Asia/Tokyo"); + expect(result).toBe("Dec 31, 2024 at 4:00 AM"); + }); + + it("should handle date change when going backwards (Pacific)", () => { + // 3 AM UTC on Dec 30 = 7 PM PST on Dec 29 + const earlyUtc = new Date("2024-12-30T03:00:00Z"); + const result = formatDateTimeInUserTimezone( + earlyUtc, + "America/Los_Angeles", + ); + expect(result).toBe("Dec 29, 2024 at 7:00 PM"); + }); + + it("should default to UTC when timezone is null", () => { + const result = formatDateTimeInUserTimezone(utcDate, null); + expect(result).toBe("Dec 30, 2024 at 7:00 PM"); + }); + }); + + describe("formatInUserTimezone with custom format", () => { + it("should support custom format strings", () => { + const result = formatInUserTimezone( + utcDate, + "America/Sao_Paulo", + "yyyy-MM-dd HH:mm", + ); + expect(result).toBe("2024-12-30 16:00"); + }); + + it("should support 24-hour time format", () => { + // 7 PM UTC = 21:00 in Jerusalem + const result = formatInUserTimezone(utcDate, "Asia/Jerusalem", "HH:mm"); + expect(result).toBe("21:00"); + }); + }); + + describe("real-world briefing scenarios", () => { + it("should correctly format a 4 PM BRT meeting for a BRT user", () => { + // User has a meeting at 4 PM BRT (which is stored as 7 PM UTC in the calendar) + const meetingTimeUtc = new Date("2024-12-30T19:00:00Z"); + const userTimezone = "America/Sao_Paulo"; + + const formattedTime = formatTimeInUserTimezone( + meetingTimeUtc, + userTimezone, + ); + + // User should see "4:00 PM", not "7:00 PM" + expect(formattedTime).toBe("4:00 PM"); + }); + + it("should correctly format a morning meeting across date line", () => { + // Meeting at 10 AM in Sydney (which is 11 PM previous day UTC) + const sydneyMorningUtc = new Date("2024-12-29T23:00:00Z"); + const userTimezone = "Australia/Sydney"; + + const formattedDateTime = formatDateTimeInUserTimezone( + sydneyMorningUtc, + userTimezone, + ); + + // User should see Dec 30 at 10 AM, not Dec 29 + expect(formattedDateTime).toBe("Dec 30, 2024 at 10:00 AM"); + }); + }); + + describe("invalid timezone handling", () => { + it("should fall back to UTC for invalid timezone strings", () => { + const result = formatTimeInUserTimezone(utcDate, "Invalid/Timezone"); + // Should not throw, and should fall back to UTC (7:00 PM) + expect(result).toBe("7:00 PM"); + }); + + it("should handle legacy timezone abbreviations that TZDate supports", () => { + const result = formatTimeInUserTimezone(utcDate, "EST"); + // TZDate supports some legacy abbreviations like "EST" (UTC-5) + // 7 PM UTC = 2 PM EST + expect(result).toBe("2:00 PM"); + }); + + it("should fall back to UTC for corrupted timezone data", () => { + const result = formatDateTimeInUserTimezone( + utcDate, + "corrupted_data_123", + ); + expect(result).toBe("Dec 30, 2024 at 7:00 PM"); + }); + }); +}); diff --git a/apps/web/utils/date.ts b/apps/web/utils/date.ts index 791c17e615..a6aa150600 100644 --- a/apps/web/utils/date.ts +++ b/apps/web/utils/date.ts @@ -1,5 +1,8 @@ import { format } from "date-fns/format"; import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; +import { TZDate } from "@date-fns/tz"; +import { createScopedLogger } from "@/utils/logger"; +import { captureException } from "@/utils/error"; export const ONE_MINUTE_MS = 1000 * 60; export const ONE_HOUR_MS = ONE_MINUTE_MS * 60; @@ -104,3 +107,59 @@ export function sortByInternalDate( return direction === "asc" ? aTime - bTime : bTime - aTime; }; } + +const DEFAULT_TIMEZONE = "UTC"; +const logger = createScopedLogger("date-utils"); + +/** + * Formats a date/time in the user's timezone. + * Falls back to UTC if the timezone is invalid (corrupted/legacy/non-IANA values). + * @param date - The date to format (typically from a calendar event) + * @param timezone - The user's timezone (e.g., "America/Sao_Paulo", "America/New_York") + * @param formatString - The date-fns format string (e.g., "h:mm a", "MMM d, yyyy 'at' h:mm a") + * @returns The formatted date string in the user's timezone + */ +export function formatInUserTimezone( + date: Date, + timezone: string | null | undefined, + formatString: string, +): string { + const tz = timezone || DEFAULT_TIMEZONE; + try { + const dateInTZ = new TZDate(date, tz); + return format(dateInTZ, formatString); + } catch (error) { + // Invalid timezone (corrupted/legacy/non-IANA) - log and fall back to UTC + logger.error("Invalid timezone, falling back to UTC", { + timezone: tz, + error, + }); + captureException(error, { + extra: { timezone: tz, context: "formatInUserTimezone" }, + }); + const dateInUTC = new TZDate(date, DEFAULT_TIMEZONE); + return format(dateInUTC, formatString); + } +} + +/** + * Formats a time (without date) in the user's timezone. + * Example output: "4:00 PM" + */ +export function formatTimeInUserTimezone( + date: Date, + timezone: string | null | undefined, +): string { + return formatInUserTimezone(date, timezone, "h:mm a"); +} + +/** + * Formats a date and time in the user's timezone. + * Example output: "Dec 30, 2024 at 4:00 PM" + */ +export function formatDateTimeInUserTimezone( + date: Date, + timezone: string | null | undefined, +): string { + return formatInUserTimezone(date, timezone, "MMM d, yyyy 'at' h:mm a"); +} diff --git a/apps/web/utils/meeting-briefs/process.ts b/apps/web/utils/meeting-briefs/process.ts index e001b6906a..07217ce738 100644 --- a/apps/web/utils/meeting-briefs/process.ts +++ b/apps/web/utils/meeting-briefs/process.ts @@ -211,6 +211,7 @@ export async function runMeetingBrief({ emailAccountId, userEmail, provider, + userTimezone: emailAccount.timezone, logger: eventLog, }); diff --git a/apps/web/utils/meeting-briefs/send-briefing.ts b/apps/web/utils/meeting-briefs/send-briefing.ts index 3e6aa38cda..30b3d604cf 100644 --- a/apps/web/utils/meeting-briefs/send-briefing.ts +++ b/apps/web/utils/meeting-briefs/send-briefing.ts @@ -1,5 +1,4 @@ import { render } from "@react-email/render"; -import { format } from "date-fns"; import { env } from "@/env"; import { createEmailProvider } from "@/utils/email/provider"; import { sendMeetingBriefingEmail } from "@inboxzero/resend"; @@ -11,6 +10,7 @@ import MeetingBriefingEmail, { import type { CalendarEvent } from "@/utils/calendar/event-types"; import type { Logger } from "@/utils/logger"; import { createUnsubscribeToken } from "@/utils/unsubscribe"; +import { formatTimeInUserTimezone } from "@/utils/date"; export async function sendBriefingEmail({ event, @@ -18,6 +18,7 @@ export async function sendBriefingEmail({ emailAccountId, userEmail, provider, + userTimezone, logger, }: { event: CalendarEvent; @@ -25,11 +26,12 @@ export async function sendBriefingEmail({ emailAccountId: string; userEmail: string; provider: string; + userTimezone: string | null; logger: Logger; }): Promise { logger = logger.with({ emailAccountId, eventId: event.id, userEmail }); - const formattedTime = format(event.startTime, "h:mm a"); + const formattedTime = formatTimeInUserTimezone(event.startTime, userTimezone); const unsubscribeToken = await createUnsubscribeToken({ emailAccountId });