diff --git a/.env.example b/.env.example
index 9894e28a..97486a4c 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,8 @@
+# Events — public ICS calendar feed URL (includes an auth token as a query param)
+EVENTS_ICS_URL=
+
# Notion API
NOTION_SECRET=
-NOTION_DB_ID=
NOTION_SIGNATORIES_DB_ID=
NOTION_FAQ_DB_ID=
NOTION_IDEAS_DB_ID=
diff --git a/CLAUDE.md b/CLAUDE.md
index e12f1744..bf396d13 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,8 @@ Many routes exist in pairs, e.g. `about.astro` / `about-new.astro`, `events.astr
### Data sources
-- **Notion API** (`src/store/notionClient.ts`) — events and other dynamic content via `fetchNotionEvents()` / `fetchNotionEventById()`. Images are direct Notion-hosted URLs (no proxy/cache — that worker was removed), expire after ~1hr, client falls back to `/images/default.jpg` on load error. See `docs/EVENTS.md` / `docs/NOTION.md`.
+- **Events ICS feed** (`src/store/eventsClient.ts`) — `fetchEvents()` fetches and hand-parses a public ICS calendar feed (`EVENTS_ICS_URL`, Mattermost Events Calendar plugin), server-side only since the URL carries an auth token. Consumed by `/api/events` and grouped into category sections (`src/utils/eventSections.ts`) by both `events.astro`/`Events.tsx` and `events-new.astro`/`EventsNew.tsx`. See `docs/EVENTS.md`.
+- **Notion API** (`src/store/notionClient.ts`) — FAQ, ideas, agenda/speakers, E4P signatories, endorsements, and community calls (events no longer come from Notion). Images are direct Notion-hosted URLs (no proxy/cache — that worker was removed), expire after ~1hr, client falls back to `/images/default.jpg` on load error. See `docs/NOTION.md`.
- **ProjectHub** (external service) — `src/pages/api/projects.ts` calls `projecthub.techforpalestine.org/api/public/projects` directly and is fetched client-side via `/api/projects` by both `ProjectsDirectory.tsx` and `ProjectsNew.tsx`. `src/pages/api/project-proxy.ts` is unrelated — it's a generic authenticated proxy used only by the volunteer/incubator application forms. See `docs/PROJECTS.md`.
- **Cloudflare KV** — `DROPPED_CONVERSIONS` namespace (bound in `wrangler.toml`) is used for conversion tracking, surfaced at `src/pages/admin/conversions.astro` and `src/pages/api/admin/conversion-stats.ts`.
- **Content collections** (`src/content/config.ts`) — currently empty (`collections = {}`); older docs referencing `content/ideas` and `content/projects` markdown collections are stale — check `src/content/` before relying on this.
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 9f1596c5..a9772ca5 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -11,10 +11,13 @@ The site deploys automatically to Cloudflare Pages on push to `main`.
Set the following in the Cloudflare Pages dashboard (Production and Preview are separate — set both). See `.env.example` for the canonical list; grouped here by subsystem (docs in parens). For how `.env`/`.dev.vars`/the dashboard interact locally, and a full audit of what's actually wired up, see [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md).
+**Events** ([docs/EVENTS.md](docs/EVENTS.md))
+
+- `EVENTS_ICS_URL` — public ICS calendar feed URL (includes an auth token as a query param); fetched server-side only
+
**Notion** ([docs/NOTION.md](docs/NOTION.md))
- `NOTION_SECRET` — shared integration token
-- `NOTION_DB_ID` — Events database ID
- `NOTION_SIGNATORIES_DB_ID`, `NOTION_FAQ_DB_ID`, `NOTION_IDEAS_DB_ID`, `NOTION_AGENDA_DB_ID`, `NOTION_ENDORSEMENTS_DB_ID`, `NOTION_COMMUNITY_CALLS_DB_ID`
- `NOTION_SPEAKERS_DB_ID` — listed for completeness; not currently read by any route
diff --git a/astro.config.mjs b/astro.config.mjs
index 40979fbd..3d18d0dd 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -65,7 +65,6 @@ export default defineConfig({
"/donate-2/",
"/donate-new/",
"/donate-2-new/",
- "/event-details/",
"/404/",
"/about-new/",
"/team-new/",
diff --git a/docs/API.md b/docs/API.md
index 5ccdb549..706d46ce 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -4,7 +4,7 @@ All routes live under `src/pages/api/` and run server-side only (`export const p
| Route | Method | Auth / origin check | Upstream | Notes |
| ----------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `/api/events` | GET | none (public read) | Notion (`fetchNotionEvents`) | `?showAll=yes` bypasses the Visibility filter |
+| `/api/events` | GET | none (public read) | ICS feed (`fetchEvents`, `EVENTS_ICS_URL`) | Flat array; consumers group into category sections via `eventSections.ts` |
| `/api/faq` | GET | none | Notion (`fetchNotionFAQ`) | `?showAll=yes` bypasses Visibility filter |
| `/api/ideas` | GET | none | Notion (`fetchNotionIdeas`) | |
| `/api/speakers` | GET | none | Notion (`fetchNotionAgenda`) | Returns `{ agendaItems, speakers }`; resolves moderator relations to speaker profiles |
diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md
index 2452e36c..e4cbc78b 100644
--- a/docs/ENVIRONMENT.md
+++ b/docs/ENVIRONMENT.md
@@ -43,7 +43,7 @@ Ground truth = every `getEnv("X", ...)` call in `src/`, plus the build-time read
| Variable | Read via | In `.env.example`? | In local `.env`? | In local `.dev.vars`? | Consumers |
| ------------------------------ | ---------------------------- | :----------------: | :--------------: | :-------------------: | ----------------------------------------------------------------------------------- |
| `NOTION_SECRET` | `getEnv` | ✅ | ✅ | ❌ | All Notion routes |
-| `NOTION_DB_ID` | `getEnv` | ✅ | ✅ | ❌ | Events |
+| `EVENTS_ICS_URL` | `getEnv` | ✅ | ❌ | ✅ | Events (`fetchEvents` in `eventsClient.ts`) — public ICS feed URL with a token |
| `NOTION_SIGNATORIES_DB_ID` | `getEnv` | ✅ | ✅ | ❌ | E4P pledge/signatories |
| `NOTION_FAQ_DB_ID` | `getEnv` | ✅ | ✅ | ❌ | FAQ |
| `NOTION_IDEAS_DB_ID` | `getEnv` | ✅ | ✅ | ❌ | Ideas |
diff --git a/docs/EVENTS.md b/docs/EVENTS.md
index 3a2b5668..2e593a1a 100644
--- a/docs/EVENTS.md
+++ b/docs/EVENTS.md
@@ -1,74 +1,87 @@
# Events Page Documentation
-The events page (`/events`) displays Tech for Palestine events fetched from a Notion database. See [NOTION.md](NOTION.md) for how this fits into the broader Notion integration, and [ARCHITECTURE.md](ARCHITECTURE.md) for the SSR/islands model these components use.
+The events pages (`/events` and `/events-new`) display Tech for Palestine events fetched from a
+public **ICS calendar feed** (Mattermost's Events Calendar plugin, hosted at
+`chat.techforpalestine.org`). See [ARCHITECTURE.md](ARCHITECTURE.md) for the SSR/islands model
+these components use. Events used to come from Notion — that integration was removed; Notion is
+still used for FAQ, ideas, agenda, signatories, and community calls (see [NOTION.md](NOTION.md)).
## Architecture Overview
```
-Notion Database → API Route → Frontend Component
- ↓ ↓
- /api/events Events.tsx
+ICS Feed → eventsClient.ts → API Route → Frontend Component
+ ↓ ↓
+ /api/events Events.tsx / EventsNew.tsx
```
-## Components
-
-### 1. Events Page (`src/pages/events.astro`)
-
-Entry point that fetches initial events data server-side and renders the Events island with it as props.
-
-```astro
----
-import { fetchNotionEvents } from "../store/notionClient";
-let events = await fetchNotionEvents();
----
-
-
-```
-
-### 2. Events Component (`src/components/Events.tsx`)
-
-React island that handles:
-
-- **Fetch on mount**: if no initial SSR data was passed, fetches `/api/events` (or `/api/events?showAll=yes`) itself.
-- **Manual refresh**: a refresh button re-fetches `/api/events`; there is **no automatic polling** — earlier versions of this doc described a 30-second auto-refresh interval, but the current component has no `setInterval` and only refetches on mount or on user action.
-- **Error handling**: on a failed fetch, the catch block is intentionally silent (documented inline in the component) — the user just keeps seeing whatever events were already loaded.
-- **Change detection** (`hasEventsChanged`): compares event arrays field-by-field so a refresh only triggers a re-render when something actually changed.
-- Responsive card layout with Material-UI, fade-in on first load.
+Both pages group events into categories (by the feed's `CATEGORIES` tags) and split each
+category into upcoming (featured) vs. past events. The grouping logic is shared
+(`src/utils/eventSections.ts`); each page renders it with its own UI.
-### 3. API Route (`src/pages/api/events.ts`)
-
-Astro API endpoint that:
+## Components
-- Fetches events from Notion via `fetchNotionEvents()` in `notionClient.ts`.
-- Returns fresh data without caching (`no-cache, no-store, must-revalidate` headers — also enforced unconditionally by the `cache-control` middleware on all `/api/*` routes, see [ARCHITECTURE.md](ARCHITECTURE.md)).
+### 1. Events Client (`src/store/eventsClient.ts`)
+
+- `fetchEvents(locals?)`: fetches the ICS feed URL from `EVENTS_ICS_URL` (via `getEnv()`),
+ parses it (line unfolding, property parsing, text unescaping — no external ICS library),
+ and returns a flat `EventItem[]` sorted by date descending.
+- Events tagged `testing` in `CATEGORIES` are always dropped.
+- `URL` in the feed is overloaded (map pin for in-person events, registration link for
+ online events) — only known registration hosts (`zoom.us`, `docs.google.com`,
+ `streamyard.com`) are treated as `registerLink`; anything else becomes `locationLink`.
+- Server-side only — never runs in the browser, since the feed URL contains an auth token.
+
+### 2. Category Grouping (`src/utils/eventSections.ts`)
+
+- `SECTION_DEFS`: ordered list of sections — Occupied Tech Podcast, Community Calls,
+ In-Person Events, Roundtable, Book Club — each with the feed tags that route into it.
+- `groupIntoSections(events)`: buckets events into sections by tag (first match wins), splits
+ each into `upcoming`/`past`, and appends a catch-all **Others** section for events whose tags
+ don't match a named section. A named section with zero events is omitted entirely.
+
+### 3. Pages & Components
+
+- **`src/pages/events.astro`** → `src/components/Events.tsx` — Material-UI styled page, one
+ section per category, upcoming events shown before past within each section.
+- **`src/pages/events-new.astro`** → `src/components/events/EventsNew.tsx` — new design-system
+ styled page, one section per category with featured cards for upcoming events and a compact
+ "Past events" list alongside.
+- Both fetch initial data server-side (`fetchEvents(Astro.locals)`) and re-fetch client-side
+ from `/api/events` on mount if no SSR data was passed.
+
+### 4. API Route (`src/pages/api/events.ts`)
+
+- Calls `fetchEvents(locals)`.
+- Returns fresh data without caching (`no-cache, no-store, must-revalidate` headers — also
+ enforced unconditionally by the `cache-control` middleware on all `/api/*` routes, see
+ [ARCHITECTURE.md](ARCHITECTURE.md)).
- Reports errors via `reportError()` and returns a generic 500 on failure.
-### 4. Notion Client (`src/store/notionClient.ts`)
-
-- `fetchNotionEvents(showAll?, locals?)`: queries the events database, filtered on the `Visibility` checkbox property unless `showAll` is true; sorted by date descending.
-- `fetchNotionEventById(pageId, locals?)`: single-event lookup, used by `event-details.astro` → `EventDetails.tsx`.
-- Both map raw Notion page properties (`Title`, `Date of event`, `Stage`, `Type of event`, `Header`, `Description`, `Link to registration`, `Link to recording`) into the flat `EventItem` shape below.
-
## Event Data Structure
```typescript
interface EventItem {
- id: string; // Notion page ID
- title: string; // Event title
- date: string; // ISO date string
- status: string; // "Past" or "Upcoming"
- location: string; // Event type/location
- image: string; // Header image URL (direct Notion URL or default)
- link: string; // Notion page URL
- description?: string; // Event description
- registerLink?: string; // Registration URL
- recordingLink?: string; // Recording URL
+ id: string; // ICS UID
+ title: string; // Event title (SUMMARY)
+ date: string; // "YYYY-MM-DD", local wall-clock date from the feed
+ status: string; // ICS STATUS (usually "CONFIRMED")
+ location: string; // Free-text location; empty if LOCATION is a bare URL
+ locationLink: string; // Map link for in-person events, "" otherwise
+ image: string; // IMAGE/ATTACH URL, or /images/default.jpg
+ link: string; // Best available link (register, then recording)
+ time?: string; // Formatted local time, e.g. "9:00 AM"
+ description?: string; // DESCRIPTION with the trailing Organizer/Tags/Recording/Banner block stripped
+ registerLink?: string; // Registration URL (X-REGISTRATION-URL or a known registration host)
+ recordingLink?: string; // X-RECORDING-URL
+ tags: string[]; // Lowercased CATEGORIES values, used for section grouping
+ dateUtcIso: string | null; // Resolved UTC instant, used for upcoming/past comparisons
}
```
## Image Handling
-Notion images are served as direct Notion-hosted URLs — **there is no image proxy or caching layer** (the earlier Cloudflare Worker image proxy was removed in PR #415). On load error, the frontend falls back to the default image:
+Feed images (`IMAGE`/`ATTACH` properties) are served as direct `chat.techforpalestine.org` URLs
+— there is no proxy/cache layer. On load error, the frontend falls back to the default image:
```tsx
```
-**Notion-hosted image URLs expire after roughly 1 hour** (a Notion/S3 signed-URL limitation). Since there's no proxy/cache anymore, an event page left open for a while, or a cached SSR response, may show broken images until the fallback kicks in or the page is refreshed and re-fetches fresh URLs from Notion.
-
## Environment Variables
```bash
-NOTION_SECRET=secret_xxx
-NOTION_DB_ID=database-id
+EVENTS_ICS_URL=https://chat.techforpalestine.org/plugins/com.techforpalestine.events-calendar/api/v1/public/calendars/by-id//ics?token=
```
+This is a bearer-style secret embedded in the URL — set it via the Cloudflare Pages dashboard
+in production and `.dev.vars` locally (never hardcode it in source or `wrangler.toml`). It's
+resolved through `getEnv()` and only ever fetched server-side.
+
## File Structure
```
src/
├── pages/
-│ ├── events.astro # Events page entry point
-│ ├── event-details.astro # Individual event details
-│ └── api/events.ts # API endpoint
+│ ├── events.astro # Events page entry point (MUI)
+│ ├── events-new.astro # Events page entry point (new design)
+│ └── api/events.ts # API endpoint
├── components/
-│ ├── Events.tsx # Main events component
-│ └── EventDetails.tsx # Event detail component
-└── store/
- └── notionClient.ts # Notion API integration
+│ ├── Events.tsx # MUI events component, category sections
+│ └── events/
+│ └── EventsNew.tsx # New-design events component, category sections
+├── store/
+│ └── eventsClient.ts # ICS feed fetch + parse
+└── utils/
+ ├── eventSections.ts # Category grouping (shared by both pages)
+ └── icalDate.ts # TZID → UTC date resolution (shared with community calls)
```
## Troubleshooting
-### Images Not Loading
+### Events Not Updating
-1. Notion-hosted images expire after ~1 hour — this is a Notion limitation, not a bug.
-2. The component falls back to `/images/default.jpg` on error; a manual refresh re-fetches current URLs from Notion.
+1. There's no background polling — the user must click refresh (MUI page) or reload the page.
+2. Check `EVENTS_ICS_URL` is set and the feed is reachable.
+3. Check browser console / Sentry for fetch failures.
-### Events Not Updating
+### An Event Is In The Wrong Category / Missing
-1. There's no background polling — the user must click refresh or reload the page.
-2. Check Notion API credentials (`NOTION_SECRET`, `NOTION_DB_ID`).
-3. Verify database permissions and the `Visibility` checkbox on the relevant Notion rows.
-4. Check browser console for API errors (fetch failures are caught silently in the UI, so nothing shows on-page).
+- Check the event's `CATEGORIES` tags in the source calendar (Mattermost Events Calendar
+ plugin) against the tag → section mapping in `src/utils/eventSections.ts`.
+- Events tagged `testing` are always dropped, regardless of other tags.
## API Reference
### GET /api/events
-Returns array of event objects sorted by date (newest first).
+Returns a flat array of event objects sorted by date (newest first); pages group them into
+categories client- or server-side via `groupIntoSections`.
**Response:**
```json
[
{
- "id": "notion-page-id",
- "title": "Event Title",
- "date": "2025-07-23",
- "status": "Upcoming",
- "location": "Virtual",
- "image": "https://notion-hosted-url.com/...",
- "description": "Event description",
- "registerLink": "https://register.url",
- "recordingLink": null
+ "id": "dd579e59-53f1-4a9f-9df7-fd09997b092d@events.t4p",
+ "title": "Entrepreneurs for Palestine Official Launch",
+ "date": "2025-02-26",
+ "status": "CONFIRMED",
+ "location": "",
+ "locationLink": "",
+ "image": "https://chat.techforpalestine.org/.../banner",
+ "link": "https://youtu.be/w22SyQY1bwI",
+ "time": "9:00 AM",
+ "description": "Join us for the official launch of T4P's newest initiative...",
+ "registerLink": "",
+ "recordingLink": "https://youtu.be/w22SyQY1bwI",
+ "tags": ["online"],
+ "dateUtcIso": "2025-02-26T08:00:00.000Z"
}
]
```
@@ -149,4 +173,4 @@ Returns array of event objects sorted by date (newest first).
pnpm dev # Start Astro dev server on :4321
```
-Copy `.env.example` to `.env` and fill in `NOTION_SECRET`/`NOTION_DB_ID` before testing locally.
+Set `EVENTS_ICS_URL` in `.dev.vars` before testing locally.
diff --git a/docs/NOTION.md b/docs/NOTION.md
index 69d334e0..74175257 100644
--- a/docs/NOTION.md
+++ b/docs/NOTION.md
@@ -1,10 +1,9 @@
# Notion Integration
-Notion is the CMS for most semi-dynamic content on the site. There are **8 separate Notion databases**, each with its own env var and consuming route(s).
+Notion is the CMS for most semi-dynamic content on the site. There are **7 separate Notion databases**, each with its own env var and consuming route(s). Events used to be a Notion database too — that integration was removed in favor of a public ICS calendar feed; see [EVENTS.md](EVENTS.md).
| Database | Env var (ID) | Fetcher / route | Consumed by |
| -------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
-| Events | `NOTION_DB_ID` | `fetchNotionEvents()` / `fetchNotionEventById()` in `src/store/notionClient.ts`; `/api/events` | `events.astro`, `Events.tsx` — full detail in [EVENTS.md](EVENTS.md) |
| FAQ | `NOTION_FAQ_DB_ID` | `fetchNotionFAQ()`; `/api/faq` | `faq.astro` |
| Ideas | `NOTION_IDEAS_DB_ID` | `fetchNotionIdeas()`; `/api/ideas` | `ideas.astro` |
| Agenda / Speakers | `NOTION_AGENDA_DB_ID` | `fetchNotionAgenda()`; `/api/speakers` | London Gathering agenda page — resolves `Moderator` relation properties against speaker pages in the same DB |
@@ -22,10 +21,10 @@ New Notion-backed features should default to pattern 1 (`notionClient.ts`) for r
## Data shape quirks worth knowing
-- Notion property names are matched by their exact display name in the database (`props["Date of event"]`, `props["Link to registration"]`, etc.) — renaming a property in Notion silently breaks the mapping (returns empty string, no error).
-- Most fetchers default missing/optional fields to `""` rather than `null`/`undefined`, and events specifically fall back to `/images/default.jpg` when no header image is set.
-- Events and FAQ both filter on a `Visibility` checkbox property by default; pass `showAll=yes` as a query param to bypass it (used by admin/preview tooling, not the public pages).
-- **Notion-hosted image URLs expire after roughly 1 hour** (an Notion/AWS S3 signed-URL limitation). The Events page has no server-side proxy/cache for this anymore (see [EVENTS.md](EVENTS.md) — the Cloudflare Worker image proxy was removed in PR #415); the frontend just falls back to the default image on load error.
+- Notion property names are matched by their exact display name in the database (`props["Question"]`, `props["Name"]`, etc.) — renaming a property in Notion silently breaks the mapping (returns empty string, no error).
+- Most fetchers default missing/optional fields to `""` rather than `null`/`undefined`.
+- FAQ filters on a `Visibility` checkbox property by default; pass `showAll=yes` as a query param to bypass it (used by admin/preview tooling, not the public pages).
+- **Notion-hosted image URLs expire after roughly 1 hour** (a Notion/AWS S3 signed-URL limitation) — there is no server-side proxy/cache for Notion-hosted images (the Cloudflare Worker image proxy was removed in PR #415); consumers fall back to a default image on load error.
## Env vars
diff --git a/public/_redirects b/public/_redirects
index 139fc38e..5ce20861 100644
--- a/public/_redirects
+++ b/public/_redirects
@@ -9,4 +9,6 @@
/mentor-application /mentorship 301
/admin/dropped-conversions /admin/conversions 301
/index-new / 301
-/index-new/ / 301
\ No newline at end of file
+/index-new/ / 301
+/event-details /events 301
+/event-details/ /events 301
\ No newline at end of file
diff --git a/src/components/EventDetails.tsx b/src/components/EventDetails.tsx
deleted file mode 100644
index 2a948e53..00000000
--- a/src/components/EventDetails.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { Box, Typography, Card, CardMedia, Chip, Link, CircularProgress } from "@mui/material";
-import LocationOnIcon from "@mui/icons-material/LocationOn";
-import EditCalendarIcon from "@mui/icons-material/EditCalendar";
-import VideoLibraryIcon from "@mui/icons-material/VideoLibrary";
-
-interface EventItem {
- title: string;
- date: string;
- status: string;
- location: string;
- image: string;
- link: string;
- description?: string;
- registerLink?: string;
- recordingLink?: string;
-}
-
-export default function EventDetails() {
- const [event, setEvent] = useState(null);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- const params = new URLSearchParams(window.location.search);
- const id = params.get("id");
-
- if (id) {
- fetch(`/api/event-by-id?id=${id}`)
- .then((res) => res.json())
- .then((data) => setEvent(data))
- .catch((err) => console.error("Failed to fetch event:", err))
- .finally(() => setLoading(false));
- } else {
- setLoading(false);
- }
- }, []);
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- if (!event) {
- return