diff --git a/src/app/(inbox)/calendar/page.tsx b/src/app/(inbox)/calendar/page.tsx new file mode 100644 index 0000000..e6146dd --- /dev/null +++ b/src/app/(inbox)/calendar/page.tsx @@ -0,0 +1,225 @@ +import Link from "next/link"; +import MobileBackButton from "@/components/MobileBackButton"; +import { CalendarEventData } from "@/components/CalendarEventCard"; +import { resolveCalendarEvent } from "@/lib/calendarDetect"; +import { addMonths, buildCalendarEntries, buildMonthDays, filterEventsForMonth, formatEventTime, groupEventsByDay, monthTitle, normalizeMonthKey } from "@/lib/calendarView"; +import { getAccountId, getMailboxes, getSession, listRecentCalendarCandidateEmails } from "@/lib/jmap"; + +export const dynamic = "force-dynamic"; + +const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; + +interface Props { + searchParams: Promise<{ month?: string }>; +} + +function eventClasses(event: CalendarEventData): string { + if (event.method === "CANCEL") { + return "border-red-200 bg-red-50/80 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300"; + } + switch (event.myCurrentPartstat) { + case "ACCEPTED": + return "border-green-200 bg-green-50/80 text-green-700 dark:border-green-900/60 dark:bg-green-950/30 dark:text-green-300"; + case "TENTATIVE": + return "border-amber-200 bg-amber-50/80 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300"; + case "DECLINED": + return "border-stone-200 bg-stone-100/90 text-stone-500 dark:border-stone-700 dark:bg-stone-800/80 dark:text-stone-400"; + default: + return "border-blue-200 bg-blue-50/80 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-300"; + } +} + +function statusLabel(event: CalendarEventData): string | null { + if (event.method === "CANCEL") return "Cancelled"; + switch (event.myCurrentPartstat) { + case "ACCEPTED": + return "Accepted"; + case "TENTATIVE": + return "Maybe"; + case "DECLINED": + return "Declined"; + default: + return null; + } +} + +function EventLink({ event }: { event: CalendarEventData }) { + const status = statusLabel(event); + return ( + +
+ + {formatEventTime(event)} + + {status && ( + + {status} + + )} +
+

+ {event.summary || event.emailSubject || "(no title)"} +

+ {event.location && ( +

+ {event.location} +

+ )} + + ); +} + +export default async function CalendarPage({ searchParams }: Props) { + const { month } = await searchParams; + const monthKey = normalizeMonthKey(month); + + const session = await getSession(); + const accountId = getAccountId(session); + const mailboxes = await getMailboxes(session.apiUrl, accountId); + + const excludedMailboxIds = new Set( + mailboxes + .filter((mailbox) => mailbox.role === "drafts" || mailbox.role === "sent" || mailbox.role === "trash") + .map((mailbox) => mailbox.id) + ); + + const recentEmails = await listRecentCalendarCandidateEmails(session.apiUrl, accountId); + const inviteEmails = recentEmails.filter((email) => { + const mailboxIds = Object.keys(email.mailboxIds ?? {}); + if (mailboxIds.some((mailboxId) => excludedMailboxIds.has(mailboxId))) { + return false; + } + + return ( + email.textBody?.some((part) => part.type === "text/calendar") || + email.attachments?.some((part) => part.type === "text/calendar") + ); + }); + + const resolvedEvents = (await Promise.all( + inviteEmails.map((email) => + resolveCalendarEvent(email, session.downloadUrl, accountId) + ) + )).filter((event): event is CalendarEventData => event !== null); + + const entries = buildCalendarEntries(resolvedEvents); + const monthEntries = filterEventsForMonth(entries, monthKey); + const monthDays = buildMonthDays(monthKey, monthEntries); + const groupedDays = groupEventsByDay(monthEntries); + + return ( +
+
+ + +
+
+

+ Calendar +

+

+ Meetings parsed from recent calendar invite emails. +

+
+ +
+ + Prev + +
+ {monthTitle(monthKey)} +
+ + Next + +
+
+ + {monthEntries.length === 0 ? ( +
+ No meetings found for {monthTitle(monthKey)}. +
+ ) : ( + <> +
+ {groupedDays.map(({ key, date, events }) => ( +
+
+

+ {date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +

+
+
+ {events.map((event) => ( + + ))} +
+
+ ))} +
+ +
+
+ {DAY_LABELS.map((label) => ( +
+ {label} +
+ ))} + + {monthDays.map((day) => ( +
+
+ + {day.date.getDate()} + +
+
+ {day.events.map((event) => ( + + ))} +
+
+ ))} +
+
+ + )} +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 842029d..9e9bbf3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -76,6 +76,12 @@ export default async function RootLayout({ > [SENT] + + [CALENDAR] +
diff --git a/src/components/CalendarEventCard.tsx b/src/components/CalendarEventCard.tsx index 3ad22a6..36575b5 100644 --- a/src/components/CalendarEventCard.tsx +++ b/src/components/CalendarEventCard.tsx @@ -4,7 +4,12 @@ import { useState } from "react"; import { sendCalendarReplyAction } from "@/app/(inbox)/email/[id]/actions"; export interface CalendarEventData { + uid: string; emailId: string; + threadId: string; + receivedAt: string; + emailSubject: string | null; + preview: string; icsText: string; method: string; summary: string; diff --git a/src/components/InboxPanelLayout.tsx b/src/components/InboxPanelLayout.tsx index c324c0d..b09b398 100644 --- a/src/components/InboxPanelLayout.tsx +++ b/src/components/InboxPanelLayout.tsx @@ -12,6 +12,7 @@ export default function InboxPanelLayout({ list, children }: Props) { const showDetail = pathname.startsWith("/email/") || pathname.startsWith("/thread/") || + pathname.startsWith("/calendar") || pathname.startsWith("/attachment/") || pathname.startsWith("/settings") || pathname.startsWith("/sent"); diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index de690c5..02077b3 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -42,6 +42,15 @@ function SentIcon() { ); } +function CalendarIcon() { + return ( + + ); +} + function SettingsIcon() { return (