From c326802e4b38dbd3c24db986dbbc7e9f150bb40e Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 18 Jul 2026 13:48:41 +0300 Subject: [PATCH 1/8] feat(events): migrate events from Notion to ICS calendar feed, redesign category UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Notion-backed events pipeline with a public ICS calendar feed (Mattermost Events Calendar plugin), and rebuilds both /events (MUI) and /events-new (new design system) around category-grouped sections instead of a flat list — each page keeps its own visual language. - src/store/eventsClient.ts: hand-rolled ICS parser (line unfolding, property parsing, text unescaping), dedupes recurring master/override duplicates and same-day "community call" duplicates from the source calendar, classifies links into register/location/watch buckets - src/utils/eventSections.ts: groups events into named categories (In-Person, Occupied Tech Podcast, Community Calls, Roundtable, Book Club, Others) with a title-keyword fallback for untagged events - src/utils/icalDate.ts: shared TZID->UTC resolver, extracted from notionClient.ts's existing implementation (now reused by community calls) - src/utils/eventDescription.tsx: lightweight Markdown-lite renderer (headings, bold, rules, lists, auto-linked URLs) for the popup — renders real React nodes rather than dangerouslySetInnerHTML, since descriptions are externally-sourced text - src/utils/googleCalendarLink.ts / webcal.ts: per-event "Add to calendar" links and a feed-wide "Subscribe to this calendar" link - Both pages: collapsible category sections (default expanded), always feature the 2 most-recent events per category (upcoming or past) with the rest in a capped "Past events" list, a detail popup with a sticky CTA footer instead of inline register/recording links - Removes dead code: event-details.astro/EventDetails.tsx (already broken, fetched a non-existent API route), fetchNotionEvents/ fetchNotionEventById from notionClient.ts - Updates docs (EVENTS.md, NOTION.md, API.md, ENVIRONMENT.md, DEPLOYMENT.md, CLAUDE.md) and adds EVENTS_ICS_URL to .env.example --- .env.example | 4 +- CLAUDE.md | 3 +- DEPLOYMENT.md | 5 +- astro.config.mjs | 1 - docs/API.md | 2 +- docs/ENVIRONMENT.md | 2 +- docs/EVENTS.md | 184 ++++--- docs/NOTION.md | 11 +- public/_redirects | 4 +- src/components/EventDetails.tsx | 124 ----- src/components/Events.tsx | 826 ++++++++++++++++++---------- src/components/events/EventsNew.tsx | 694 ++++++++++++++--------- src/pages/api/events.ts | 9 +- src/pages/event-details.astro | 9 - src/pages/events-new.astro | 21 +- src/pages/events.astro | 16 +- src/store/eventsClient.ts | 386 +++++++++++++ src/store/notionClient.ts | 145 +---- src/utils/eventDescription.tsx | 112 ++++ src/utils/eventSections.ts | 111 ++++ src/utils/googleCalendarLink.ts | 46 ++ src/utils/icalDate.ts | 109 ++++ src/utils/webcal.ts | 5 + 23 files changed, 1877 insertions(+), 952 deletions(-) delete mode 100644 src/components/EventDetails.tsx delete mode 100644 src/pages/event-details.astro create mode 100644 src/store/eventsClient.ts create mode 100644 src/utils/eventDescription.tsx create mode 100644 src/utils/eventSections.ts create mode 100644 src/utils/googleCalendarLink.ts create mode 100644 src/utils/icalDate.ts create mode 100644 src/utils/webcal.ts 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

Event not found.

