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 (