diff --git a/src/app/(inbox)/calendar/page.tsx b/src/app/(inbox)/calendar/page.tsx
index e6146dd..f14cafd 100644
--- a/src/app/(inbox)/calendar/page.tsx
+++ b/src/app/(inbox)/calendar/page.tsx
@@ -1,8 +1,10 @@
import Link from "next/link";
import MobileBackButton from "@/components/MobileBackButton";
import { CalendarEventData } from "@/components/CalendarEventCard";
+import CalendarEventLink from "@/components/CalendarEventLink";
+import MobileCalendarAgenda from "@/components/MobileCalendarAgenda";
import { resolveCalendarEvent } from "@/lib/calendarDetect";
-import { addMonths, buildCalendarEntries, buildMonthDays, filterEventsForMonth, formatEventTime, groupEventsByDay, monthTitle, normalizeMonthKey } from "@/lib/calendarView";
+import { addMonths, buildCalendarEntries, buildMonthDays, filterEventsForMonth, monthTitle, normalizeMonthKey } from "@/lib/calendarView";
import { getAccountId, getMailboxes, getSession, listRecentCalendarCandidateEmails } from "@/lib/jmap";
export const dynamic = "force-dynamic";
@@ -13,69 +15,6 @@ 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);
@@ -112,7 +51,6 @@ export default async function CalendarPage({ searchParams }: Props) {
const entries = buildCalendarEntries(resolvedEvents);
const monthEntries = filterEventsForMonth(entries, monthKey);
const monthDays = buildMonthDays(monthKey, monthEntries);
- const groupedDays = groupEventsByDay(monthEntries);
return (
@@ -148,33 +86,13 @@ export default async function CalendarPage({ searchParams }: Props) {
+
+
{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) => (
@@ -210,14 +128,13 @@ export default async function CalendarPage({ searchParams }: Props) {
{day.events.map((event) => (
-
+
))}
))}
- >
)}
diff --git a/src/components/CalendarEventLink.tsx b/src/components/CalendarEventLink.tsx
new file mode 100644
index 0000000..39545d2
--- /dev/null
+++ b/src/components/CalendarEventLink.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import Link from "next/link";
+import { CalendarEventData } from "@/components/CalendarEventCard";
+import { formatEventTime } from "@/lib/calendarView";
+
+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;
+ }
+}
+
+interface Props {
+ event: CalendarEventData;
+}
+
+export default function CalendarEventLink({ event }: Props) {
+ const status = statusLabel(event);
+
+ return (
+
+
+
+ {formatEventTime(event)}
+
+ {status && (
+
+ {status}
+
+ )}
+
+
+ {event.summary || event.emailSubject || "(no title)"}
+
+ {event.location && (
+
+ {event.location}
+
+ )}
+
+ );
+}
diff --git a/src/components/MobileCalendarAgenda.tsx b/src/components/MobileCalendarAgenda.tsx
new file mode 100644
index 0000000..bc6d29b
--- /dev/null
+++ b/src/components/MobileCalendarAgenda.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { CalendarEventData } from "@/components/CalendarEventCard";
+import CalendarEventLink from "@/components/CalendarEventLink";
+import { buildMonthDays } from "@/lib/calendarView";
+
+const SWIPE_THRESHOLD_PX = 50;
+
+interface Props {
+ monthKey: string;
+ entries: CalendarEventData[];
+}
+
+function initialIndexForMonth(days: ReturnType) {
+ const inMonthDays = days.filter((day) => day.inMonth);
+ const todayIndex = inMonthDays.findIndex((day) => day.isToday);
+ return todayIndex >= 0 ? todayIndex : 0;
+}
+
+export default function MobileCalendarAgenda({ monthKey, entries }: Props) {
+ const days = useMemo(
+ () => buildMonthDays(monthKey, entries).filter((day) => day.inMonth),
+ [monthKey, entries]
+ );
+ const [selectedIndex, setSelectedIndex] = useState(() => initialIndexForMonth(buildMonthDays(monthKey, entries)));
+ const touchStartYRef = useRef(null);
+ const touchStartXRef = useRef(null);
+
+ useEffect(() => {
+ setSelectedIndex(initialIndexForMonth(buildMonthDays(monthKey, entries)));
+ }, [monthKey, entries]);
+
+ const selectedDay = days[selectedIndex] ?? days[0];
+
+ function moveDay(delta: number) {
+ setSelectedIndex((current) => {
+ const next = current + delta;
+ if (next < 0 || next >= days.length) return current;
+ return next;
+ });
+ }
+
+ if (!selectedDay) {
+ return (
+
+ No days available for this month.
+
+ );
+ }
+
+ const dayTitle = selectedDay.date.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+
+ return (
+ {
+ const touch = event.changedTouches[0];
+ touchStartYRef.current = touch.clientY;
+ touchStartXRef.current = touch.clientX;
+ }}
+ onTouchEnd={(event) => {
+ if (touchStartYRef.current === null || touchStartXRef.current === null) return;
+ const touch = event.changedTouches[0];
+ const deltaY = touch.clientY - touchStartYRef.current;
+ const deltaX = touch.clientX - touchStartXRef.current;
+ touchStartYRef.current = null;
+ touchStartXRef.current = null;
+
+ if (Math.abs(deltaY) < SWIPE_THRESHOLD_PX || Math.abs(deltaY) <= Math.abs(deltaX)) {
+ return;
+ }
+
+ if (deltaY < 0) moveDay(1);
+ else moveDay(-1);
+ }}
+ >
+
+
+
+
+ {dayTitle}
+
+
+ {selectedDay.events.length === 0
+ ? "No meetings on this day"
+ : `${selectedDay.events.length} meeting${selectedDay.events.length === 1 ? "" : "s"}`}
+
+
+ {selectedDay.isToday && (
+
+ Today
+
+ )}
+
+
+ Swipe up for next day, down for previous day.
+
+
+
+
+ {selectedDay.events.length === 0 ? (
+
+ Nothing scheduled from invite emails.
+
+ ) : (
+ selectedDay.events.map((event) => (
+
+ ))
+ )}
+
+
+
+
+
+ {selectedIndex + 1} / {days.length}
+
+
+
+
+ );
+}