From 823f9f588554d81ded51e79f664f2b5c9de3c132 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Sun, 26 Jul 2026 21:34:07 -0400 Subject: [PATCH 1/2] calender export --- backend/functions/src/calendar/index.ts | 245 ++++++++++++++++++++++++ backend/functions/src/index.ts | 7 +- web/app/routes.ts | 2 +- 3 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 backend/functions/src/calendar/index.ts diff --git a/backend/functions/src/calendar/index.ts b/backend/functions/src/calendar/index.ts new file mode 100644 index 0000000..781bca9 --- /dev/null +++ b/backend/functions/src/calendar/index.ts @@ -0,0 +1,245 @@ +import * as admin from "firebase-admin"; +import { onRequest } from "firebase-functions/v2/https"; + +// admin may already be initialized by another module in this codebase. +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +const CALENDAR_TIMEZONE = "America/New_York"; + +// Canonical US Eastern VTIMEZONE. Emitting wall-clock times with a TZID +// reference lets calendar clients resolve DST themselves, so we never do +// offset math on the stored times. +const VTIMEZONE = [ + "BEGIN:VTIMEZONE", + `TZID:${CALENDAR_TIMEZONE}`, + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:-0500", + "TZOFFSETTO:-0400", + "TZNAME:EDT", + "DTSTART:19700308T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:-0400", + "TZOFFSETTO:-0500", + "TZNAME:EST", + "DTSTART:19701101T020000", + "RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU", + "END:STANDARD", + "END:VTIMEZONE", +]; + +interface EventDoc { + title?: string; + date?: string; // "YYYY-MM-DD" + startTime?: string; // "HH:MM" + endTime?: string; // "HH:MM" + description?: string; + tags?: string[]; + location?: string; + isVirtual?: boolean; + createdAt?: admin.firestore.Timestamp; + updatedAt?: admin.firestore.Timestamp; +} + +// Escape a value for use in an ICS TEXT field (RFC 5545 §3.3.11). +function escapeText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r\n|\n|\r/g, "\\n"); +} + +// Zero-pad to two digits. +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +// Build a floating "basic format" local timestamp (YYYYMMDDTHHMMSS) from the +// stored date/time strings, optionally shifted by a number of minutes. +// Components are treated as UTC purely for arithmetic so the host server's +// timezone never affects the result. +function localStamp( + dateStr: string, + timeStr: string, + addMinutes = 0, +): string | null { + const dateParts = dateStr.split("-").map(Number); + const timeParts = timeStr.split(":").map(Number); + if (dateParts.length !== 3 || timeParts.length < 2) return null; + const [y, mo, d] = dateParts; + const [h, mi] = timeParts; + if ([y, mo, d, h, mi].some((v) => Number.isNaN(v))) return null; + const dt = new Date(Date.UTC(y, mo - 1, d, h, mi)); + if (addMinutes) dt.setUTCMinutes(dt.getUTCMinutes() + addMinutes); + return ( + `${dt.getUTCFullYear()}${pad(dt.getUTCMonth() + 1)}${pad(dt.getUTCDate())}` + + `T${pad(dt.getUTCHours())}${pad(dt.getUTCMinutes())}00` + ); +} + +// All-day DATE value (YYYYMMDD), optionally shifted by whole days. +function dateOnly(dateStr: string, addDays = 0): string | null { + const dateParts = dateStr.split("-").map(Number); + if (dateParts.length !== 3) return null; + const [y, mo, d] = dateParts; + if ([y, mo, d].some((v) => Number.isNaN(v))) return null; + const dt = new Date(Date.UTC(y, mo - 1, d)); + if (addDays) dt.setUTCDate(dt.getUTCDate() + addDays); + return `${dt.getUTCFullYear()}${pad(dt.getUTCMonth() + 1)}${pad(dt.getUTCDate())}`; +} + +// Current UTC timestamp as an ICS UTC value (YYYYMMDDTHHMMSSZ). +function utcStamp(date: Date): string { + return ( + `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` + ); +} + +// Fold a content line to <=75 octets per RFC 5545 §3.1, splitting on byte +// boundaries so multi-byte UTF-8 characters are never broken. +function foldLine(line: string): string { + const bytes = Buffer.from(line, "utf8"); + if (bytes.length <= 75) return line; + const chunks: Buffer[] = []; + let start = 0; + // First line: 75 bytes. Continuation lines start with a space, so they hold + // 74 bytes of content. + let limit = 75; + while (start < bytes.length) { + let end = Math.min(start + limit, bytes.length); + // Don't split in the middle of a multi-byte sequence (continuation bytes + // are 0b10xxxxxx). + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; + chunks.push(bytes.subarray(start, end)); + start = end; + limit = 74; + } + return chunks + .map((c, i) => (i === 0 ? "" : " ") + c.toString("utf8")) + .join("\r\n"); +} + +function buildEvent( + id: string, + ev: EventDoc, + dtstamp: string, + projectId: string, +): string[] { + const lines: string[] = ["BEGIN:VEVENT"]; + lines.push(`UID:${id}@${projectId}`); + lines.push(`DTSTAMP:${dtstamp}`); + + const date = ev.date ?? ""; + const start = (ev.startTime ?? "").trim(); + const end = (ev.endTime ?? "").trim(); + + if (start) { + const dtStart = localStamp(date, start); + if (dtStart) lines.push(`DTSTART;TZID=${CALENDAR_TIMEZONE}:${dtStart}`); + let dtEnd = end ? localStamp(date, end) : null; + // Default to a one-hour block when no end time is provided. + if (!dtEnd) dtEnd = localStamp(date, start, 60); + if (dtEnd) lines.push(`DTEND;TZID=${CALENDAR_TIMEZONE}:${dtEnd}`); + } else { + // All-day event. DTEND is exclusive, so it points at the next day. + const dtStart = dateOnly(date); + const dtEnd = dateOnly(date, 1); + if (dtStart) lines.push(`DTSTART;VALUE=DATE:${dtStart}`); + if (dtEnd) lines.push(`DTEND;VALUE=DATE:${dtEnd}`); + } + + if (ev.title) lines.push(`SUMMARY:${escapeText(ev.title)}`); + + const descParts: string[] = []; + if (ev.description) descParts.push(ev.description); + if (ev.tags && ev.tags.length > 0) + descParts.push(`Tags: ${ev.tags.join(", ")}`); + if (descParts.length > 0) { + lines.push(`DESCRIPTION:${escapeText(descParts.join("\n\n"))}`); + } + + const location = (ev.location ?? "").trim(); + if (location) { + lines.push(`LOCATION:${escapeText(location)}`); + } else if (ev.isVirtual) { + lines.push("LOCATION:Virtual"); + } + + if (ev.updatedAt) { + lines.push(`LAST-MODIFIED:${utcStamp(ev.updatedAt.toDate())}`); + } + + lines.push("END:VEVENT"); + return lines; +} + +/** + * Public HTTPS endpoint that serves the CancerLINC events calendar as a live + * iCalendar (ICS) feed. Calendar clients (Google, Apple, WordPress plugins) + * subscribe to this URL and re-poll it for updates. + */ +// Build the full VCALENDAR document from a list of events. Pure and +// side-effect free so it can be unit tested without Firestore. +export function buildCalendar( + events: Array, + projectId: string, + now: Date = new Date(), +): string { + const dtstamp = utcStamp(now); + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//CancerLINC//Events Calendar//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "X-WR-CALNAME:CancerLINC Events", + `X-WR-TIMEZONE:${CALENDAR_TIMEZONE}`, + ...VTIMEZONE, + ]; + + for (const ev of events) { + if (!ev.date) continue; // an event with no date cannot be scheduled + lines.push(...buildEvent(ev.id, ev, dtstamp, projectId)); + } + + lines.push("END:VCALENDAR"); + return lines.map(foldLine).join("\r\n") + "\r\n"; +} + +export const calendarIcs = onRequest( + { invoker: "public", cors: true }, + async (req, res) => { + try { + const projectId = + process.env.GCLOUD_PROJECT ?? process.env.GCP_PROJECT ?? "cancerlinc"; + const snapshot = await admin + .firestore() + .collection("events") + .orderBy("date") + .get(); + + const events = snapshot.docs.map((doc) => ({ + id: doc.id, + ...(doc.data() as EventDoc), + })); + const body = buildCalendar(events, projectId); + + res.set("Content-Type", "text/calendar; charset=utf-8"); + res.set( + "Content-Disposition", + 'inline; filename="cancerlinc-events.ics"', + ); + // Let subscribers and any CDN cache briefly; the feed stays effectively live. + res.set("Cache-Control", "public, max-age=300"); + res.status(200).send(body); + } catch (err) { + console.error("Failed to build ICS feed", err); + res.status(500).send("Failed to build calendar feed"); + } + }, +); diff --git a/backend/functions/src/index.ts b/backend/functions/src/index.ts index 9b9bf7b..ce20ad8 100644 --- a/backend/functions/src/index.ts +++ b/backend/functions/src/index.ts @@ -1,19 +1,20 @@ export { + deactivateStaleChats, onAuthUserCreated, onMessageCreated, - deactivateStaleChats, sendMessageNotification, } from "./shared"; export { createUserChat, - sendChatMessage, sendChatImageMessage, + sendChatMessage, } from "./patient"; export { createStaffAccount, - setStaffDisabled, deleteStaffAccount, + setStaffDisabled, } from "./admin"; +export { calendarIcs } from "./calendar"; diff --git a/web/app/routes.ts b/web/app/routes.ts index 23833aa..08d90cf 100644 --- a/web/app/routes.ts +++ b/web/app/routes.ts @@ -7,8 +7,8 @@ export default [ index("routes/_index.tsx"), route("member/:user", "routes/member.tsx"), route("staff", "routes/staff_admin.tsx"), - route("calendar", "routes/calendar.tsx"), route("unverified", "routes/unverified-table.tsx"), + route("calendar", "routes/calendar.tsx"), ]), ]), From 874da7d5569230fafb9fea8de24ef48179b72540 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Sat, 1 Aug 2026 14:38:57 -0400 Subject: [PATCH 2/2] feat(calendar): live ICS export feed + calendar export button Add a public `calendarIcs` Cloud Function that serves the events collection as a live iCalendar feed, plus an Export button on the calendar page to download the .ics file or copy the feed link. Consolidate Firebase init onto the typed module and drop the duplicate untyped firebase.js. Co-Authored-By: Claude Opus 4.8 --- backend/functions/src/calendar/index.ts | 146 ++++++++++++++---------- web/app/firebase.js | 19 --- web/app/hooks/useReferrals.ts | 2 +- web/app/routes/calendar.tsx | 137 ++++++++++++++++++++-- 4 files changed, 211 insertions(+), 93 deletions(-) delete mode 100644 web/app/firebase.js diff --git a/backend/functions/src/calendar/index.ts b/backend/functions/src/calendar/index.ts index 781bca9..9708e1a 100644 --- a/backend/functions/src/calendar/index.ts +++ b/backend/functions/src/calendar/index.ts @@ -1,5 +1,5 @@ import * as admin from "firebase-admin"; -import { onRequest } from "firebase-functions/v2/https"; +import {onRequest} from "firebase-functions/v2/https"; // admin may already be initialized by another module in this codebase. if (admin.apps.length === 0) { @@ -47,10 +47,10 @@ interface EventDoc { // Escape a value for use in an ICS TEXT field (RFC 5545 §3.3.11). function escapeText(value: string): string { return value - .replace(/\\/g, "\\\\") - .replace(/;/g, "\\;") - .replace(/,/g, "\\,") - .replace(/\r\n|\n|\r/g, "\\n"); + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r\n|\n|\r/g, "\\n"); } // Zero-pad to two digits. @@ -63,9 +63,9 @@ function pad(n: number): string { // Components are treated as UTC purely for arithmetic so the host server's // timezone never affects the result. function localStamp( - dateStr: string, - timeStr: string, - addMinutes = 0, + dateStr: string, + timeStr: string, + addMinutes = 0, ): string | null { const dateParts = dateStr.split("-").map(Number); const timeParts = timeStr.split(":").map(Number); @@ -120,15 +120,25 @@ function foldLine(line: string): string { limit = 74; } return chunks - .map((c, i) => (i === 0 ? "" : " ") + c.toString("utf8")) - .join("\r\n"); + .map((c, i) => (i === 0 ? "" : " ") + c.toString("utf8")) + .join("\r\n"); +} + +// True only for a well-formed http(s) URL, so we never emit a bogus join link. +function isHttpUrl(value: string): boolean { + try { + const u = new URL(value); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } } function buildEvent( - id: string, - ev: EventDoc, - dtstamp: string, - projectId: string, + id: string, + ev: EventDoc, + dtstamp: string, + projectId: string, ): string[] { const lines: string[] = ["BEGIN:VEVENT"]; lines.push(`UID:${id}@${projectId}`); @@ -155,20 +165,34 @@ function buildEvent( if (ev.title) lines.push(`SUMMARY:${escapeText(ev.title)}`); - const descParts: string[] = []; - if (ev.description) descParts.push(ev.description); - if (ev.tags && ev.tags.length > 0) - descParts.push(`Tags: ${ev.tags.join(", ")}`); - if (descParts.length > 0) { - lines.push(`DESCRIPTION:${escapeText(descParts.join("\n\n"))}`); + const location = (ev.location ?? "").trim(); + const format = ev.isVirtual ? "Virtual" : "In Person"; + // Only treat the location as a join link when the event is virtual and the + // location is actually a URL — otherwise it's a physical address. + const joinUrl = ev.isVirtual && isHttpUrl(location) ? location : null; + + // Structured LOCATION field: shown only when a location is provided. + if (location) lines.push(`LOCATION:${escapeText(location)}`); + + // Conference join link so calendar apps render a "Join" button. CONFERENCE + // is RFC 7986; URL is added for older clients that don't support it. The + // value is a URI, so it is not TEXT-escaped. + if (joinUrl) { + lines.push(`CONFERENCE;VALUE=URI;FEATURE=VIDEO;LABEL=Join:${joinUrl}`); + lines.push(`URL:${joinUrl}`); } - const location = (ev.location ?? "").trim(); - if (location) { - lines.push(`LOCATION:${escapeText(location)}`); - } else if (ev.isVirtual) { - lines.push("LOCATION:Virtual"); + // DESCRIPTION always carries the format (and location, if any) so the core + // details survive even if a client drops the structured props above. + const metaLines = [`Format: ${format}`]; + if (location) metaLines.push(`Location: ${location}`); + if (ev.tags && ev.tags.length > 0) { + metaLines.push(`Tags: ${ev.tags.join(", ")}`); } + const descParts: string[] = []; + if (ev.description) descParts.push(ev.description); + descParts.push(metaLines.join("\n")); + lines.push(`DESCRIPTION:${escapeText(descParts.join("\n\n"))}`); if (ev.updatedAt) { lines.push(`LAST-MODIFIED:${utcStamp(ev.updatedAt.toDate())}`); @@ -178,17 +202,12 @@ function buildEvent( return lines; } -/** - * Public HTTPS endpoint that serves the CancerLINC events calendar as a live - * iCalendar (ICS) feed. Calendar clients (Google, Apple, WordPress plugins) - * subscribe to this URL and re-poll it for updates. - */ // Build the full VCALENDAR document from a list of events. Pure and // side-effect free so it can be unit tested without Firestore. export function buildCalendar( - events: Array, - projectId: string, - now: Date = new Date(), + events: Array, + projectId: string, + now: Date = new Date(), ): string { const dtstamp = utcStamp(now); const lines: string[] = [ @@ -211,35 +230,38 @@ export function buildCalendar( return lines.map(foldLine).join("\r\n") + "\r\n"; } +// Public HTTPS endpoint that serves the CancerLINC events calendar as a live +// iCalendar (ICS) feed. Calendar clients (Google, Apple, WordPress plugins) +// subscribe to this URL and re-poll it for updates. export const calendarIcs = onRequest( - { invoker: "public", cors: true }, - async (req, res) => { - try { - const projectId = + {invoker: "public", cors: true}, + async (req, res) => { + try { + const projectId = process.env.GCLOUD_PROJECT ?? process.env.GCP_PROJECT ?? "cancerlinc"; - const snapshot = await admin - .firestore() - .collection("events") - .orderBy("date") - .get(); - - const events = snapshot.docs.map((doc) => ({ - id: doc.id, - ...(doc.data() as EventDoc), - })); - const body = buildCalendar(events, projectId); - - res.set("Content-Type", "text/calendar; charset=utf-8"); - res.set( - "Content-Disposition", - 'inline; filename="cancerlinc-events.ics"', - ); - // Let subscribers and any CDN cache briefly; the feed stays effectively live. - res.set("Cache-Control", "public, max-age=300"); - res.status(200).send(body); - } catch (err) { - console.error("Failed to build ICS feed", err); - res.status(500).send("Failed to build calendar feed"); - } - }, + const snapshot = await admin + .firestore() + .collection("events") + .orderBy("date") + .get(); + + const events = snapshot.docs.map((doc) => ({ + id: doc.id, + ...(doc.data() as EventDoc), + })); + const body = buildCalendar(events, projectId); + + res.set("Content-Type", "text/calendar; charset=utf-8"); + res.set( + "Content-Disposition", + "inline; filename=\"cancerlinc-events.ics\"", + ); + // Let subscribers and any CDN cache briefly; the feed stays effectively live. + res.set("Cache-Control", "public, max-age=300"); + res.status(200).send(body); + } catch (err) { + console.error("Failed to build ICS feed", err); + res.status(500).send("Failed to build calendar feed"); + } + }, ); diff --git a/web/app/firebase.js b/web/app/firebase.js deleted file mode 100644 index 82077f8..0000000 --- a/web/app/firebase.js +++ /dev/null @@ -1,19 +0,0 @@ -import { initializeApp } from "firebase/app"; -import { getFirestore } from "firebase/firestore"; -import { getAuth } from "firebase/auth"; -import { getStorage } from "firebase/storage"; - -const firebaseConfig = { - apiKey: import.meta.env.VITE_FIREBASE_API_KEY, - authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, - projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, - storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, - messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, - appId: import.meta.env.VITE_FIREBASE_APP_ID, - measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, -}; - -const app = initializeApp(firebaseConfig); -export const db = getFirestore(app); -export const auth = getAuth(app); -export const storage = getStorage(app); diff --git a/web/app/hooks/useReferrals.ts b/web/app/hooks/useReferrals.ts index 4ef488d..b594a80 100644 --- a/web/app/hooks/useReferrals.ts +++ b/web/app/hooks/useReferrals.ts @@ -11,7 +11,7 @@ import { addDoc, Timestamp, } from "firebase/firestore"; -import { db } from "~/firebase"; +import { db } from "~/services/firebase_app"; import type { Referral, ReferralWithProvider } from "~/types/referral"; import type { User } from "~/types/user"; diff --git a/web/app/routes/calendar.tsx b/web/app/routes/calendar.tsx index 95bba1b..6754942 100644 --- a/web/app/routes/calendar.tsx +++ b/web/app/routes/calendar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, @@ -9,6 +9,10 @@ import { Calendar, MapPin, Video, + Download, + Copy, + Check, + ChevronDown, } from "lucide-react"; import { collection, @@ -21,7 +25,13 @@ import { query, orderBy, } from "firebase/firestore"; -import { db } from "~/firebase"; +import { db } from "~/services/firebase_app"; + +// Public ICS feed served by the `calendarIcs` Cloud Function. Calendar apps +// (Google, Apple, WordPress) can subscribe to this URL for a live feed. +const ICS_FEED_URL = `https://us-central1-${ + import.meta.env.VITE_FIREBASE_PROJECT_ID +}.cloudfunctions.net/calendarIcs`; // types interface CalendarEvent { @@ -124,6 +134,24 @@ export default function CalendarPage() { const [editTarget, setEditTarget] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [copied, setCopied] = useState(false); + const exportRef = useRef(null); + + // Close the export menu when clicking outside of it. + useEffect(() => { + if (!exportOpen) return; + function onClick(e: MouseEvent) { + if ( + exportRef.current && + !exportRef.current.contains(e.target as Node) + ) { + setExportOpen(false); + } + } + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [exportOpen]); //Firestore real-time listener useEffect(() => { @@ -245,6 +273,43 @@ export default function CalendarPage() { } } + // Fetch the live ICS feed and save it as a file. We download via a blob so + // the browser honors the .ics filename even though the feed is cross-origin. + async function downloadIcs() { + setExportOpen(false); + try { + const res = await fetch(ICS_FEED_URL); + if (!res.ok) throw new Error(`Feed returned ${res.status}`); + const text = await res.text(); + const url = URL.createObjectURL( + new Blob([text], { type: "text/calendar" }) + ); + const a = document.createElement("a"); + a.href = url; + a.download = "cancerlinc-events.ics"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + setError( + e instanceof Error + ? `Could not download calendar: ${e.message}` + : "Could not download calendar" + ); + } + } + + async function copyFeedLink() { + try { + await navigator.clipboard.writeText(ICS_FEED_URL); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError("Could not copy link to clipboard"); + } + } + function toggleTag(tag: string) { setForm((f) => ({ ...f, @@ -267,13 +332,63 @@ export default function CalendarPage() { Schedule and manage community events for patients.

- +
+
+ + {exportOpen && ( +
+ + +
+ )} +
+ +
{error && ( @@ -397,13 +512,13 @@ export default function CalendarPage() { className="rounded-lg bg-black p-1.5 text-white hover:bg-gray-800" title="Add event" > - +