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} + + +
+
+ ); +}