; - } - - const eventDate = new Date(event.date); - const formattedDate = eventDate.toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }); - const isPast = event.status?.toLowerCase() === "past"; - - return ( - - - - - - - {event.title} - - - - {event.status && ( - - )} - - {event.location} - - {formattedDate} - - - {event.description && ( - - {event.description} - - )} - - - {event.registerLink && ( - - - Register - - )} - {event.recordingLink && ( - - - Recording - - )} - - - ); -} diff --git a/src/components/Events.tsx b/src/components/Events.tsx index d92d40c9..d74e2590 100644 --- a/src/components/Events.tsx +++ b/src/components/Events.tsx @@ -1,89 +1,512 @@ -import React, { useState, useEffect } from "react"; -import { Box, Typography, Card, Chip, Button, Link, CircularProgress, Fade } from "@mui/material"; -import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import React, { createContext, useContext, useState, useEffect } from "react"; +import { + Box, + Typography, + Card, + Button, + Link, + Fade, + IconButton, + Dialog, + DialogContent, + DialogActions, +} from "@mui/material"; import AccessTimeIcon from "@mui/icons-material/AccessTime"; import LocationOnIcon from "@mui/icons-material/LocationOn"; -import EditCalendarIcon from "@mui/icons-material/EditCalendar"; -import VideoLibraryIcon from "@mui/icons-material/VideoLibrary"; -import RefreshIcon from "@mui/icons-material/Refresh"; import EventBusyIcon from "@mui/icons-material/EventBusy"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import EventAvailableIcon from "@mui/icons-material/EventAvailable"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import CloseIcon from "@mui/icons-material/Close"; +import { hasMeaningfulDescription, primaryEventLink, type EventItem } from "../store/eventsClient"; +import { groupIntoSections, type EventSection } from "../utils/eventSections"; +import { buildGoogleCalendarLink } from "../utils/googleCalendarLink"; +import { toWebcalUrl } from "../utils/webcal"; +import { parseEventDescription, renderInlineText } from "../utils/eventDescription"; -interface EventItem { - id: string; - title: string; - date: string; - status: string; - location: string; - image: string; - link: string; - time?: string; - description?: string; - registerLink?: string; - recordingLink?: string; +const PAST_EVENTS_PAGE_SIZE = 5; + +// This page's own accent palette — same structure as the new-design events +// page, different colors to match the site's existing MUI look. +const RED = "#EA4335"; +const RED_HOVER = "#C5341F"; +const GREEN = "#168039"; + +interface SelectedEvent { + event: EventItem; + isPast: boolean; +} + +const EventModalContext = createContext<(selected: SelectedEvent) => void>(() => {}); + +function isEventPast(event: EventItem): boolean { + return !event.dateUtcIso || new Date(event.dateUtcIso).getTime() < Date.now(); } interface EventsProps { events: EventItem[]; loading?: boolean; + icsUrl?: string; } -// Helper function to compare events arrays for changes -const hasEventChanges = (oldEvents: EventItem[], newEvents: EventItem[]): boolean => { - if (oldEvents.length !== newEvents.length) { - return true; - } +interface DateParts { + day: number; + month: string; + year: string; + full: string; +} - // Create maps for efficient lookup - const oldEventMap = new Map(oldEvents.map((event) => [event.id, event])); - const newEventMap = new Map(newEvents.map((event) => [event.id, event])); +const MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; - // Check if any old event has been deleted - for (const oldEvent of oldEvents) { - if (!newEventMap.has(oldEvent.id)) { - return true; // Event was deleted - } - } +function parseDateParts(dateStr: string): DateParts { + const [year, month, day] = dateStr.split("-"); + return { + day: parseInt(day, 10), + month: MONTH_NAMES[parseInt(month, 10) - 1], + year, + full: `${parseInt(day, 10)} ${MONTH_NAMES[parseInt(month, 10) - 1]} ${year}`, + }; +} - // Check if any new event was added or existing event was modified - return newEvents.some((newEvent) => { - const oldEvent = oldEventMap.get(newEvent.id); - if (!oldEvent) { - return true; // New event - } +function SubscribeNote({ icsUrl }: { icsUrl: string }) { + if (!icsUrl) return null; + + return ( + + + Want every new T4P event on your own calendar automatically? + + + + Subscribe to this calendar + + + ); +} + +function EventDescription({ text }: { text: string }) { + const blocks = parseEventDescription(text); + + return ( + + {blocks.map((block, i) => { + if (block.type === "hr") { + return ; + } + if (block.type === "heading") { + return ( + + {renderInlineText(block.text, `h-${i}`)} + + ); + } + if (block.type === "list") { + return ( + + {block.items.map((item, j) => ( + + {renderInlineText(item, `l-${i}-${j}`)} + + ))} + + ); + } + return ( + + {renderInlineText(block.text, `p-${i}`)} + + ); + })} + + ); +} + +function EventDetailsDialog({ + selected, + onClose, +}: { + selected: SelectedEvent | null; + onClose: () => void; +}) { + if (!selected) return null; + const { event, isPast } = selected; + const { day, month, year, full } = parseDateParts(event.date); + const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast); + const calendarLink = isPast ? null : buildGoogleCalendarLink(event); + + return ( + + + {event.title} + + + + + + + + + {event.title} + + + + {full} + {event.time && · {event.time}} + {event.location && · {event.location}} + + + {event.description && } + + {(infoLink || event.locationLink || calendarLink) && ( + + {infoLink && ( + + )} + {event.locationLink && ( + + View location + + )} + {calendarLink && ( + + + Add to calendar + + )} + + )} + + ); +} + +function FeaturedEventCard({ event, isPast }: { event: EventItem; isPast: boolean }) { + const [imgFailed, setImgFailed] = useState(false); + const { day, month, year } = parseDateParts(event.date); + const calendarLink = isPast ? null : buildGoogleCalendarLink(event); + const openModal = useContext(EventModalContext); + const showPopup = hasMeaningfulDescription(event); + const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast); + + return ( + + + {event.title} setImgFailed(true)} + loading="lazy" + decoding="async" + /> + + + + + + {event.title} + + + {(event.time || event.location) && ( + + {event.time && ( + + + {event.time} + + )} + {event.location && ( + + + {event.location} + + )} + + )} + + {event.description && ( + + {event.description} + + )} + + + {showPopup ? ( + + ) : ( + infoLink && ( + + ) + )} + {calendarLink && ( + + + Add to calendar + + )} + + + + ); +} + +function PastEventRow({ event }: { event: EventItem }) { + const { full } = parseDateParts(event.date); + const openModal = useContext(EventModalContext); + const isPast = isEventPast(event); + const showPopup = hasMeaningfulDescription(event); + const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast); + + return ( + + + + {event.title} + + {showPopup ? ( + openModal({ event, isPast })} + className="shrink-0 font-medium hover:underline" + sx={{ color: GREEN }} + > + More info + + ) : infoLink ? ( + + {infoLabel} + + ) : ( + + — + + )} + + + {full} + {event.time && · {event.time}} + {event.location && · {event.location}} + + + ); +} + +function PastEventsList({ events }: { events: EventItem[] }) { + const [expanded, setExpanded] = useState(false); + const visible = expanded ? events : events.slice(0, PAST_EVENTS_PAGE_SIZE); + const remaining = events.length - visible.length; + + return ( +
+ + Past events + +
+ {visible.map((event) => ( + + ))} +
+ {remaining > 0 && ( + + )} +
+ ); +} - // Compare key properties that might change - return ( - oldEvent.title !== newEvent.title || - oldEvent.date !== newEvent.date || - oldEvent.status !== newEvent.status || - oldEvent.location !== newEvent.location || - oldEvent.description !== newEvent.description || - oldEvent.registerLink !== newEvent.registerLink || - oldEvent.recordingLink !== newEvent.recordingLink || - oldEvent.image !== newEvent.image - ); - }); -}; +const FEATURED_COUNT = 2; + +function EventSectionBlock({ section }: { section: EventSection }) { + const { def, upcoming, past } = section; + const [expanded, setExpanded] = useState(true); + + // Always feature the most-recent events (soonest-upcoming first, filling + // any remaining slots from the latest past events) rather than only ever + // spotlighting upcoming ones — most categories are past-heavy. + const featuredUpcoming = upcoming.slice(0, FEATURED_COUNT); + const featuredPast = past.slice(0, FEATURED_COUNT - featuredUpcoming.length); + const featured = [ + ...featuredUpcoming.map((event) => ({ event, isPast: false })), + ...featuredPast.map((event) => ({ event, isPast: true })), + ]; + const rest = [...upcoming.slice(featuredUpcoming.length), ...past.slice(featuredPast.length)]; + + return ( + + + + + {def.title} + + + {def.subtitle} + + + setExpanded((prev) => !prev)} + aria-expanded={expanded} + aria-label={expanded ? "Collapse section" : "Expand section"} + className="shrink-0" + sx={{ + backgroundColor: "grey.100", + "&:hover": { backgroundColor: RED, color: "#fff" }, + }} + > + + + + + {expanded && ( +
0 + ? "mt-8 grid gap-8 min-[900px]:grid-cols-[minmax(0,1fr)_minmax(280px,340px)]" + : "mt-8" + } + > +
+ {featured.map(({ event, isPast }) => ( + + ))} +
+ + {rest.length > 0 && } +
+ )} +
+ ); +} export default function Events({ events: initialEvents, loading: initialLoading = false, + icsUrl = "", }: EventsProps) { const [events, setEvents] = useState(initialEvents); const [loading, setLoading] = useState(initialLoading); - const [lastUpdated, setLastUpdated] = useState(new Date()); const [showEvents, setShowEvents] = useState(initialEvents.length > 0); + const [selectedEvent, setSelectedEvent] = useState(null); - const urlSearchParams = new URLSearchParams(window.location.search); - const params = Object.fromEntries(urlSearchParams.entries()); - const showAll = params?.showAll === "yes"; - - // Function to fetch fresh events (for refresh button) - const fetchFreshEvents = async () => { - const wasRefreshing = events.length > 0; // Track if we're refreshing existing events + const fetchEvents = async () => { setLoading(true); try { - const response = await fetch(showAll ? "/api/events?showAll=yes" : "/api/events", { + const response = await fetch("/api/events", { cache: "no-cache", headers: { "Cache-Control": "no-cache", @@ -92,38 +515,24 @@ export default function Events({ }); if (response.ok) { - const newEvents = await response.json(); + const newEvents: EventItem[] = await response.json(); setEvents(newEvents); - setLastUpdated(new Date()); - if (newEvents.length > 0) { - // Only trigger fade-in if this is initial load, not a refresh - if (!wasRefreshing) { - setShowEvents(true); - } - } else { - setShowEvents(false); - } + setShowEvents(newEvents.length > 0); } - } catch (error) { + } catch { /* * intentional silent error handling: user sees existing/cached events on fetch failure. * console.error provides no value in production at this stage; users don't * see it, monitoring systems don't capture it; it's purely a debugging tool. - * if error visibility is needed, potential implementation suggestions could include: - * - user-facing feedback: Material-UI Snackbar/Alert component - * - production monitoring: Sentry/LogRocket integration in catch block - * - error state management: const [error, setError] = useState(null) - * (although I really do not like using useState for this, or at all) */ } finally { setLoading(false); } }; - // Only fetch fresh events if we don't have initial data useEffect(() => { if (initialEvents.length === 0 && initialLoading) { - fetchFreshEvents(); + fetchEvents(); } else { setLoading(false); if (initialEvents.length > 0) { @@ -132,220 +541,61 @@ export default function Events({ } }, []); - // Debug logging for state changes - useEffect(() => { - console.log("Loading state changed:", loading); - }, [loading]); - - useEffect(() => { - console.log("Events state changed:", { - count: events.length, - firstEventTitle: events[0]?.title, - }); - }, [events]); + const sections = groupIntoSections(events); return ( -
- {/* Status indicator */} - - - Last updated: {lastUpdated.toLocaleTimeString()} - - - - - + +
+ {loading && events.length === 0 && ( +
+ {[...Array(3)].map((_, index) => ( + +
+
+
+
+ ))} +
+ )} - {/* Skeleton loading state */} - {loading && events.length === 0 && ( -
- {[...Array(3)].map((_, index) => ( - -
-
-
+ {!loading && events.length === 0 && ( + + + + + No events found + + + There are no events available at the moment. Check back later! + - ))} -
- )} + + )} - {/* Empty state */} - {!loading && events.length === 0 && ( - - - - - No events found - - - There are no events available at the moment. Check back later! - - - - )} - - {/* Events list with fade-in animation */} - {events.length > 0 && ( - -
- {events.map((event, i) => { - // Parse date string without timezone conversion - const [year, month, day] = event.date.split("-"); - const monthNames = [ - "JAN", - "FEB", - "MAR", - "APR", - "MAY", - "JUN", - "JUL", - "AUG", - "SEP", - "OCT", - "NOV", - "DEC", - ]; - const monthName = monthNames[parseInt(month) - 1]; - const dayNum = parseInt(day); - - // Automatically determine if event is past or upcoming based on date - const eventDate = new Date(event.date); - const today = new Date(); - today.setHours(0, 0, 0, 0); - const isPast = eventDate < today; - const autoStatus = isPast ? "Past" : "Upcoming"; - - return ( - - {/* Image */} - - {event.title} { - const target = e.target as HTMLImageElement; - if (target.src !== "/images/default.jpg") { - target.src = "/images/default.jpg"; - } - }} - /> - - - {/* Content */} - - - - {event.title} - - - - - {/* Meta */} - - - - {monthName} {dayNum}, {year} - - {event.time && ( - - - {event.time} - - )} - {event.location && ( - - - {event.location} - - )} - - - {/* Description */} - {event.description && ( - - {event.description} - - )} - - {/* Footer */} - - - {event.registerLink && !isPast && ( - e.stopPropagation()} - className="flex items-center font-medium text-[#EA4335] hover:underline" - > - - Register - - )} - {event.recordingLink && ( - e.stopPropagation()} - className="flex items-center font-medium text-[#168039] hover:underline" - > - - Recording - - )} - - - - - ); - })} -
-
- )} -
+ {events.length > 0 && ( + +
+ +
+ {sections.map((section) => ( + + ))} +
+
+
+ )} +
+ setSelectedEvent(null)} /> + ); } diff --git a/src/components/events/EventsNew.tsx b/src/components/events/EventsNew.tsx index d469431e..b2055da6 100644 --- a/src/components/events/EventsNew.tsx +++ b/src/components/events/EventsNew.tsx @@ -1,17 +1,21 @@ -import React, { useEffect, useState } from "react"; - -interface EventItem { - id: string; - title: string; - date: string; // "YYYY-MM-DD" format - status: string; - location: string; - image: string; - link: string; - time?: string; - description?: string; - registerLink?: string; - recordingLink?: string; +import React, { createContext, useContext, useEffect, useState } from "react"; +import { hasMeaningfulDescription, primaryEventLink, type EventItem } from "../../store/eventsClient"; +import { groupIntoSections, type EventSection } from "../../utils/eventSections"; +import { buildGoogleCalendarLink } from "../../utils/googleCalendarLink"; +import { toWebcalUrl } from "../../utils/webcal"; +import { parseEventDescription, renderInlineText } from "../../utils/eventDescription"; + +const PAST_EVENTS_PAGE_SIZE = 5; + +interface SelectedEvent { + event: EventItem; + isPast: boolean; +} + +const EventModalContext = createContext<(selected: SelectedEvent) => void>(() => {}); + +function isEventPast(event: EventItem): boolean { + return !event.dateUtcIso || new Date(event.dateUtcIso).getTime() < Date.now(); } interface DateParts { @@ -64,68 +68,78 @@ function ArrowRight({ size = 16 }: { size?: number }) { ); } -function SectionLabel({ - accent = false, - children, -}: { - accent?: boolean; - children: React.ReactNode; -}) { +function ChevronDown({ size = 18, expanded }: { size?: number; expanded: boolean }) { return ( -
-

- {children} -

-
-
+ + ); +} + +function CalendarPlus({ size = 15 }: { size?: number }) { + return ( + + ); +} + +function CloseIcon({ size = 18 }: { size?: number }) { + return ( + ); } function LoadingSkeleton() { return (
- {/* Hero skeleton */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+
+
+
-
-
- - {/* Archive row skeletons */} -
-
-
-
-
-
-
- {Array.from({ length: 2 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} -
-
-
+
+ ))}
); } @@ -138,18 +152,200 @@ function EmptyState() { ); } -interface HeroEventProps { +interface SubscribeNoteProps { + icsUrl: string; +} + +function SubscribeNote({ icsUrl }: SubscribeNoteProps) { + if (!icsUrl) return null; + + return ( +
+
+

+ Want every new T4P event on your own calendar automatically? +

+ + + Subscribe to this calendar + +
+
+ ); +} + +function EventDescription({ text }: { text: string }) { + const blocks = parseEventDescription(text); + + return ( +
+ {blocks.map((block, i) => { + if (block.type === "hr") return
; + if (block.type === "heading") { + return ( +

+ {renderInlineText(block.text, `h-${i}`)} +

+ ); + } + if (block.type === "list") { + const ListTag = block.ordered ? "ol" : "ul"; + return ( + + {block.items.map((item, j) => ( +
  • {renderInlineText(item, `l-${i}-${j}`)}
  • + ))} +
    + ); + } + return

    {renderInlineText(block.text, `p-${i}`)}

    ; + })} +
    + ); +} + +interface EventModalProps { + selected: SelectedEvent; + onClose: () => void; +} + +function EventModal({ selected, onClose }: EventModalProps) { + const { event, isPast } = selected; + const { day, month, year, full } = parseDateParts(event.date); + const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast); + const calendarLink = isPast ? null : buildGoogleCalendarLink(event); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKeyDown); + + // This site's pages scroll via Lenis (window.__lenis), which intercepts + // wheel/touch events itself — CSS `overflow: hidden` on html/body alone + // doesn't stop it. Stop Lenis while the modal is open, same pattern as + // ProjectDrawer.tsx. + const lenis = (window as any).__lenis; + lenis?.stop(); + + return () => { + document.removeEventListener("keydown", onKeyDown); + lenis?.start(); + }; + }, [onClose]); + + return ( +
    +
    e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={event.title} + > +
    +
    + {event.title} + +
    + +
    + + +

    {event.title}

    + +

    + {full} + {event.time && · {event.time}} + {event.location && · {event.location}} +

    + + {event.description && } +
    +
    + + {(infoLink || event.locationLink || calendarLink) && ( +
    + {infoLink && ( + + {infoLabel} + + + )} + {event.locationLink && ( + + View location + + )} + {calendarLink && ( + + + Add to calendar + + )} +
    + )} +
    +
    + ); +} + +interface FeaturedCardProps { event: EventItem; + isPast: boolean; } -function HeroEvent({ event }: HeroEventProps) { +function FeaturedCard({ event, isPast }: FeaturedCardProps) { const [imgFailed, setImgFailed] = useState(false); const { day, month, year } = parseDateParts(event.date); + const calendarLink = isPast ? null : buildGoogleCalendarLink(event); + const openModal = useContext(EventModalContext); + const showPopup = hasMeaningfulDescription(event); + const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast); return ( -
    - {/* Image side */} -
    +
    +
    {event.title}
    - - {/* Content side */} -
    - {/* Date block */} +
    - {/* Title */} -

    {event.title}

    +

    {event.title}

    - {/* Meta */} {(event.time || event.location) && (

    {event.time && {event.time}} @@ -182,247 +373,230 @@ function HeroEvent({ event }: HeroEventProps) {

    )} - {/* Description */} {event.description && ( -

    - {event.description} -

    +

    {event.description}

    )} - {/* Register CTA */} - {event.registerLink && ( -
    +
    + {showPopup ? ( + + ) : ( + infoLink && ( + + {infoLabel} + + + ) + )} + {calendarLink && ( - Register - + + Add to calendar -
    - )} + )} +
    -
    +
    ); } -interface LineupTileProps { +interface PastEventRowProps { event: EventItem; } -function LineupTile({ event }: LineupTileProps) { - const [imgFailed, setImgFailed] = useState(false); - const { day, month, year } = parseDateParts(event.date); +function PastEventRow({ event }: PastEventRowProps) { + const { full } = parseDateParts(event.date); + const openModal = useContext(EventModalContext); + const isPast = isEventPast(event); + const showPopup = hasMeaningfulDescription(event); + const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast); return ( -
    - {/* Image */} -
    - {event.title} setImgFailed(true)} - loading="lazy" - decoding="async" - /> -
    - {/* Content */} -
    - {/* Date */} - - - {/* Title */} -

    {event.title}

    - - {/* Location */} - {event.location &&

    {event.location}

    } - - {/* Register link */} - {event.registerLink && ( +
    +
    +

    {event.title}

    + {showPopup ? ( + + ) : infoLink ? ( - Register - + {infoLabel} + ) : ( + )}
    -
    +

    + {full} + {event.time && · {event.time}} + {event.location && · {event.location}} +

    +
    ); } -interface LineupStripProps { +interface PastEventsListProps { events: EventItem[]; } -function LineupStrip({ events }: LineupStripProps) { - return ( - <> - {/* Mobile: horizontal scroll */} -
    - {events.map((event) => ( - - ))} -
    +function PastEventsList({ events }: PastEventsListProps) { + const [expanded, setExpanded] = useState(false); + const visible = expanded ? events : events.slice(0, PAST_EVENTS_PAGE_SIZE); + const remaining = events.length - visible.length; - {/* Tablet+: grid */} -
    - {events.map((event) => ( - + return ( +
    +

    Past events

    +
    + {visible.map((event) => ( + ))}
    - + {remaining > 0 && ( + + )} +
    ); } -function PastEventCard({ event }: { event: EventItem }) { - const [imgFailed, setImgFailed] = useState(false); - const { full } = parseDateParts(event.date); +interface EventSectionBlockProps { + section: EventSection; +} + +const FEATURED_COUNT = 2; + +function EventSectionBlock({ section }: EventSectionBlockProps) { + const { def, upcoming, past } = section; + const [expanded, setExpanded] = useState(true); + + // Always feature the most-recent events (soonest-upcoming first, filling + // any remaining slots from the latest past events) rather than only ever + // spotlighting upcoming ones — most categories are past-heavy. + const featuredUpcoming = upcoming.slice(0, FEATURED_COUNT); + const featuredPast = past.slice(0, FEATURED_COUNT - featuredUpcoming.length); + const featured = [ + ...featuredUpcoming.map((event) => ({ event, isPast: false })), + ...featuredPast.map((event) => ({ event, isPast: true })), + ]; + const rest = [...upcoming.slice(featuredUpcoming.length), ...past.slice(featuredPast.length)]; return ( -
    -
    -
    - {event.title} setImgFailed(true)} - loading="lazy" - decoding="async" - /> -
    -
    +
    +
    +
    - - Past - +

    {def.title}

    +

    {def.subtitle}

    -

    {event.title}

    -
    - - {event.time && {event.time}} - {event.location && ( - {event.location} - )} -
    - {event.description && ( -

    {event.description}

    - )} - {event.recordingLink && ( - - )} +
    + + {expanded && ( +
    0 + ? "mt-8 grid gap-8 min-[900px]:grid-cols-[minmax(0,1fr)_minmax(280px,340px)]" + : "mt-8" + } + > +
    + {featured.map(({ event, isPast }) => ( + + ))} +
    + + {rest.length > 0 && } +
    + )}
    -
    + ); } interface EventsNewProps { initialEvents?: EventItem[]; + icsUrl?: string; } -export default function EventsNew({ initialEvents = [] }: EventsNewProps) { +export default function EventsNew({ initialEvents = [], icsUrl = "" }: EventsNewProps) { const [events, setEvents] = useState(initialEvents); const [loading, setLoading] = useState(initialEvents.length === 0); - - const showAll = - typeof window !== "undefined" - ? new URLSearchParams(window.location.search).get("showAll") === "yes" - : false; + const [selectedEvent, setSelectedEvent] = useState(null); useEffect(() => { - if (!showAll && initialEvents.length > 0) return; + if (initialEvents.length > 0) return; setLoading(true); - fetch(showAll ? "/api/events?showAll=yes" : "/api/events", { cache: "no-cache" }) + fetch("/api/events", { cache: "no-cache" }) .then((r) => (r.ok ? r.json() : [])) .then((data: EventItem[]) => setEvents(data)) .finally(() => setLoading(false)); }, []); - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const upcoming = events.filter((e) => new Date(e.date) >= today); - const past = events.filter((e) => new Date(e.date) < today); - - const heroEvent = upcoming[0] ?? null; - const lineupEvents = upcoming.slice(1); + const sections = groupIntoSections(events); return ( -
    - {loading ? ( - - ) : events.length === 0 ? ( - - ) : ( - <> - {/* Tier 1 — Hero */} - {heroEvent && ( -
    -
    - Upcoming - -
    -
    - )} - - {/* Tier 2 — Lineup strip */} - {lineupEvents.length > 0 && ( -
    -
    -

    Also upcoming

    - -
    -
    - )} - - {/* Tier 3 — Archive */} - {past.length > 0 && ( -
    -
    - Past -
    - {past.map((event) => ( - - ))} -
    -
    -
    - )} - + +
    + {loading ? ( + + ) : events.length === 0 ? ( + + ) : ( + <> + + {sections.map((section) => ( + + ))} + + )} +
    + {selectedEvent && ( + setSelectedEvent(null)} /> )} -
    + ); } diff --git a/src/pages/api/events.ts b/src/pages/api/events.ts index 837f1e5c..af1e8745 100644 --- a/src/pages/api/events.ts +++ b/src/pages/api/events.ts @@ -1,17 +1,14 @@ import type { APIRoute } from "astro"; import * as Sentry from "@sentry/astro"; -import { fetchNotionEvents } from "../../store/notionClient"; +import { fetchEvents } from "../../store/eventsClient"; import { reportError } from "../../lib/report-error"; export const prerender = false; -export const GET: APIRoute = async ({ request, locals }) => { +export const GET: APIRoute = async ({ locals }) => { const ctx = locals.runtime?.ctx; try { - const url = new URL(request.url); - const showAll = url.searchParams.get("showAll") === "yes"; - - const events = await fetchNotionEvents(showAll, locals); + const events = await fetchEvents(locals); return new Response(JSON.stringify(events), { status: 200, diff --git a/src/pages/event-details.astro b/src/pages/event-details.astro deleted file mode 100644 index 2357c953..00000000 --- a/src/pages/event-details.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -import Layout from "../layouts/Layout.astro"; -import EventDetailsComponent from "../components/EventDetails"; -import "../styles/base.css"; ---- - - - - diff --git a/src/pages/events-new.astro b/src/pages/events-new.astro index ce959d9e..623bd67d 100644 --- a/src/pages/events-new.astro +++ b/src/pages/events-new.astro @@ -1,28 +1,15 @@ --- import { getEnv } from "../utils/getEnv"; -import { fetchNotionEvents } from "../store/notionClient"; +import { fetchEvents, type EventItem } from "../store/eventsClient"; import HomeLayout from "../layouts/HomeLayout.astro"; import HomeNavbar from "../components/home/HomeNavbar.astro"; import Button from "../components/ui/Button.astro"; import EventsNewComponent from "../components/events/EventsNew"; const membershipLive = getEnv("MEMBERSHIP_LIVE", Astro.locals) === "true"; +const icsUrl = getEnv("EVENTS_ICS_URL", Astro.locals) || ""; -interface EventItem { - id: string; - title: string; - date: string; - status: string; - location: string; - image: string; - link: string; - time?: string; - description?: string; - registerLink?: string; - recordingLink?: string; -} - -const events: EventItem[] = await fetchNotionEvents(false, Astro.locals).catch(() => []); +const events: EventItem[] = await fetchEvents(Astro.locals).catch(() => []); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -82,7 +69,7 @@ const eventSchema = nextEvent && {
    - +
    diff --git a/src/pages/events.astro b/src/pages/events.astro index e046d5da..924f270d 100644 --- a/src/pages/events.astro +++ b/src/pages/events.astro @@ -3,21 +3,15 @@ import Layout from "../layouts/Layout.astro"; import EventsComponent from "../components/Events.tsx"; import "../styles/base.css"; import PageHeader from "../components/PageHeader.astro"; +import type { EventItem } from "../store/eventsClient"; +import { getEnv } from "../utils/getEnv"; -let events: any[] = []; +let events: EventItem[] = []; let loading: boolean = true; // Skip SSR fetch for now - let client handle it -// TODO: Re-enable SSR once we fix the 30-second delay -// try { -// events = await fetchNotionEvents(); -// console.log(`SSR: Loaded ${events.length} events for initial page load`); -// loading = false; -// } catch (err) { -// loading = true; -// console.error("SSR: Error fetching events:", err); -// } +const icsUrl = getEnv("EVENTS_ICS_URL", Astro.locals) || ""; --- - + diff --git a/src/store/eventsClient.ts b/src/store/eventsClient.ts new file mode 100644 index 00000000..a9583a4f --- /dev/null +++ b/src/store/eventsClient.ts @@ -0,0 +1,386 @@ +import { getEnv } from "../utils/getEnv"; +import { resolveIcsDateTimeToUtcIso } from "../utils/icalDate"; +import { reportError } from "../lib/report-error"; + +export interface EventItem { + id: string; + title: string; + date: string; // "YYYY-MM-DD", local wall-clock date from the feed + status: string; + location: string; + locationLink: string; // map link for in-person events, "" otherwise + watchLink: string; // online join/stream link (e.g. a YouTube channel), "" otherwise + image: string; + link: string; + time?: string; + description?: string; + registerLink?: string; + recordingLink?: string; + tags: string[]; + dateUtcIso: string | null; // instant used for upcoming/past comparisons; null if unresolvable + endUtcIso: string | null; // event end instant; falls back to start + 1hr if DTEND is missing +} + +const REGISTRATION_HOSTS = ["zoom.us", "docs.google.com", "streamyard.com"]; +const MAP_HOSTS = ["openstreetmap.org", "maps.google.com", "goo.gl"]; + +const DROPPED_TAGS = new Set(["testing"]); + +interface RawProperty { + name: string; + params: Record; + value: string; +} + +// ICS folds long lines: a continuation line starts with a single space or tab +// and must be joined to the previous line (with the leading whitespace stripped). +function unfoldLines(raw: string): string[] { + const lines = raw.split(/\r\n|\n|\r/); + const unfolded: string[] = []; + for (const line of lines) { + if ((line.startsWith(" ") || line.startsWith("\t")) && unfolded.length > 0) { + unfolded[unfolded.length - 1] += line.slice(1); + } else if (line.length > 0) { + unfolded.push(line); + } + } + return unfolded; +} + +// Splits "NAME;PARAM=VAL;PARAM2=VAL2:value:with:colons" into name/params/value. +// The delimiter between the property head and its value is the first colon +// that isn't inside a double-quoted parameter value. +function parsePropertyLine(line: string): RawProperty | null { + let inQuotes = false; + let colonIndex = -1; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') inQuotes = !inQuotes; + if (ch === ":" && !inQuotes) { + colonIndex = i; + break; + } + } + if (colonIndex === -1) return null; + + const head = line.slice(0, colonIndex); + const value = line.slice(colonIndex + 1); + const [name, ...paramParts] = head.split(";"); + + const params: Record = {}; + for (const part of paramParts) { + const eqIndex = part.indexOf("="); + if (eqIndex === -1) continue; + const key = part.slice(0, eqIndex).toUpperCase(); + const val = part.slice(eqIndex + 1).replace(/^"|"$/g, ""); + params[key] = val; + } + + return { name: name.toUpperCase(), params, value }; +} + +function unescapeIcsText(value: string): string { + return value + .replace(/\\n/gi, "\n") + .replace(/\\,/g, ",") + .replace(/\\;/g, ";") + .replace(/\\\\/g, "\\"); +} + +function isUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +// The feed appends an "Organizer: \nTags: ...\nRecording: ...\nBanner: ..." +// metadata block to every description, sometimes with no blank line before it +// (some events have no other body text at all, so it's the very first line) — +// match per-line rather than relying on a fixed "\n\nOrganizer:" prefix so the +// internal username never leaks into what's shown publicly. +function stripDescriptionMetadata(description: string): string { + const lines = description.split("\n"); + const organizerIndex = lines.findIndex((line) => /^Organizer:/i.test(line.trim())); + const kept = organizerIndex === -1 ? lines : lines.slice(0, organizerIndex); + return kept.join("\n").trim(); +} + +// Organizers often write a "Register here: " (sometimes with the URL on +// its own following line) directly in the description body, duplicating the +// same URL already surfaced as the structured registerLink/CTA button — +// strip it out so it doesn't show twice in the popup. +const REGISTER_LABEL_RE = /^(register|rsvp|sign\s*up)(\s+here)?:?\s*(https?:\/\/\S+)?\s*$/i; +const BARE_URL_LINE_RE = /^https?:\/\/\S+$/i; + +function stripInlineRegistrationLines(description: string): string { + const lines = description.split("\n"); + const kept: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!REGISTER_LABEL_RE.test(line)) { + kept.push(lines[i]); + continue; + } + // Label-only line ("Register here:") with the URL on the next line — + // consume that line too. + const hasInlineUrl = /https?:\/\//i.test(line); + if (!hasInlineUrl && i + 1 < lines.length && BARE_URL_LINE_RE.test(lines[i + 1].trim())) { + i++; + } + } + + return kept.join("\n"); +} + +function extractImage(properties: RawProperty[]): string { + const image = properties.find((p) => p.name === "IMAGE"); + if (image?.value) return image.value; + const attach = properties.find((p) => p.name === "ATTACH"); + if (attach?.value) return attach.value; + return "/images/default.jpg"; +} + +function matchesHost(url: string, hosts: string[]): boolean { + try { + const host = new URL(url).hostname.replace(/^www\./, ""); + return hosts.some((h) => host === h || host.endsWith(`.${h}`)); + } catch { + return false; + } +} + +function resolveLinks(properties: RawProperty[]): { + registerLink: string; + locationLink: string; + watchLink: string; +} { + const registrationProp = properties.find((p) => p.name === "X-REGISTRATION-URL"); + if (registrationProp?.value) { + return { registerLink: registrationProp.value, locationLink: "", watchLink: "" }; + } + + const urlValue = properties.find((p) => p.name === "URL")?.value || ""; + if (!urlValue) return { registerLink: "", locationLink: "", watchLink: "" }; + + if (matchesHost(urlValue, REGISTRATION_HOSTS)) { + return { registerLink: urlValue, locationLink: "", watchLink: "" }; + } + if (matchesHost(urlValue, MAP_HOSTS)) { + return { registerLink: "", locationLink: urlValue, watchLink: "" }; + } + + // Not a registration link or a map pin — this is almost always an online + // event's join/watch link (e.g. a YouTube channel), not a physical + // location, even though the feed reuses LOCATION/URL for it. + return { registerLink: "", locationLink: "", watchLink: urlValue }; +} + +function formatLocalTime(timePart: string): string { + const hour = Number(timePart.slice(0, 2)); + const minute = timePart.slice(2, 4); + const period = hour >= 12 ? "PM" : "AM"; + const hour12 = hour % 12 === 0 ? 12 : hour % 12; + return `${hour12}:${minute} ${period}`; +} + +function parseVEvent(block: string[]): EventItem | null { + const properties: RawProperty[] = []; + for (const line of block) { + const parsed = parsePropertyLine(line); + if (parsed) properties.push(parsed); + } + + const get = (name: string) => properties.find((p) => p.name === name); + + const uid = get("UID")?.value; + const summary = get("SUMMARY")?.value; + const dtstart = get("DTSTART"); + if (!uid || !summary || !dtstart) return null; + + const title = unescapeIcsText(summary); + + const tzid = dtstart.params["TZID"]; + const isAllDay = !dtstart.value.includes("T"); + let date = ""; + let time: string | undefined; + if (!isAllDay) { + const [rawDate, rawTime] = dtstart.value.replace("Z", "").split("T"); + date = `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`; + time = formatLocalTime(rawTime); + } else { + date = `${dtstart.value.slice(0, 4)}-${dtstart.value.slice(4, 6)}-${dtstart.value.slice(6, 8)}`; + } + + const dateUtcIso = resolveIcsDateTimeToUtcIso(dtstart.value, tzid); + + const dtend = get("DTEND"); + const endUtcIso = dtend + ? resolveIcsDateTimeToUtcIso(dtend.value, dtend.params["TZID"]) + : dateUtcIso + ? new Date(new Date(dateUtcIso).getTime() + 60 * 60 * 1000).toISOString() + : null; + + const rawLocation = get("LOCATION")?.value || ""; + const location = unescapeIcsText(isUrl(rawLocation) ? "" : rawLocation); + + const rawDescription = get("DESCRIPTION")?.value || ""; + const description = stripDescriptionMetadata( + stripInlineRegistrationLines(unescapeIcsText(rawDescription)) + ); + + const categories = get("CATEGORIES")?.value || ""; + const tags = categories + .split(",") + .map((t) => unescapeIcsText(t).trim().toLowerCase()) + .filter(Boolean); + + const recordingLink = get("X-RECORDING-URL")?.value || ""; + const image = extractImage(properties); + const { registerLink, locationLink, watchLink } = resolveLinks(properties); + const status = get("STATUS")?.value || ""; + + return { + id: uid, + title, + date, + status, + location, + locationLink, + watchLink, + image, + link: registerLink || recordingLink || "", + time, + description, + registerLink, + recordingLink, + tags, + dateUtcIso, + endUtcIso, + }; +} + +function parseIcsCalendar(raw: string): EventItem[] { + const lines = unfoldLines(raw); + const parsed: { event: EventItem; isOverride: boolean }[] = []; + let currentBlock: string[] | null = null; + + for (const line of lines) { + if (line === "BEGIN:VEVENT") { + currentBlock = []; + continue; + } + if (line === "END:VEVENT") { + if (currentBlock) { + const event = parseVEvent(currentBlock); + if (event && !event.tags.some((t) => DROPPED_TAGS.has(t))) { + const isOverride = currentBlock.some((l) => l.startsWith("RECURRENCE-ID")); + parsed.push({ event, isOverride }); + } + } + currentBlock = null; + continue; + } + if (currentBlock) currentBlock.push(line); + } + + // The feed sometimes includes both a recurring master VEVENT and an + // explicit RECURRENCE-ID override for that same occurrence (e.g. the + // master's own first instance) — dedupe by (uid, start instant), + // preferring the override since it carries any edits for that date. + const byOccurrence = new Map(); + for (const entry of parsed) { + const key = `${entry.event.id}|${entry.event.dateUtcIso}`; + const existing = byOccurrence.get(key); + if (!existing || (entry.isOverride && !existing.isOverride)) { + byOccurrence.set(key, entry); + } + } + + return dedupeCommunityCallDuplicates(Array.from(byOccurrence.values()).map((entry) => entry.event)); +} + +const COMMUNITY_CALL_TITLE_RE = /^(t4p\s+)?(monthly\s+)?community\s+(monthly\s+)?call/i; + +function isCommunityCallTitle(title: string): boolean { + return COMMUNITY_CALL_TITLE_RE.test(title.trim()); +} + +function eventRichness(event: EventItem): number { + let score = 0; + if (event.tags.length > 0) score++; + if (event.recordingLink) score++; + if (event.description) score++; + if (event.image !== "/images/default.jpg") score++; + return score; +} + +// The source calendar sometimes carries a bare recurring placeholder +// ("Community monthly call", almost no content) alongside a separate, +// richer one-off event for the same month ("Community monthly call July +// 2025") — same calendar day, different time. Keep only the richer one. +function dedupeCommunityCallDuplicates(events: EventItem[]): EventItem[] { + const byDay = new Map(); + const rest: EventItem[] = []; + + for (const event of events) { + if (!isCommunityCallTitle(event.title)) { + rest.push(event); + continue; + } + const existing = byDay.get(event.date); + if (!existing || eventRichness(event) > eventRichness(existing)) { + byDay.set(event.date, event); + } + } + + return [...rest, ...byDay.values()]; +} + +// The best single call-to-action link + label for an event, in priority +// order. Shared by both event pages' detail popups. +// Below this length a description is basically just a placeholder (e.g. +// "In this call we discuss T4P updates!") — not worth a popup of its own. +const MEANINGFUL_DESCRIPTION_MIN_LENGTH = 80; + +export function hasMeaningfulDescription(event: EventItem): boolean { + return (event.description?.trim().length ?? 0) >= MEANINGFUL_DESCRIPTION_MIN_LENGTH; +} + +export function primaryEventLink(event: EventItem, isPast: boolean): { link: string; label: string } { + if (isPast) { + // Registration is closed once an event is over — never offer it here, + // even if the feed still has a registerLink set. + if (event.recordingLink) return { link: event.recordingLink, label: "Watch recording" }; + if (event.watchLink) return { link: event.watchLink, label: "Watch online" }; + return { link: "", label: "" }; + } + if (event.registerLink) return { link: event.registerLink, label: "Register" }; + if (event.watchLink) return { link: event.watchLink, label: "Watch online" }; + if (event.recordingLink) return { link: event.recordingLink, label: "Watch recording" }; + return { link: "", label: "" }; +} + +export async function fetchEvents(locals?: any): Promise { + const icsUrl = getEnv("EVENTS_ICS_URL", locals); + + if (!icsUrl) { + throw new Error("Missing events feed configuration: EVENTS_ICS_URL is required"); + } + + try { + const response = await fetch(icsUrl); + if (!response.ok) { + throw new Error(`Events feed request failed with status ${response.status}`); + } + const raw = await response.text(); + const events = parseIcsCalendar(raw); + + return events.sort((a, b) => { + const aTime = a.dateUtcIso ? new Date(a.dateUtcIso).getTime() : 0; + const bTime = b.dateUtcIso ? new Date(b.dateUtcIso).getTime() : 0; + return bTime - aTime; + }); + } catch (error) { + reportError(error, { context: "events-ics-fetch" }); + throw new Error("Failed to fetch events"); + } +} diff --git a/src/store/notionClient.ts b/src/store/notionClient.ts index 023f84ce..e6360e72 100644 --- a/src/store/notionClient.ts +++ b/src/store/notionClient.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { getEnv } from "../utils/getEnv.js"; import { sanitizeUrl } from "../components/projects/projectData"; +import { resolveDateToUtcIso } from "../utils/icalDate"; // Helper function to create Notion axios instance with runtime environment variables function createNotionAxios(secret: string) { @@ -43,154 +44,10 @@ function richText(prop: NotionRichTextProperty | undefined, fallback = ""): stri return prop?.rich_text?.[0]?.plain_text || fallback; } -interface NotionDateProperty { - date?: { start: string; time_zone?: string | null } | null; -} - interface NotionUrlProperty { url?: string | null; } -// Notion returns `start` two different ways depending on whether the date -// property has an explicit time zone override: -// - no override: the UTC offset is embedded in `start` itself, e.g. -// "2026-07-22T17:00:00.000-04:00" — Date.parse handles this correctly. -// - override set: `start` has NO offset ("2026-07-22T17:00:00.000") and -// the zone lives separately in `time_zone`. Parsing that string with -// `new Date()` would use the *server's* zone, not the call's — wrong -// for every visitor outside that zone. Resolve it explicitly instead. -function resolveDateToUtcIso(prop: NotionDateProperty | undefined): string | null { - const start = prop?.date?.start; - if (!start) return null; - - // Date-only rows ("2026-07-22", no time component) can't anchor a live - // window — reject rather than silently defaulting to UTC midnight. - if (!start.includes("T")) return null; - - const timeZone = prop?.date?.time_zone; - if (!timeZone) { - const parsed = new Date(start); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); - } - - const [datePart, timePart] = start.split("T"); - const [year, month, day] = datePart.split("-").map(Number); - const [hour, minute, secondMs] = timePart.split(":"); - const second = Number((secondMs || "0").split(".")[0]); - - // Standard offset-discovery trick: guess the instant is UTC, ask the - // target time zone what wall-clock time that instant shows, then correct - // by the difference. Handles arbitrary IANA zones with no dependency. - const guessUtc = Date.UTC(year, month - 1, day, Number(hour), Number(minute), second); - - let formatted: Intl.DateTimeFormatPart[]; - try { - formatted = new Intl.DateTimeFormat("en-US", { - timeZone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).formatToParts(new Date(guessUtc)); - } catch { - return null; - } - - const part = (type: string) => formatted.find((p) => p.type === type)?.value; - const asIfUtc = Date.UTC( - Number(part("year")), - Number(part("month")) - 1, - Number(part("day")), - Number(part("hour")), - Number(part("minute")), - Number(part("second")) - ); - - return new Date(guessUtc - (asIfUtc - guessUtc)).toISOString(); -} - -export const fetchNotionEvents = async (showAll: boolean = false, locals?: any) => { - const secret = getEnv("NOTION_SECRET", locals); - const dbId = getEnv("NOTION_DB_ID", locals); - - if (!secret || !dbId) { - throw new Error("Missing Notion credentials: NOTION_SECRET and NOTION_DB_ID are required"); - } - - const notionAxios = createNotionAxios(secret); - const filter = showAll - ? {} - : { - filter: { - property: "Visibility", - checkbox: { - equals: true, - }, - }, - }; - - const response = await notionAxios.post(`databases/${dbId}/query`, filter); - - const events = response.data.results.map((page: any) => { - const props = page.properties; - - const headerImage = fileUrl(props["Header"], "/images/default.jpg"); - const description = richText(props["Description"]); - const registerLink = props["Link to registration"]?.url || ""; - const recordingLink = props["Link to recording"]?.url || ""; - - return { - id: page.id, - title: titleText(props["Title"], "Untitled"), - date: props["Date of event"]?.date?.start || "", - status: props["Stage"]?.select?.name || "", - location: props["Type of event"]?.multi_select?.[0]?.name || "", - image: headerImage, - link: page.url, - description, - registerLink, - recordingLink, - }; - }); - - // Sort by date (descending) - return events.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime()); -}; - -export const fetchNotionEventById = async (pageId: string, locals?: any) => { - const secret = getEnv("NOTION_SECRET", locals); - - if (!secret) { - throw new Error("Missing Notion credentials: NOTION_SECRET is required"); - } - - const notionAxios = createNotionAxios(secret); - const response = await notionAxios.get(`pages/${pageId}`); - - const props = response.data.properties; - - const headerImage = fileUrl(props["Header"], "/images/default.jpg"); - const description = richText(props["Description"]); - const registerLink = props["Link to registration"]?.url || ""; - const recordingLink = props["Link to recording"]?.url || ""; - - return { - id: response.data.id, - title: titleText(props["Title"], "Untitled"), - date: props["Date of event"]?.date?.start || "", - status: props["Stage"]?.select?.name || "", - location: props["Type of event"]?.multi_select?.[0]?.name || "", - image: headerImage, - link: response.data.url, - description, - registerLink, - recordingLink, - }; -}; - export const fetchNotionFAQ = async (showAll: boolean = false, locals?: any) => { const secret = getEnv("NOTION_SECRET", locals); const faqDbId = getEnv("NOTION_FAQ_DB_ID", locals); diff --git a/src/utils/eventDescription.tsx b/src/utils/eventDescription.tsx new file mode 100644 index 00000000..147fb970 --- /dev/null +++ b/src/utils/eventDescription.tsx @@ -0,0 +1,112 @@ +import React from "react"; + +// The Mattermost calendar plugin's event descriptions use a small, consistent +// subset of Markdown: "## " headings, "---" rules, "- "/"1. " lists, blank-line +// paragraphs, **bold**, and bare URLs. This isn't full CommonMark support — +// just what's actually observed in the feed. +export type DescriptionBlock = + | { type: "heading"; text: string } + | { type: "hr" } + | { type: "list"; ordered: boolean; items: string[] } + | { type: "paragraph"; text: string }; + +const isBulletLine = (l: string) => /^-\s+/.test(l); +const isNumberedLine = (l: string) => /^\d+\.\s+/.test(l); + +export function parseEventDescription(description: string): DescriptionBlock[] { + const rawBlocks = description + .split(/\n{2,}/) + .map((b) => b.trim()) + .filter(Boolean); + + const blocks: DescriptionBlock[] = []; + + for (const raw of rawBlocks) { + if (raw === "---") { + blocks.push({ type: "hr" }); + continue; + } + if (raw.startsWith("## ")) { + blocks.push({ type: "heading", text: raw.slice(3).trim() }); + continue; + } + + const lines = raw + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + if (lines.length > 0 && lines.every(isBulletLine)) { + blocks.push({ type: "list", ordered: false, items: lines.map((l) => l.replace(/^-\s+/, "")) }); + continue; + } + if (lines.length > 0 && lines.every(isNumberedLine)) { + blocks.push({ type: "list", ordered: true, items: lines.map((l) => l.replace(/^\d+\.\s+/, "")) }); + continue; + } + + // A label line ("Agenda:") followed entirely by list items is a common + // pattern in the feed — split it into a short paragraph plus a real + // list, rather than losing the list structure as one plain paragraph. + const listStart = lines.findIndex((l) => isBulletLine(l) || isNumberedLine(l)); + if (listStart > 0) { + const ordered = isNumberedLine(lines[listStart]); + const rest = lines.slice(listStart); + const restIsList = ordered ? rest.every(isNumberedLine) : rest.every(isBulletLine); + if (restIsList) { + blocks.push({ type: "paragraph", text: lines.slice(0, listStart).join("\n") }); + const prefixRe = ordered ? /^\d+\.\s+/ : /^-\s+/; + blocks.push({ type: "list", ordered, items: rest.map((l) => l.replace(prefixRe, "")) }); + continue; + } + } + + blocks.push({ type: "paragraph", text: raw }); + } + + return blocks; +} + +const URL_PATTERN = /(https?:\/\/[^\s)]+)/g; + +// Renders **bold** spans, auto-links bare URLs, and turns single newlines +// into
    — the only inline markup the feed's descriptions use. No raw +// HTML is ever injected, so there's no sanitization surface to worry about. +export function renderInlineText(text: string, keyPrefix: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + + text.split("\n").forEach((line, lineIndex) => { + if (lineIndex > 0) nodes.push(
    ); + + line.split(/(\*\*[^*]+\*\*)/g).forEach((part, partIndex) => { + const boldMatch = part.match(/^\*\*([^*]+)\*\*$/); + const partKey = `${keyPrefix}-${lineIndex}-${partIndex}`; + if (boldMatch) { + nodes.push({boldMatch[1]}); + return; + } + + part.split(URL_PATTERN).forEach((segment, segmentIndex) => { + if (!segment) return; + const segmentKey = `${partKey}-${segmentIndex}`; + if (/^https?:\/\//.test(segment)) { + nodes.push( + + {segment} + + ); + } else { + nodes.push({segment}); + } + }); + }); + }); + + return nodes; +} diff --git a/src/utils/eventSections.ts b/src/utils/eventSections.ts new file mode 100644 index 00000000..3ee225d1 --- /dev/null +++ b/src/utils/eventSections.ts @@ -0,0 +1,111 @@ +import type { EventItem } from "../store/eventsClient"; + +export interface EventSectionDef { + key: string; + title: string; + subtitle: string; + matchTags: string[]; // lowercase feed CATEGORIES tags that route into this section + // Words checked against the event title (all must appear, case-insensitive) + // when no tag match was found — the source calendar is inconsistent about + // tagging recurring events, so the name is the more reliable signal here. + titleKeywords?: string[]; +} + +export interface EventSection { + def: EventSectionDef; + upcoming: EventItem[]; + past: EventItem[]; +} + +const OTHERS_KEY = "others"; + +// Order matters: sections are checked top to bottom and the first match wins, +// so a multi-tag event (e.g. "webinar,roundtable") lands in Roundtable, not Others. +export const SECTION_DEFS: EventSectionDef[] = [ + { + key: "in-person-events", + title: "In-Person Events", + subtitle: "Join us at conferences, meetups and hackathons in a city near you!", + matchTags: ["in-person event"], + }, + { + key: "occupied-tech-podcast", + title: "Occupied Tech Podcast", + subtitle: "Conversations on tech, occupation, and resistance.", + matchTags: ["occupied tech podcast", "podcast"], + }, + { + key: "community-calls", + title: "Community Calls", + subtitle: "Monthly updates from T4P, the Incubator, and the wider community.", + matchTags: ["community monthly call"], + titleKeywords: ["community", "call"], + }, + { + key: "roundtable", + title: "Roundtable", + subtitle: "Discussions with organizers, founders, and allies on today's issues.", + matchTags: ["roundtable"], + }, + { + key: "book-club", + title: "Book Club", + subtitle: "Reading and discussing work that shapes the movement.", + matchTags: ["book club"], + }, +]; + +const OTHERS_DEF: EventSectionDef = { + key: OTHERS_KEY, + title: "Others", + subtitle: "More events from across the community.", + matchTags: [], +}; + +function sectionForEvent(event: EventItem): EventSectionDef { + for (const def of SECTION_DEFS) { + if (event.tags.some((tag) => def.matchTags.includes(tag))) return def; + } + + // No tag placed it — fall back to the event's title for sections that + // define keywords, since the feed's CATEGORIES tagging is unreliable. + const title = event.title.toLowerCase(); + for (const def of SECTION_DEFS) { + if (def.titleKeywords?.every((word) => title.includes(word))) return def; + } + + return OTHERS_DEF; +} + +export function groupIntoSections(events: EventItem[], nowMs: number = Date.now()): EventSection[] { + const byKey = new Map(); + + for (const event of events) { + const def = sectionForEvent(event); + if (!byKey.has(def.key)) byKey.set(def.key, { def, upcoming: [], past: [] }); + + const section = byKey.get(def.key)!; + const eventTime = event.dateUtcIso ? new Date(event.dateUtcIso).getTime() : 0; + if (eventTime >= nowMs) { + section.upcoming.push(event); + } else { + section.past.push(event); + } + } + + // Upcoming soonest-first, past most-recent-first. + for (const section of byKey.values()) { + section.upcoming.sort((a, b) => timeOf(a) - timeOf(b)); + section.past.sort((a, b) => timeOf(b) - timeOf(a)); + } + + const ordered = [...SECTION_DEFS, OTHERS_DEF] + .map((def) => byKey.get(def.key)) + .filter((section): section is EventSection => Boolean(section)); + + return ordered; +} + +function timeOf(event: EventItem): number { + return event.dateUtcIso ? new Date(event.dateUtcIso).getTime() : 0; +} diff --git a/src/utils/googleCalendarLink.ts b/src/utils/googleCalendarLink.ts new file mode 100644 index 00000000..fd2f8b69 --- /dev/null +++ b/src/utils/googleCalendarLink.ts @@ -0,0 +1,46 @@ +interface CalendarEventInput { + title: string; + description?: string; + location: string; + dateUtcIso: string | null; + endUtcIso: string | null; + registerLink?: string; + watchLink?: string; +} + +// "2025-02-26T09:00:00.000Z" -> "20250226T090000Z" +function toGoogleCalendarUtc(iso: string): string { + return iso.replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +// The on-site popup already has a dedicated "Register" CTA button, so the +// stripped description omits any "Register here: " line the organizer +// wrote — but once someone adds the event to their own calendar, that popup +// is gone and the calendar entry becomes the only record they have. Make +// sure the link survives into the exported event's details. +function buildCalendarDescription(event: CalendarEventInput): string { + const base = event.description?.trim() || ""; + const link = event.registerLink || event.watchLink; + if (!link || base.includes(link)) return base; + + const linkLine = event.registerLink ? `Register: ${link}` : `Watch online: ${link}`; + return base ? `${base}\n\n${linkLine}` : linkLine; +} + +export function buildGoogleCalendarLink(event: CalendarEventInput): string | null { + if (!event.dateUtcIso) return null; + + const start = toGoogleCalendarUtc(event.dateUtcIso); + const end = toGoogleCalendarUtc(event.endUtcIso ?? event.dateUtcIso); + + const params = new URLSearchParams({ + action: "TEMPLATE", + text: event.title, + dates: `${start}/${end}`, + }); + const details = buildCalendarDescription(event); + if (details) params.set("details", details); + if (event.location) params.set("location", event.location); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} diff --git a/src/utils/icalDate.ts b/src/utils/icalDate.ts new file mode 100644 index 00000000..3b525324 --- /dev/null +++ b/src/utils/icalDate.ts @@ -0,0 +1,109 @@ +// Resolves an iCalendar-style local date/time + IANA time zone to a UTC ISO string. +// +// Two call shapes need this: +// - Notion date properties with a `time_zone` override (no offset in `start`) +// - ICS `DTSTART;TZID=...` lines (no offset in the value, zone given separately) +// +// Standard offset-discovery trick: guess the instant is UTC, ask the target time +// zone what wall-clock time that instant shows, then correct by the difference. +// Handles arbitrary IANA zones with no dependency. +export function resolveLocalDateTimeToUtcIso( + datePart: string, + timePart: string, + timeZone: string +): string | null { + const [year, month, day] = datePart.split("-").map(Number); + const [hour, minute, secondMs] = timePart.split(":"); + const second = Number((secondMs || "0").split(".")[0]); + + const guessUtc = Date.UTC(year, month - 1, day, Number(hour), Number(minute), second); + + let formatted: Intl.DateTimeFormatPart[]; + try { + formatted = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(new Date(guessUtc)); + } catch { + return null; + } + + const part = (type: string) => formatted.find((p) => p.type === type)?.value; + const asIfUtc = Date.UTC( + Number(part("year")), + Number(part("month")) - 1, + Number(part("day")), + Number(part("hour")), + Number(part("minute")), + Number(part("second")) + ); + + return new Date(guessUtc - (asIfUtc - guessUtc)).toISOString(); +} + +interface NotionDateProperty { + date?: { start: string; time_zone?: string | null } | null; +} + +// Notion returns `start` two different ways depending on whether the date +// property has an explicit time zone override: +// - no override: the UTC offset is embedded in `start` itself, e.g. +// "2026-07-22T17:00:00.000-04:00" — Date.parse handles this correctly. +// - override set: `start` has NO offset ("2026-07-22T17:00:00.000") and +// the zone lives separately in `time_zone`. Parsing that string with +// `new Date()` would use the *server's* zone, not the call's — wrong +// for every visitor outside that zone. Resolve it explicitly instead. +export function resolveDateToUtcIso(prop: NotionDateProperty | undefined): string | null { + const start = prop?.date?.start; + if (!start) return null; + + // Date-only rows ("2026-07-22", no time component) can't anchor a live + // window — reject rather than silently defaulting to UTC midnight. + if (!start.includes("T")) return null; + + const timeZone = prop?.date?.time_zone; + if (!timeZone) { + const parsed = new Date(start); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + + const [datePart, timePart] = start.split("T"); + return resolveLocalDateTimeToUtcIso(datePart, timePart, timeZone); +} + +// ICS DTSTART value shapes: +// - "DTSTART;TZID=Europe/Prague:20250226T090000" -> datePart="20250226T090000", tzid="Europe/Prague" +// - "DTSTART;VALUE=DATE:20250226" -> all-day, no time component +// - "DTSTART:20250226T090000Z" -> already UTC +export function resolveIcsDateTimeToUtcIso(value: string, tzid?: string): string | null { + const isAllDay = !value.includes("T"); + if (isAllDay) return null; + + if (value.endsWith("Z")) { + const raw = value.slice(0, -1); + const year = Number(raw.slice(0, 4)); + const month = Number(raw.slice(4, 6)); + const day = Number(raw.slice(6, 8)); + const hour = Number(raw.slice(9, 11)); + const minute = Number(raw.slice(11, 13)); + const second = Number(raw.slice(13, 15) || "0"); + return new Date(Date.UTC(year, month - 1, day, hour, minute, second)).toISOString(); + } + + const [rawDate, rawTime] = value.split("T"); + const datePart = `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`; + const timePart = `${rawTime.slice(0, 2)}:${rawTime.slice(2, 4)}:${rawTime.slice(4, 6) || "00"}`; + + if (!tzid) { + const parsed = new Date(`${datePart}T${timePart}`); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + + return resolveLocalDateTimeToUtcIso(datePart, timePart, tzid); +} diff --git a/src/utils/webcal.ts b/src/utils/webcal.ts new file mode 100644 index 00000000..aedbd585 --- /dev/null +++ b/src/utils/webcal.ts @@ -0,0 +1,5 @@ +// webcal:// tells the OS/browser to hand the URL to the default calendar app as a +// subscription (auto-updating) rather than downloading it as a one-off file. +export function toWebcalUrl(httpsUrl: string): string { + return httpsUrl.replace(/^https?:/, "webcal:"); +} From 3a62232ff95a01fb523591930a4429e27630e8a2 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 18 Jul 2026 14:03:39 +0300 Subject: [PATCH 2/8] fix(events): drop the "Others" catch-all section Events whose tags/title don't match a named category are now skipped entirely instead of falling into a generic "Others" section. --- src/utils/eventSections.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/utils/eventSections.ts b/src/utils/eventSections.ts index 3ee225d1..cae0ec40 100644 --- a/src/utils/eventSections.ts +++ b/src/utils/eventSections.ts @@ -17,8 +17,6 @@ export interface EventSection { past: EventItem[]; } -const OTHERS_KEY = "others"; - // Order matters: sections are checked top to bottom and the first match wins, // so a multi-tag event (e.g. "webinar,roundtable") lands in Roundtable, not Others. export const SECTION_DEFS: EventSectionDef[] = [ @@ -55,14 +53,9 @@ export const SECTION_DEFS: EventSectionDef[] = [ }, ]; -const OTHERS_DEF: EventSectionDef = { - key: OTHERS_KEY, - title: "Others", - subtitle: "More events from across the community.", - matchTags: [], -}; - -function sectionForEvent(event: EventItem): EventSectionDef { +// Events whose tags/title don't match any named section are dropped — +// there's no catch-all "Others" bucket. +function sectionForEvent(event: EventItem): EventSectionDef | null { for (const def of SECTION_DEFS) { if (event.tags.some((tag) => def.matchTags.includes(tag))) return def; } @@ -74,7 +67,7 @@ function sectionForEvent(event: EventItem): EventSectionDef { if (def.titleKeywords?.every((word) => title.includes(word))) return def; } - return OTHERS_DEF; + return null; } export function groupIntoSections(events: EventItem[], nowMs: number = Date.now()): EventSection[] { @@ -82,6 +75,7 @@ export function groupIntoSections(events: EventItem[], nowMs: number = Date.now( for (const event of events) { const def = sectionForEvent(event); + if (!def) continue; if (!byKey.has(def.key)) byKey.set(def.key, { def, upcoming: [], past: [] }); const section = byKey.get(def.key)!; @@ -99,9 +93,9 @@ export function groupIntoSections(events: EventItem[], nowMs: number = Date.now( section.past.sort((a, b) => timeOf(b) - timeOf(a)); } - const ordered = [...SECTION_DEFS, OTHERS_DEF] - .map((def) => byKey.get(def.key)) - .filter((section): section is EventSection => Boolean(section)); + const ordered = SECTION_DEFS.map((def) => byKey.get(def.key)).filter( + (section): section is EventSection => Boolean(section) + ); return ordered; } From 65f2f006bbfdaea3b656908eb785fc097537195d Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 18 Jul 2026 14:22:40 +0300 Subject: [PATCH 3/8] feat(events): highlight upcoming events with an "Upcoming" badge Featured cards get a badge overlay on the image; the compact past-list rows get a small inline badge for the rare case a category has more upcoming events than fit in the featured slots. --- src/components/Events.tsx | 27 +++++++++++++++++++++++---- src/components/events/EventsNew.tsx | 16 ++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/components/Events.tsx b/src/components/Events.tsx index d74e2590..c9f0755e 100644 --- a/src/components/Events.tsx +++ b/src/components/Events.tsx @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogActions, + Chip, } from "@mui/material"; import AccessTimeIcon from "@mui/icons-material/AccessTime"; import LocationOnIcon from "@mui/icons-material/LocationOn"; @@ -253,7 +254,7 @@ function FeaturedEventCard({ event, isPast }: { event: EventItem; isPast: boolea return ( - + {event.title} + {!isPast && ( + + )}