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 (
+
+ );
+ }
+
+ /**
+ * Empty state (no future events)
+ */
+ if (!events.length) {
+ return (
+
+ );
+ }
+
+ /**
+ * Main render - event cards
+ */
+ return (
+
+
+ {/* Header */}
+
+
+ {/* 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}
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+ );
+}