diff --git a/package-lock.json b/package-lock.json index 2e295eb..a86c2e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "bits-website", "version": "0.1.0", "dependencies": { + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "lucide-react": "^0.554.0", "next": "16.0.3", "postcss": "^8.5.6", @@ -2739,6 +2741,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 4f03491..39ae331 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint" }, "dependencies": { + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "lucide-react": "^0.554.0", "next": "16.0.3", "postcss": "^8.5.6", diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts new file mode 100644 index 0000000..e1756b2 --- /dev/null +++ b/src/app/api/calendar/route.ts @@ -0,0 +1,58 @@ +// app/api/calendar/route.ts +import { NextResponse } from "next/server"; + +// Define TypeScript type for calender events based on API response structure +type CalendarEvent = { + id: string; + summary?: string; + location?: string; + description?: string; + start: { + dateTime?: string; + date?: string; + }; +}; + +// Fetch upcoming events from Google Calendar API +export async function GET() { + try { + const key = process.env.GOOGLE_API_KEY; + const calendarId = process.env.GOOGLE_CALENDAR_ID; + + // If either key is missing, return an error response + if (!key || !calendarId) { + return NextResponse.json( + { error: "Missing GOOGLE_API_KEY or GOOGLE_CALENDAR_ID" }, + { status: 500 }, + ); + } + + //Construct Google Calender API URL with query params + const url = + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent( + calendarId, + )}/events` + + `?key=${key}` + + `&singleEvents=true` + + `&orderBy=startTime` + + `&timeMin=${new Date().toISOString()}`; + + //Fetch events from Google Calender API + const res = await fetch(url); + + // Return error if API call fails + if (!res.ok) { + const text = await res.text(); + return NextResponse.json({ error: text }, { status: res.status }); + } + + //Parse response; return events as JSON + const data: { items: CalendarEvent[] } = await res.json(); + + return NextResponse.json(data.items); + } catch (err: unknown) { + // Handle unexpected errors; return JSON error response + const message = err instanceof Error ? err.message : "Unknown server error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index d5b0f94..c85646f 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -1,43 +1,14 @@ import Navbar from "@/components/Navbar"; -import { Calendar, Clock, MapPin } from "lucide-react"; +import EventList from "@/components/events/EventList"; -const events = [ - { date: "NOV 23", title: "General Body Meeting", time: "6:00 PM", location: "McBryde 100" }, - { date: "DEC 01", title: "Project Presentations", time: "5:30 PM", location: "Goodwin Hall" }, - { date: "DEC 05", title: "End of Semester Social", time: "7:00 PM", location: "TBD" }, -]; - -export default function Events() { +export default function EventsPage() { return ( <> - -
-
-
-
- -
-

Upcoming Events

- -
- {events.map((event, i) => ( -
-
- {event.date.split(' ')[0]} - {event.date.split(' ')[1]} -
-
-

{event.title}

-
- {event.time} - {event.location} -
-
-
- ))} -
+
+

Events

+
); -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b65e258..a798e95 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,7 +9,8 @@ const inter = Inter({ export const metadata: Metadata = { title: "BITS | Building Impactful Tech with Students", - description: "Professional-grade software solutions built by Virginia Tech engineering talent.", + description: + "Professional-grade software solutions built by Virginia Tech engineering talent.", }; export default function RootLayout({ @@ -20,15 +21,14 @@ export default function RootLayout({ return ( {/* We apply the font variable here so Tailwind can see it */} - +
- {/* --- BACKGROUND LAYERS --- */}
{/* 1. Grain Texture (White noise on black) */}
- + {/* 2. The Tech Grid (Inverted to faint white lines) */}
@@ -38,9 +38,9 @@ export default function RootLayout({
- {children} + {children}
); -} \ No newline at end of file +} diff --git a/src/components/events/EventList.tsx b/src/components/events/EventList.tsx new file mode 100644 index 0000000..389d5a6 --- /dev/null +++ b/src/components/events/EventList.tsx @@ -0,0 +1,370 @@ +"use client"; +import { useEffect, useState } from "react"; +import { MapPin } from "lucide-react"; + +/** + * ============================================================================ + * TYPES + * ============================================================================ + */ + +/** + * API event structure from Google Calendar + */ +type ApiEvent = { + id: string; + summary?: string; + location?: string; + description?: string; + start: { + dateTime?: string; + date?: string; + }; + end?: { + dateTime?: string; + date?: string; + }; +}; + +/** + * Normalized event with parsed dates and computed properties + * Used internally after processing from raw API response + */ +type NormalizedEvent = { + id: string; + summary?: string; + location?: string; + description?: string; + startDate: Date; + endDate?: Date; + isMultiDay: boolean; + isAllDay: boolean; + isToday: boolean; + isCurrentTime: boolean; +}; + +/** + * ============================================================================ + * UTILITY FUNCTIONS + * ============================================================================ + */ + +/** + * Parse a date string from Google Calendar API + * Handles both ISO 8601 (timed) and YYYY-MM-DD (all-day) formats + * + * @param value - Date string from API, or undefined + * @returns Parsed Date object, or undefined if parsing fails + */ +function parseGoogleDate(value?: string): Date | undefined { + if (!value) return undefined; + return new Date(value); +} + +/** + * Check if two dates fall on the same calendar day + * Ignores time component, only compares year/month/day + * + * @param date1 - First date to compare + * @param date2 - Second date to compare + * @returns True if dates are the same day + */ +function isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +/** + * Format a time string from a Date object + * Example: "5:00 PM" or "2:30 PM" + * + * @param date - Date object to format + * @returns Formatted time string + */ +function formatTime(date: Date): string { + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +/** + * Format a date range for display + * Single-day events show as "Mon, Jan 10" + * Multi-day events show as "Mon, Jan 10 → Wed, Jan 12" + * + * @param start - Start date + * @param end - Optional end date (if not provided or same day, shows single date) + * @returns Formatted date range string + */ +function formatDateRange(start: Date, end?: Date): string { + const startStr = start.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + + // Single-day event or no end date provided + if (!end || isSameDay(start, end)) { + return startStr; + } + + // Multi-day event - show range + const endStr = end.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + + return `${startStr} → ${endStr}`; +} + +/** + * ============================================================================ + * COMPONENT: EventList + * ============================================================================ + * + * Displays upcoming events from Google Calendar + * Features: + * - Fetches events from /api/calendar endpoint + * - Shows only future events (upcoming) + * - Handles multi-day events with date ranges + * - Responsive design optimized for dark theme + * - Loading and error states + */ +export default function EventList() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + /** + * Fetch and process calendar events + * Runs once on component mount + */ + useEffect(() => { + fetch("/api/calendar") + .then((res) => { + if (!res.ok) throw new Error("Failed to load events"); + return res.json(); + }) + .then((data: ApiEvent[]) => { + const now = new Date(); + const normalized: NormalizedEvent[] = []; + + // Parse and normalize events + for (const event of data) { + // Detect if event is all-day (has 'date' field instead of 'dateTime') + const isAllDay = !event.start.dateTime && !!event.start.date; + + // Parse start date (required field) + const startDate = parseGoogleDate( + event.start.dateTime ?? event.start.date, + ); + if (!startDate) continue; + + // Parse end date (optional field) + const endDateRaw = parseGoogleDate( + event.end?.dateTime ?? event.end?.date, + ); + + // For all-day events, Google Calendar returns end date as exclusive (next day) + // So we need to subtract one day to get the actual event end date + const endDate = + isAllDay && endDateRaw + ? new Date(endDateRaw.getTime() - 86400000) + : endDateRaw; + + // Skip events that have already ended + const eventEndTime = endDate || startDate; + if (eventEndTime < now) continue; + + // Determine if event spans multiple calendar days + const isMultiDay = endDate ? !isSameDay(startDate, endDate) : false; + + // Check if event is happening today + const isToday = isSameDay(startDate, now); + + // Check if event is happening at the current time (for timed events or all-day events) + // For all-day events, show as live for the entire day + // For timed events, show as live if current time is between start and end + const isCurrentTime = + isToday && + (isAllDay || (startDate <= now && (!endDate || endDate >= now))); + + normalized.push({ + ...event, + startDate, + endDate, + isMultiDay, + isAllDay, + isToday, + isCurrentTime, + }); + } + + // Sort chronologically by start date (earliest first) + normalized.sort( + (a, b) => a.startDate.getTime() - b.startDate.getTime(), + ); + + setEvents(normalized); + }) + .catch((err) => { + console.error(err); + setError(err instanceof Error ? err.message : "Unknown error"); + }) + .finally(() => setLoading(false)); + }, []); + + /** + * Loading state + */ + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + /** + * Error state + */ + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + /** + * Empty state (no future events) + */ + if (!events.length) { + return ( +
+

No upcoming events

+
+ ); + } + + /** + * Main render - event cards + */ + return ( +
+
+ {/* Header */} +
+

Events

+

+ {events.length} upcoming event{events.length !== 1 ? "s" : ""} +

+
+ + {/* Event cards */} +
+ {events.map((event) => { + const dateStr = formatDateRange(event.startDate, event.endDate); + + return ( +
+ {event.isCurrentTime && ( +
+ + + Live Now! + +
+ )} + {event.isAllDay && event.isToday && !event.isCurrentTime && ( +
+ + + Live Now! + +
+ )} + {event.isToday && !event.isCurrentTime && !event.isAllDay && ( +
+ + Starting Soon! + +
+ )} +
+ {/* Date Section */} +
+

+ {event.isMultiDay ? "Date Range" : "Date"} +

+

+ {dateStr} +

+ {!event.isAllDay && + (event.startDate.getHours() !== 0 || + event.startDate.getMinutes() !== 0) ? ( +

+ {formatTime(event.startDate)} + {event.endDate && + (event.endDate.getHours() !== 0 || + event.endDate.getMinutes() !== 0) ? ( + <> – {formatTime(event.endDate)} + ) : null} +

+ ) : null} +
+ + {/* Divider (visible on desktop only) */} +
+ + {/* Content Section */} +
+ {/* Event title */} +

+ {event.summary ?? "Untitled Event"} +

+ + {/* Location (if present) */} + {event.location && ( +

+ + {event.location} +

+ )} + + {/* Description (if present, truncated to 2 lines) */} + {event.description && ( +

+ {event.description} +

+ )} +
+
+
+ ); + })} +
+
+
+ ); +}