diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef99db5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + client: + name: Client (typecheck, lint, tests, build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck client + run: pnpm --filter client typecheck + + - name: Lint client + run: pnpm --filter client lint + + - name: Unit tests + run: pnpm --filter client test:unit + + - name: Web export (bundle check) + run: pnpm --filter client build + env: + # Placeholder values: the bundle must build without real credentials + EXPO_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + EXPO_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + + bots: + name: Bots (typecheck, syntax) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck slack-bot + run: pnpm --filter slack-bot typecheck + + - name: Syntax-check discord bot + run: | + for f in apps/discord-event-bot/src/*.js; do + node --check "$f" + done diff --git a/ANALYSIS.md b/ANALYSIS.md index a62f2c8..45a6531 100644 --- a/ANALYSIS.md +++ b/ANALYSIS.md @@ -1,3 +1,5 @@ +> **Historical snapshot.** This document describes the project at an earlier stage of development and is kept for reference. Parts of it no longer match the codebase — see [README.md](README.md) for the current state. + # Universify Project Analysis ## Executive Summary diff --git a/DATABASE_SETUP_COMPLETE.md b/DATABASE_SETUP_COMPLETE.md index b781da6..0fde79b 100644 --- a/DATABASE_SETUP_COMPLETE.md +++ b/DATABASE_SETUP_COMPLETE.md @@ -1,3 +1,5 @@ +> **Historical snapshot.** This document describes the project at an earlier stage of development and is kept for reference. Parts of it no longer match the codebase — see [README.md](README.md) for the current state. + # Complete Database Setup Guide for Universify ## Overview diff --git a/GOOGLE_INTEGRATION_SUMMARY.md b/GOOGLE_INTEGRATION_SUMMARY.md index ff1cbeb..23953c8 100644 --- a/GOOGLE_INTEGRATION_SUMMARY.md +++ b/GOOGLE_INTEGRATION_SUMMARY.md @@ -1,3 +1,5 @@ +> **Historical snapshot.** This document describes the project at an earlier stage of development and is kept for reference. Parts of it no longer match the codebase — see [README.md](README.md) for the current state. + # Google Sign-In & Calendar Integration Summary This document summarizes all the changes made to integrate Google Sign-In and Google Calendar functionality into Universify. diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index cb0375f..caae9cb 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -1,3 +1,5 @@ +> **Historical snapshot.** This document describes the project at an earlier stage of development and is kept for reference. Parts of it no longer match the codebase — see [README.md](README.md) for the current state. + # Universify - Implementation Summary ## Overview diff --git a/INTEGRATION_SUMMARY.md b/INTEGRATION_SUMMARY.md index 9497510..6e6244d 100644 --- a/INTEGRATION_SUMMARY.md +++ b/INTEGRATION_SUMMARY.md @@ -1,3 +1,5 @@ +> **Historical snapshot.** This document describes the project at an earlier stage of development and is kept for reference. Parts of it no longer match the codebase — see [README.md](README.md) for the current state. + # Calendar Integration Summary ## What Was Integrated diff --git a/README.md b/README.md index 8a7e3e2..a962316 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,16 @@ Universify is developed under the **ScottyLabs** organization as part of the **L - **Multi-source event ingestion** — Slack bot (real-time Socket Mode + REST API) and Discord bot (announcement channel monitoring) independently parse and submit events to a shared Supabase database. - **NLP-based message parsing** — Custom regex + chrono-node parsers extract dates, times, locations, and categories from free-text announcements with confidence scoring and missing-field detection. -- **Human-in-the-loop review (Discord)** — Parsed events surface as interactive embeds with Approve / Edit / Reject buttons; authors can correct fields via a Discord modal before events go live. -- **Personalized recommendation engine** — Analyzes user behavior to build interest profiles from category affinity, n-gram title matching (1–3 grams), RSVP popularity weighting (`log1p(going) + 1`), and time-of-day buckets. +- **Human-in-the-loop review** — Discord: parsed events surface as interactive embeds with Approve / Edit / Reject buttons and a correction modal. Slack: an optional review channel with Approve / Reject buttons before events go live. +- **Cross-source deduplication** — Both bots and the client detect when the same announcement arrives from multiple platforms (title-token similarity within a ±2h window), with an optional LLM tie-break via OpenRouter. +- **Personalized recommendation engine** — Builds interest profiles from the events a user scheduled, RSVP'd to, or created: category affinity, n-gram title matching (1–3 grams), RSVP popularity weighting (`log1p(going) + 1`), and time-of-day buckets. Powers the ranked Home feed and calendar time-slot suggestions. - **Google Calendar sync** — Bidirectional: reads personal calendar events into the app and writes Universify events back to Google Calendar via the Calendar API with OAuth scopes. - **Cross-platform client** — React Native (Expo) app with web support via `react-native-web`, responsive layout with desktop navigation, and haptic feedback on native. - **Week-view calendar with overlap layout** — Google Calendar-style column algorithm that detects simultaneous events and renders them side-by-side with calculated widths. - **Advanced filtering and search** — Multi-axis filtering by category, event type, time-of-day, date range, location, and availability. Three search modes: name-only, full-text, and weighted semantic scoring. -- **RSVP and attendance tracking** — Per-event going/maybe/not-going counts with capacity tracking and availability filtering. -- **Event scheduling** — Users pin events to weekly calendars; state syncs to Supabase when authenticated or falls back to local storage. +- **RSVP and attendance tracking** — Per-user RSVPs stored in their own table (so any signed-in user's RSVP persists under RLS), with trigger-maintained going/maybe/not-going aggregates, capacity tracking, and availability filtering. +- **Event scheduling & reminders** — Users pin events to weekly calendars; state syncs to Supabase when authenticated or falls back to local storage. On web, opt-in browser notifications fire ~30 minutes before scheduled events. +- **Recurring events & flyer images** — Daily/weekly/monthly recurrence with interval and end date, expanded into calendar occurrences; URL-based flyer images on cards and detail views. - **CMU-only authentication** — Google OAuth via Supabase Auth with email domain enforcement (`@andrew.cmu.edu`, `@cmu.edu`), automatic user profile creation via database trigger, and session caching. - **Dark mode and accessibility** — System/light/dark themes, configurable font sizes, high-contrast mode, and reduced-motion support. @@ -69,6 +71,8 @@ Universify is developed under the **ScottyLabs** organization as part of the **L | Technology | Purpose | |---|---| | pnpm Workspaces | Monorepo dependency management | +| GitHub Actions | CI: typecheck, lint, unit tests, web bundle check, bot checks | +| node:test + tsx | Unit tests for the pure utilities (dedupe, recurrence, layout, recommendations) | | Playwright | End-to-end browser testing | | ESLint | Code linting (Expo config) | | Vercel | Web deployment target (`vercel.json` configured) | @@ -136,10 +140,10 @@ Universify is developed under the **ScottyLabs** organization as part of the **L 1. **Ingestion** — Bots listen for new messages in announcement channels. The Slack bot receives events via Bolt.js Socket Mode; the Discord bot monitors `GuildAnnouncement` channels and text channels matching `*announcements*`. 2. **Parsing** — Raw message text passes through platform-specific parsers. The Slack parser uses hand-written regex for dates (`MM/DD/YYYY`, `Month Day, Year`), time ranges (`3-5pm`, `3:00 PM - 5:00 PM`), and location labels (`Location:`, `at`). The Discord parser uses chrono-node for flexible NLP date extraction, with Luxon for timezone handling, plus regex for room codes (`WEH 5302`) and labeled fields. -3. **Review (Discord only)** — Parsed drafts are posted as rich embeds in a dedicated `#event-approval` channel with confidence scoring. Missing or low-confidence fields are flagged. Authors approve, edit via Discord modal, or reject. -4. **Storage** — Approved events are inserted into the shared Supabase `events` table. The Slack bot also maintains an in-memory store for its REST API. Both bots tag events with their source (`slack-`, `discord`) for traceability. +3. **Review** — Discord: parsed drafts are posted as rich embeds in a dedicated `#event-approval` channel with confidence scoring; missing or low-confidence fields are flagged, and authors approve, edit via Discord modal, or reject. Slack: when `SLACK_REVIEW_CHANNEL` is configured, parsed events are posted there with Approve/Reject buttons; without it they are stored automatically. +4. **Storage & deduplication** — Approved events are inserted into the shared Supabase `events` table. Before inserting, both bots run a cross-source duplicate check (title-token Jaccard similarity within a ±2h start window; the Slack bot can optionally escalate borderline pairs to an LLM via OpenRouter). The Slack bot also maintains an in-memory store for its REST API. Both bots tag events with their source (`slack`, `discord`) for traceability. 5. **Serving** — The Expo client fetches events from Supabase on load, with a static JSON fallback for offline/demo use. Google Calendar events are fetched separately via the Calendar API and merged client-side (prefixed `gcal-`). -6. **Recommendations** — The `useRecommendations` hook analyzes the event corpus to build interest profiles: it tokenizes titles into 1–3 grams, counts category frequencies, and weights by RSVP popularity. Suggestions for a given time range combine interest matching with temporal overlap and event-duration fit scoring. +6. **Recommendations** — The engine (`utils/recommendationEngine.ts`) mines an interest profile from the events the user engaged with (scheduled, RSVP'd, created): title 1–3 grams, category frequencies, and time-of-day buckets, weighted by RSVP popularity. The Home feed ranks upcoming events by that profile plus the user's explicit category preferences; drag-selecting a time range in the calendar surfaces the top suggestions that fit the window (interest match + popularity + duration fit). ## Project Structure @@ -151,47 +155,49 @@ universify/ │ │ │ ├── (auth)/ # Login, signup, OAuth callback screens │ │ │ ├── (tabs)/ # Main tab screens: home, calendar, find, create, profile │ │ │ └── settings/ # Account, appearance, preferences screens -│ │ ├── components/ # 32 React components +│ │ ├── components/ # React components │ │ │ ├── calendar/ # WeekView, CalendarHeader, EventDisplayCard, TimeColumn │ │ │ ├── events/ # EventCard, EventDetailSidebar, CreateEventForm │ │ │ ├── layout/ # DesktopNav, Header, FilterDrawer, ResponsiveLayout -│ │ │ ├── recommendations/ # RecommendationCard, RecommendationRail, RecommendationsList +│ │ │ ├── recommendations/ # RecommendationCard, RecommendationsList │ │ │ └── ui/ # Button, Input, Modal, SearchBar, CategoryPill, DateTimePicker -│ │ ├── contexts/ # 7 React contexts (Auth, Events, GoogleCalendar, Slack, etc.) -│ │ ├── hooks/ # 11 custom hooks (useRecommendations, useCalendar, useAuth, etc.) +│ │ ├── contexts/ # React contexts (Auth, Events, Filters, GoogleCalendar, Slack, Settings) +│ │ ├── hooks/ # Custom hooks (useAppTheme, useRecommendations, useEventReminders, etc.) │ │ ├── lib/ # API clients: Supabase, Slack REST, Google Calendar, storage │ │ ├── types/ # TypeScript type definitions (Event, User, Settings) -│ │ ├── utils/ # Event filtering/sorting, date math, calendar layout algorithm +│ │ ├── utils/ # Recommendation engine, dedupe, recurrence, layout, date math │ │ ├── data/ # Static JSON: allEvents, categories, mock data +│ │ ├── tests/ # Unit tests (node:test via tsx) │ │ ├── e2e/ # Playwright end-to-end tests │ │ └── scripts/ # Seed data, health checks, backend testing │ │ │ ├── slack-bot/ # Slack integration service (TypeScript) │ │ └── src/ │ │ ├── index.ts # Entry: Express server + Bolt.js Socket Mode -│ │ ├── listener.ts # Real-time message handler +│ │ ├── listener.ts # Real-time message handler + review-button actions │ │ ├── parser.ts # Regex-based event extraction (date, time, location, categories) +│ │ ├── dedupe.ts # Cross-source duplicate detection (+ optional LLM tie-break) +│ │ ├── supabaseSubmit.ts # Parsed event → Supabase insert (service role) │ │ ├── routes.ts # REST API: /health, /channels, /events, /cached │ │ └── store.ts # In-memory event store with channel-based querying │ │ │ └── discord-event-bot/ # Discord integration service (JavaScript) │ └── src/ -│ ├── bot.js # Gateway client, review UI (embeds + modals), SQLite sessions -│ ├── parser.js # chrono-node date extraction, labeled field parsing -│ ├── storage.js # JSON file-based review persistence (legacy) +│ ├── bot.js # Gateway client, NLP parsing, review UI, SQLite sessions +│ ├── dedupe.js # Cross-source duplicate detection │ └── supabaseSubmit.js # Approved event → Supabase insert with RLS bypass │ ├── supabase/ │ └── migrations/ │ ├── 001_initial_schema.sql # events, user_profiles, user_scheduled_events tables + RLS + indexes -│ └── 002_handle_new_user.sql # Trigger: auto-create profile on auth.users insert +│ ├── 002_handle_new_user.sql # Trigger: auto-create profile on auth.users insert +│ └── 003_event_rsvps.sql # Per-user RSVPs + trigger-maintained aggregates on events │ +├── .github/workflows/ci.yml # CI: typecheck, lint, unit tests, bundle check, bot checks ├── pnpm-workspace.yaml # Monorepo workspace config -└── package.json # Root scripts: dev, build, lint +└── package.json # Root scripts: dev, build, lint, typecheck, test ``` -**By the numbers:** ~15,200 lines of TypeScript/JavaScript across 90+ source files, 122 lines of SQL schema, 32 React components, 11 custom hooks, 7 context providers, and 2 Playwright test suites. - ## Setup Instructions ### Prerequisites @@ -218,6 +224,7 @@ Run the SQL migrations in your Supabase project's SQL Editor: # In order: # 1. supabase/migrations/001_initial_schema.sql # 2. supabase/migrations/002_handle_new_user.sql +# 3. supabase/migrations/003_event_rsvps.sql ``` Enable **Google OAuth** in Supabase Auth settings (Dashboard → Authentication → Providers → Google). Configure the allowed redirect URL to include your app's callback route. @@ -238,8 +245,14 @@ SLACK_BOT_TOKEN=xoxb-your-bot-token SLACK_SIGNING_SECRET=your_signing_secret SLACK_APP_TOKEN=xapp-your-app-token PORT=3001 -# Optional: LLM-powered duplicate detection -OPENROUTER_API_KEY=your_openrouter_key +# Supabase persistence (omit to keep events in memory only) +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +SUPABASE_ORGANIZER_ID=uuid_of_bot_user +# Optional: human-in-the-loop review channel (Approve/Reject buttons) +# SLACK_REVIEW_CHANNEL=C0123456789 +# Optional: LLM tie-break for borderline duplicate detection +# OPENROUTER_API_KEY=your_openrouter_key ``` **Discord Bot** (`apps/discord-event-bot/.env`): @@ -279,7 +292,7 @@ cd apps/client && pnpm seed | **Slack (Bolt.js + Web API)** | The Slack bot connects via Socket Mode for real-time message streaming and exposes a REST API (`/api/slack/events`, `/channels`, `/cached`) that the Expo client calls to import events from specific channels. | | **Discord (Discord.js)** | Monitors announcement channels in Discord servers, parses messages into event drafts, and surfaces them for human review via interactive embeds. Approved events are inserted directly into Supabase. | | **Google Calendar API** | Reads the user's personal calendar events (next 4 weeks) and converts them to the Universify event format for unified display. Also supports writing Universify events back to Google Calendar. | -| **OpenRouter** | API key is configured for LLM-powered duplicate detection in the Slack bot pipeline. (Integration is in early stages.) | +| **OpenRouter** (optional) | LLM tie-break for borderline duplicate pairs in the Slack bot's dedupe pipeline. Without an API key, deduplication falls back to the heuristic (title similarity + time window) alone. | ## Notable Engineering Decisions @@ -291,7 +304,7 @@ The Discord bot introduces a confidence scoring system (`high` / `medium` / `low ### Recommendation Engine: N-gram Interest Profiles -Rather than requiring users to manually select interests, the recommendation engine builds implicit interest profiles by analyzing the events corpus. It tokenizes event titles into 1-grams, 2-grams, and 3-grams (after stopword filtering), then scores each gram by frequency weighted by event popularity (`log1p(going_count) + 1`). Categories and time-of-day buckets are scored separately and merged into a top-10 composite profile. When suggesting events for a time range, candidates are scored by interest match, popularity boost, and temporal fit (events that fit within the requested window score higher). This approach means recommendations improve as more events enter the system, without any cold-start configuration. +Rather than requiring users to manually select interests, the recommendation engine builds implicit interest profiles from the events a user actually engaged with — scheduled, RSVP'd, or created. It tokenizes those event titles into 1-grams, 2-grams, and 3-grams (after per-token normalization and stopword filtering), then scores each gram by frequency weighted by event popularity (`log1p(going_count) + 1`). Categories and time-of-day buckets are scored separately and merged into a top-10 composite profile. The Home feed ranks all upcoming events against this profile (explicit category preferences from Settings get the strongest boost); time-range suggestions additionally score temporal fit, so events that fit within the drag-selected calendar window rank higher. Users with no engagement history fall back gracefully to explicit preferences + popularity + date. ### Cross-Platform Storage and Auth Strategy @@ -299,25 +312,30 @@ The client handles three storage tiers: Supabase for authenticated persistent st ## Status -Universify is under active development as a ScottyLabs Labrador project. +Universify is developed as a ScottyLabs Labrador project. **Working:** -- Expo client with full tab navigation (Home, Calendar, Find, Create, Profile) -- Supabase-backed event CRUD with Row Level Security +- Expo client with full tab navigation (Home, Calendar, Find, Create, Profile) and a full event detail page (`/event/:id`) +- Supabase-backed event CRUD with Row Level Security, including per-user RSVP persistence (`event_rsvps` table with trigger-maintained aggregates) - Google OAuth with CMU domain enforcement -- Google Calendar bidirectional sync -- Slack bot with real-time Socket Mode and REST API +- Google Calendar bidirectional sync (the Universify→Google event mapping survives reloads) +- Slack bot with real-time Socket Mode, REST API, Supabase persistence, and an optional Approve/Reject review channel - Discord bot with announcement parsing, review workflow, and Supabase submission -- Recommendation engine with interest profiling and time-range suggestions -- Week-view calendar with overlap-aware column layout -- Multi-axis event filtering and search (3 modes) -- Playwright E2E test suite +- Cross-source duplicate detection in both bots and the client (title similarity + time window; optional LLM tie-break via OpenRouter in the Slack bot) +- Recommendation engine wired into the product: ranked Home feed (interest profile mined from scheduled/RSVP'd/created events + explicit category preferences + popularity) and time-range suggestions when drag-selecting in the calendar +- Recurring events (daily/weekly/monthly with interval and end date) rendered as occurrences in the calendar +- Event flyer images (URL-based) shown on cards and detail views +- Week-view calendar with overlap-aware column layout and drag-to-select +- Multi-axis event filtering (categories, event types, date range, time of day, location, availability) and search (3 modes) +- Full theming: light/dark/system, font-size scaling, high-contrast mode, and reduced-motion support applied across the app +- Event reminders via browser notifications ~30 minutes before scheduled events (web, opt-in preference) +- Unit tests (`pnpm test`), Playwright E2E suite, and GitHub Actions CI (typecheck, lint, tests, bundle check) - Responsive web layout with desktop navigation -**In progress:** -- LLM-powered duplicate detection via OpenRouter -- Push notification delivery for event reminders -- Instagram event scraping pipeline +**Not implemented (requires external infrastructure):** +- Email notification delivery — the preference toggle exists, but sending requires an email provider integration +- Native push notifications — requires an EAS build and a push service; reminders are web-only today +- Instagram event scraping ## Contributors diff --git a/apps/client/.gitignore b/apps/client/.gitignore index f8c6c2e..1f7f6cf 100644 --- a/apps/client/.gitignore +++ b/apps/client/.gitignore @@ -21,6 +21,10 @@ expo-env.d.ts # Metro .metro-health-check* +# Playwright +test-results/ +playwright-report/ + # debug npm-debug.* yarn-debug.* diff --git a/apps/client/README.md b/apps/client/README.md index 5569f50..4cbb3d5 100644 --- a/apps/client/README.md +++ b/apps/client/README.md @@ -46,7 +46,7 @@ A comprehensive cross-platform event aggregation and discovery application built - **Profile & Settings** - User profile with stats (events created, saved, interests) - - Account settings (name, email, password change) + - Account settings (name, university; sign-in managed by Google) - Preferences (home page, calendar days, category interests) - Appearance (theme, font size, accessibility) - Logout functionality @@ -134,51 +134,50 @@ apps/client/ ### Prerequisites -- Node.js 18+ -- npm or pnpm -- Expo CLI +- Node.js 18+ +- pnpm 10+ (this is a pnpm workspace — use pnpm, not npm) ### Installation ```bash -# Navigate to client directory -cd apps/client - -# Install dependencies -npm install +# From the repository root +pnpm install -# Start development server -npm start +# Start the development server +cd apps/client +pnpm start ``` ### Running on Different Platforms ```bash # Web -npm run web +pnpm web # iOS (requires Mac) -npm run ios +pnpm ios # Android -npm run android +pnpm android ``` -## 🧪 Demo Accounts +### Useful scripts -Use these accounts to test the application: - -1. **Demo User** - - Email: `demo@cmu.edu` - - Password: `Demo123!` +```bash +pnpm typecheck # TypeScript check +pnpm lint # ESLint +pnpm test:unit # Unit tests (dedupe, recurrence, layout, recommendations) +pnpm test:e2e # Playwright E2E suite (starts the web server itself) +pnpm build # Static web export to dist/ +pnpm seed # Seed mock events into Supabase (needs service-role key) +``` -2. **Student User** - - Email: `student@andrew.cmu.edu` - - Password: `Student123!` +## 🧪 Trying It Out -3. **Test User** - - Email: `test@stanford.edu` - - Password: `Test123!` +Authentication is Google OAuth only, restricted to `@andrew.cmu.edu` / +`@cmu.edu` accounts — there are no password logins. Without Supabase +credentials in `.env`, the app runs in offline demo mode: it loads the +bundled mock events and keeps all changes in local state. ## 🎨 Design System diff --git a/apps/client/app/(auth)/callback.tsx b/apps/client/app/(auth)/callback.tsx index 2db434e..6dae8ee 100644 --- a/apps/client/app/(auth)/callback.tsx +++ b/apps/client/app/(auth)/callback.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, ActivityIndicator, Platform } from 'react-native'; import { router } from 'expo-router'; import { supabase } from '@/lib/supabase'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; /** * OAuth callback route for Google sign-in. @@ -10,6 +12,8 @@ import { supabase } from '@/lib/supabase'; */ export default function AuthCallbackScreen() { const [error, setError] = useState(null); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); useEffect(() => { if (Platform.OS !== 'web' || typeof window === 'undefined') { @@ -29,9 +33,6 @@ export default function AuthCallbackScreen() { } if (!code) { - // #region agent log - if (typeof fetch !== 'undefined') fetch('http://127.0.0.1:7249/ingest/6ce6a0bd-b1d8-4a58-95c8-c0ef781b168b',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'callback.tsx:noCode',message:'Callback hit but no code in URL',data:{url:typeof window!=='undefined'?window.location.href:''},timestamp:Date.now(),hypothesisId:'A'})}).catch(()=>{}); - // #endregion // No code - might already have session or direct visit; go to app const { data: { session } } = await supabase.auth.getSession(); if (session) { @@ -43,17 +44,10 @@ export default function AuthCallbackScreen() { } try { - const { data: exchangeData, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code); - - // #region agent log - if (typeof fetch !== 'undefined') fetch('http://127.0.0.1:7249/ingest/6ce6a0bd-b1d8-4a58-95c8-c0ef781b168b',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'callback.tsx:exchange',message:'After exchangeCodeForSession',data:{hasCode:!!code,hasError:!!exchangeError,hasSession:!!exchangeData?.session,hasProviderToken:!!exchangeData?.session?.provider_token},timestamp:Date.now(),hypothesisId:'B'})}).catch(()=>{}); - // #endregion + const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code); if (exchangeError) { console.error('Code exchange error:', exchangeError); - // #region agent log - if (typeof fetch !== 'undefined') fetch('http://127.0.0.1:7249/ingest/6ce6a0bd-b1d8-4a58-95c8-c0ef781b168b',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'callback.tsx:exchangeError',message:'Code exchange failed',data:{error:exchangeError.message},timestamp:Date.now(),hypothesisId:'B'})}).catch(()=>{}); - // #endregion setError(exchangeError.message); setTimeout(() => router.replace('/(auth)/login'), 3000); return; @@ -77,7 +71,7 @@ export default function AuthCallbackScreen() { {error} ) : ( <> - + Completing sign-in... )} @@ -85,22 +79,23 @@ export default function AuthCallbackScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#F8F9FA', - }, - message: { - marginTop: 16, - fontSize: 16, - color: '#6B7280', - }, - errorText: { - fontSize: 16, - color: '#DC2626', - textAlign: 'center', - paddingHorizontal: 24, - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background, + }, + message: { + marginTop: 16, + fontSize: 16 * fontScale, + color: colors.textSecondary, + }, + errorText: { + fontSize: 16 * fontScale, + color: colors.danger, + textAlign: 'center', + paddingHorizontal: 24, + }, + }); diff --git a/apps/client/app/(auth)/login.tsx b/apps/client/app/(auth)/login.tsx index 7d034cf..d74f14d 100644 --- a/apps/client/app/(auth)/login.tsx +++ b/apps/client/app/(auth)/login.tsx @@ -7,15 +7,18 @@ import { ScrollView, ActivityIndicator, } from 'react-native'; -import { router } from 'expo-router'; import { useAuth } from '@/contexts/AuthContext'; import { useGoogleAuth } from '@/contexts/GoogleAuthContext'; import { useResponsive } from '@/hooks/useResponsive'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; export default function LoginScreen() { const { isLoading, error } = useAuth(); const { googleSignIn, isLoading: isGoogleLoading } = useGoogleAuth(); const { isMobile } = useResponsive(); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const handleGoogleSignIn = async () => { await googleSignIn(); @@ -75,111 +78,112 @@ export default function LoginScreen() { ); } -const styles = StyleSheet.create({ - scrollContent: { - flexGrow: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 24, - }, - scrollContentMobile: { - padding: 16, - }, - card: { - width: '100%', - maxWidth: 440, - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 32, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 4, - }, - cardMobile: { - padding: 24, - borderRadius: 12, - }, - logoContainer: { - alignItems: 'center', - marginBottom: 32, - }, - logo: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: '#FF6B6B', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - logoText: { - fontSize: 40, - }, - title: { - fontSize: 28, - fontWeight: 'bold', - color: '#2C2C2C', - marginBottom: 8, - }, - subtitle: { - fontSize: 16, - color: '#6B7280', - marginBottom: 4, - }, - cmuHint: { - fontSize: 13, - color: '#9CA3AF', - }, - errorContainer: { - backgroundColor: '#FEE2E2', - borderRadius: 8, - padding: 12, - marginBottom: 16, - }, - errorText: { - color: '#DC2626', - fontSize: 14, - textAlign: 'center', - }, - buttonDisabled: { - opacity: 0.6, - }, - googleButton: { - height: 48, - backgroundColor: '#FFFFFF', - borderRadius: 8, - borderWidth: 1, - borderColor: '#D1D5DB', - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 24, - }, - googleIcon: { - fontSize: 20, - marginRight: 8, - fontWeight: 'bold', - color: '#4285F4', - }, - googleButtonText: { - color: '#374151', - fontSize: 16, - fontWeight: '600', - }, - signupContainer: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - }, - signupText: { - fontSize: 14, - color: '#6B7280', - }, - signupLink: { - fontSize: 14, - color: '#FF6B6B', - fontWeight: '600', - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + scrollContentMobile: { + padding: 16, + }, + card: { + width: '100%', + maxWidth: 440, + backgroundColor: colors.surface, + borderRadius: 16, + padding: 32, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, + cardMobile: { + padding: 24, + borderRadius: 12, + }, + logoContainer: { + alignItems: 'center', + marginBottom: 32, + }, + logo: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + logoText: { + fontSize: 40, + }, + title: { + fontSize: 28 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + marginBottom: 8, + }, + subtitle: { + fontSize: 16 * fontScale, + color: colors.textSecondary, + marginBottom: 4, + }, + cmuHint: { + fontSize: 13 * fontScale, + color: colors.textTertiary, + }, + errorContainer: { + backgroundColor: colors.dangerSoft, + borderRadius: 8, + padding: 12, + marginBottom: 16, + }, + errorText: { + color: colors.danger, + fontSize: 14 * fontScale, + textAlign: 'center', + }, + buttonDisabled: { + opacity: 0.6, + }, + googleButton: { + height: 48, + backgroundColor: colors.surface, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + }, + googleIcon: { + fontSize: 20, + marginRight: 8, + fontWeight: 'bold', + color: '#4285F4', + }, + googleButtonText: { + color: colors.textPrimary, + fontSize: 16 * fontScale, + fontWeight: '600', + }, + signupContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + signupText: { + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + signupLink: { + fontSize: 14 * fontScale, + color: colors.primary, + fontWeight: '600', + }, + }); diff --git a/apps/client/app/(auth)/signup.tsx b/apps/client/app/(auth)/signup.tsx index 94822b2..87c8f3d 100644 --- a/apps/client/app/(auth)/signup.tsx +++ b/apps/client/app/(auth)/signup.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; import { Redirect } from 'expo-router'; import { useAuth } from '@/contexts/AuthContext'; import LoginScreen from './login'; diff --git a/apps/client/app/(tabs)/_layout.tsx b/apps/client/app/(tabs)/_layout.tsx index 8a4d0e8..8ccbc3e 100644 --- a/apps/client/app/(tabs)/_layout.tsx +++ b/apps/client/app/(tabs)/_layout.tsx @@ -3,15 +3,14 @@ import React, { useEffect } from 'react'; import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { useAppTheme } from '@/hooks/useAppTheme'; import { useAuth } from '@/contexts/AuthContext'; import { ActivityIndicator, View } from 'react-native'; import { useResponsive } from '@/hooks/useResponsive'; import { DesktopNav } from '@/components/layout/DesktopNav'; export default function TabLayout() { - const colorScheme = useColorScheme(); + const { colors } = useAppTheme(); const { isAuthenticated, isLoading } = useAuth(); const { isDesktop } = useResponsive(); @@ -23,8 +22,8 @@ export default function TabLayout() { if (isLoading) { return ( - - + + ); } @@ -38,10 +37,11 @@ export default function TabLayout() { {isDesktop && } , }} /> - ); diff --git a/apps/client/app/(tabs)/calendar.tsx b/apps/client/app/(tabs)/calendar.tsx index 8cc3caf..fcf8f33 100644 --- a/apps/client/app/(tabs)/calendar.tsx +++ b/apps/client/app/(tabs)/calendar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef } from 'react'; +import React, { useState, useMemo, useRef, useEffect } from 'react'; import { View, StyleSheet, Text, ActivityIndicator, TouchableOpacity, TextInput, ScrollView } from 'react-native'; import { useEvents } from '@/contexts/EventsContext'; import { useCalendar } from '@/hooks/useCalendar'; @@ -15,6 +15,16 @@ import { ResizableSidebar } from '@/components/layout/ResizableSidebar'; import { EventDisplayCard } from '@/components/calendar/EventDisplayCard'; import { Event } from '@/types/event'; import { deleteGoogleCalendarEvent } from '@/lib/googleCalendar'; +import { useUserInterests, getSuggestionsForTimeRange } from '@/hooks/useRecommendations'; +import { expandRecurringEvents, baseEventId } from '@/utils/recurringEvents'; +import { useEventReminders } from '@/hooks/useEventReminders'; +import { storage } from '@/lib/storage'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; + +// Universify event id -> Google Calendar event id map, persisted so +// unscheduling can delete the Google copy even after a reload +const GCAL_MAP_STORAGE_KEY = 'universify_gcal_event_map'; export default function CalendarScreen() { const { events, isLoading } = useEvents(); @@ -22,7 +32,9 @@ export default function CalendarScreen() { const { settings, updateSettings } = useSettings(); const { isMobile, isDesktop } = useResponsive(); const { googleEvents, isLoading: isGoogleLoading, refreshGoogleCalendar } = useGoogleCalendar(); - const { isGoogleAuthenticated, googleSession, providerToken, refreshSession } = useGoogleAuth(); + const { isGoogleAuthenticated, googleSession, providerToken } = useGoogleAuth(); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const calendar = useCalendar(isMobile ? 3 : settings.calendarViewDays); const weekKey = getWeekKey(calendar.currentDate); @@ -41,6 +53,28 @@ export default function CalendarScreen() { // Map Universify event id -> Google Calendar event id when we create in Google on schedule (so we can delete on unschedule) const scheduleEventToGoogleIdRef = useRef>(new Map()); + useEffect(() => { + storage + .getItem(GCAL_MAP_STORAGE_KEY) + .then((raw) => { + if (raw) { + scheduleEventToGoogleIdRef.current = new Map( + Object.entries(JSON.parse(raw) as Record) + ); + } + }) + .catch((err) => console.error('Failed to load Google Calendar event map:', err)); + }, []); + + const persistGcalMap = () => { + storage + .setItem( + GCAL_MAP_STORAGE_KEY, + JSON.stringify(Object.fromEntries(scheduleEventToGoogleIdRef.current)) + ) + .catch((err) => console.error('Failed to persist Google Calendar event map:', err)); + }; + const viewDays = settings.calendarViewDays; // Get days to display based on view mode @@ -79,88 +113,72 @@ export default function CalendarScreen() { }, [googleEvents, displayDays]); // Get events to display in the calendar - // Merge local scheduled events with Google events + // Merge local scheduled events (plus their recurring occurrences within + // the visible range) with Google events const weekEvents = useMemo(() => { // Local events: only show if scheduled const local = events.filter((event) => scheduledEventIds.includes(event.id)); - return [...local, ...googleViewEvents]; - }, [events, scheduledEventIds, googleViewEvents]); - - // Get tags from all scheduled events to calculate relevance - const scheduledEventTags = useMemo(() => { - const scheduledIds = allScheduledEventIds; - const scheduledEventsList = events.filter(e => scheduledIds.includes(e.id)); - const tagCounts: Record = {}; - - scheduledEventsList.forEach(event => { - if (event.tags && Array.isArray(event.tags)) { - event.tags.forEach(tag => { - tagCounts[tag.toLowerCase()] = (tagCounts[tag.toLowerCase()] || 0) + 1; - }); - } - }); - - return tagCounts; - }, [events, allScheduledEventIds]); + if (displayDays.length === 0) return [...local, ...googleViewEvents]; - // Calculate relevance score for an event based on tag overlap with scheduled events - const calculateRelevance = (event: Event): number => { - if (!event.tags || !Array.isArray(event.tags)) return 0; - - let score = 0; - event.tags.forEach(tag => { - const tagLower = tag.toLowerCase(); - if (scheduledEventTags[tagLower]) { - score += scheduledEventTags[tagLower]; - } - }); - - return score; - }; + const viewStart = new Date(displayDays[0]); + viewStart.setHours(0, 0, 0, 0); + const viewEnd = new Date(displayDays[displayDays.length - 1]); + viewEnd.setHours(23, 59, 59, 999); + + return [...expandRecurringEvents(local, viewStart, viewEnd), ...googleViewEvents]; + }, [events, scheduledEventIds, googleViewEvents, displayDays]); + + // Interest profile mined from the events the user has scheduled — the + // recommendation engine extracts title n-grams, categories and time-of-day + // preferences from them. + const engagedEvents = useMemo( + () => events.filter((e) => allScheduledEventIds.includes(e.id)), + [events, allScheduledEventIds] + ); + const { topInterests } = useUserInterests({ events: engagedEvents }); + + // Browser notifications ~30 min before scheduled events (web, opt-in pref) + useEventReminders( + engagedEvents, + currentUser?.preferences.notificationPreferences.eventReminders ?? false + ); // Get all events for sidebar (sorted by date) // Only include Universify events (Google events are already on the calendar) // Only show future/current events (end time >= now) so past events don't clutter the list - // Filter by time selection if active - // When time selection is active, show only top 3 most relevant events + // When a time range is drag-selected, show the top 5 suggested events for + // that window, ranked by the recommendation engine (interest match + + // popularity + how well the event fits the selected range). const sortedEvents = useMemo(() => { const now = new Date(); - let filteredEvents = events.filter( - (event) => new Date(event.endTime) >= now - ); + const upcoming = events.filter((event) => new Date(event.endTime) >= now); if (timeSelection) { const { startDate, endDate } = timeSelection; - filteredEvents = filteredEvents.filter(event => { - const eventStart = new Date(event.startTime); - const eventEnd = new Date(event.endTime); - return eventStart < endDate && eventEnd > startDate; - }); - - if (filteredEvents.length > 0 && Object.keys(scheduledEventTags).length > 0) { - const eventsWithRelevance = filteredEvents.map(event => ({ - event, - relevance: calculateRelevance(event) - })); - eventsWithRelevance.sort((a, b) => { - if (b.relevance !== a.relevance) return b.relevance - a.relevance; - return new Date(a.event.startTime).getTime() - new Date(b.event.startTime).getTime(); - }); - filteredEvents = eventsWithRelevance.slice(0, 3).map(item => item.event); - } + return getSuggestionsForTimeRange( + upcoming, + topInterests, + startDate.toISOString(), + endDate.toISOString(), + 5 + ); } - return filteredEvents.sort((a, b) => - new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + return upcoming.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() ); - }, [events, timeSelection, scheduledEventTags]); + }, [events, timeSelection, topInterests]); const handleEventPress = (event: Event) => { + // Recurring occurrences carry a synthetic "::" id; act on the + // base event so scheduling/expansion always target the real record. + const baseId = baseEventId(event.id); + const base = events.find((e) => e.id === baseId) ?? event; if (isDesktop) { // Toggle expansion - setExpandedCardId(prevId => prevId === event.id ? null : event.id); + setExpandedCardId((prevId) => (prevId === baseId ? null : baseId)); } else { - setSelectedEvent(event); + setSelectedEvent(base); } }; @@ -170,19 +188,10 @@ export default function CalendarScreen() { // Sync to Google Calendar if authenticated if (isGoogleAuthenticated) { - // #region agent log - const _beforeRefresh = { hasProviderToken: !!providerToken, hasGoogleSessionToken: !!googleSession?.provider_token }; - if (typeof fetch !== 'undefined') fetch('http://127.0.0.1:7249/ingest/6ce6a0bd-b1d8-4a58-95c8-c0ef781b168b',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'calendar.tsx:handleScheduleEvent',message:'BEFORE refreshSession',data:_beforeRefresh,timestamp:Date.now(),hypothesisId:'D'})}).catch(()=>{}); - // #endregion - // refreshSession() returns session WITHOUT provider_token (Supabase known issue) - do NOT call it or we overwrite good session const { data: { session } } = await supabase.auth.getSession(); const token = session?.provider_token || providerToken || googleSession?.provider_token; - // #region agent log - if (typeof fetch !== 'undefined') fetch('http://127.0.0.1:7249/ingest/6ce6a0bd-b1d8-4a58-95c8-c0ef781b168b',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'calendar.tsx:handleScheduleEvent',message:'Token check',data:{hasToken:!!token,fromSession:!!session?.provider_token,fromContext:!!providerToken,fromGoogleSession:!!googleSession?.provider_token},timestamp:Date.now(),hypothesisId:'D'})}).catch(()=>{}); - // #endregion - if (!token) { console.error('No provider_token available after refresh'); alert("No Google access token from Supabase. Try signing in again."); @@ -231,7 +240,10 @@ export default function CalendarScreen() { return; } - if (json.id) scheduleEventToGoogleIdRef.current.set(event.id, json.id); + if (json.id) { + scheduleEventToGoogleIdRef.current.set(event.id, json.id); + persistGcalMap(); + } console.log("Created event in Google Calendar:", json); alert("Event added to Google Calendar ✅"); } catch (err) { @@ -254,6 +266,7 @@ export default function CalendarScreen() { if (googleId) { await deleteGoogleCalendarEvent(token, googleId); scheduleEventToGoogleIdRef.current.delete(event.id); + persistGcalMap(); } } await refreshGoogleCalendar(); @@ -339,7 +352,7 @@ export default function CalendarScreen() { {(isLoading || isGoogleLoading) ? ( - + Loading calendar... ) : ( @@ -361,7 +374,7 @@ export default function CalendarScreen() { - {timeSelection ? 'Selected Time Range' : 'All Events'} + {timeSelection ? 'Suggested for This Time' : 'All Events'} {timeSelection && ( {isLoading || isLoadingScheduled ? ( - + Loading events... ) : ( @@ -453,177 +466,179 @@ export default function CalendarScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F8F9FA', - }, - content: { - flex: 1, - flexDirection: 'row', - padding: 24, - gap: 24, - }, - calendarSection: { - flex: 1, - display: 'flex', - flexDirection: 'column', - }, - controlsRow: { +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flex: 1, flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 16, - }, - viewControls: { + padding: 24, + gap: 24, + }, + calendarSection: { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + viewControls: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + viewButton: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 6, + backgroundColor: colors.surfaceAlt, + }, + viewButtonActive: { + backgroundColor: colors.primary, + }, + viewButtonText: { + fontSize: 13 * fontScale, + fontWeight: '500', + color: colors.textSecondary, + }, + viewButtonTextActive: { + color: colors.onPrimary, + }, + customDaysContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginLeft: 8, + backgroundColor: colors.surface, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 8, + paddingVertical: 4, + }, + customDaysInput: { + width: 24, + fontSize: 13 * fontScale, + textAlign: 'center', + padding: 0, + color: colors.textPrimary, + }, + customDaysLabel: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + }, + sidebar: { + flex: 1, + backgroundColor: colors.surface, + }, + sidebarHeader: { + padding: 20, + borderBottomWidth: 1, + borderBottomColor: colors.border, flexDirection: 'row', + justifyContent: 'space-between', alignItems: 'center', - gap: 8, - }, - viewButton: { + }, + sidebarTitle: { + fontSize: 18 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + }, + resetButton: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 6, - backgroundColor: '#F3F4F6', - }, - viewButtonActive: { - backgroundColor: '#FF6B6B', - }, - viewButtonText: { - fontSize: 13, + backgroundColor: colors.surfaceAlt, + borderWidth: 1, + borderColor: colors.border, + }, + resetButtonText: { + fontSize: 13 * fontScale, fontWeight: '500', - color: '#6B7280', - }, - viewButtonTextActive: { - color: '#FFFFFF', - }, - customDaysContainer: { - flexDirection: 'row', + color: colors.textSecondary, + }, + sidebarContent: { + flex: 1, + position: 'relative', + }, + eventsList: { + flex: 1, + padding: 16, + }, + eventsListHidden: { + opacity: 0, + pointerEvents: 'none', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', alignItems: 'center', - gap: 4, - marginLeft: 8, - backgroundColor: '#FFFFFF', - borderRadius: 6, - borderWidth: 1, - borderColor: '#E5E7EB', - paddingHorizontal: 8, - paddingVertical: 4, - }, - customDaysInput: { - width: 24, - fontSize: 13, - textAlign: 'center', - padding: 0, - }, - customDaysLabel: { - fontSize: 12, - color: '#6B7280', - }, - sidebar: { - flex: 1, - backgroundColor: '#FFFFFF', - }, - sidebarHeader: { - padding: 20, - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - sidebarTitle: { - fontSize: 18, - fontWeight: 'bold', - color: '#1F2937', - }, - resetButton: { - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 6, - backgroundColor: '#F3F4F6', - borderWidth: 1, - borderColor: '#E5E7EB', - }, - resetButtonText: { - fontSize: 13, - fontWeight: '500', - color: '#6B7280', - }, - sidebarContent: { - flex: 1, - position: 'relative', - }, - eventsList: { - flex: 1, - padding: 16, - }, - eventsListHidden: { - opacity: 0, - pointerEvents: 'none', - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 32, - }, - loadingText: { - marginTop: 12, - fontSize: 14, - color: '#6B7280', - }, - calendarLoadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#FFFFFF', - }, - calendarLoadingText: { - marginTop: 12, - fontSize: 14, - color: '#6B7280', - }, - eventDetailOverlay: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'center', - alignItems: 'center', - }, - eventDetailBackdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, - eventDetail: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 24, - width: '90%', - maxWidth: 500, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 12, - }, - eventDetailTitle: { - fontSize: 24, - fontWeight: 'bold', - color: '#1F2937', - marginBottom: 12, - }, - eventDetailDescription: { - fontSize: 16, - color: '#6B7280', - marginBottom: 20, - }, - closeButton: { - backgroundColor: '#FF6B6B', - borderRadius: 8, - padding: 12, - alignItems: 'center', - }, - closeButtonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: '600', - }, -}); + padding: 32, + }, + loadingText: { + marginTop: 12, + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + calendarLoadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.surface, + }, + calendarLoadingText: { + marginTop: 12, + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + eventDetailOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + }, + eventDetailBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.overlay, + }, + eventDetail: { + backgroundColor: colors.surface, + borderRadius: 16, + padding: 24, + width: '90%', + maxWidth: 500, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 12, + }, + eventDetailTitle: { + fontSize: 24 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + marginBottom: 12, + }, + eventDetailDescription: { + fontSize: 16 * fontScale, + color: colors.textSecondary, + marginBottom: 20, + }, + closeButton: { + backgroundColor: colors.primary, + borderRadius: 8, + padding: 12, + alignItems: 'center', + }, + closeButtonText: { + color: colors.onPrimary, + fontSize: 16 * fontScale, + fontWeight: '600', + }, + }); diff --git a/apps/client/app/(tabs)/create.tsx b/apps/client/app/(tabs)/create.tsx index aafcbb4..943ccab 100644 --- a/apps/client/app/(tabs)/create.tsx +++ b/apps/client/app/(tabs)/create.tsx @@ -1,16 +1,20 @@ import React from 'react'; -import { View, StyleSheet, Platform } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import { router } from 'expo-router'; import { CreateEventForm } from '@/components/events/CreateEventForm'; import { useEvents } from '@/contexts/EventsContext'; import { useAuth } from '@/contexts/AuthContext'; import { EventFormData } from '@/types/event'; import { useResponsive } from '@/hooks/useResponsive'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; export default function CreateScreen() { const { createEvent } = useEvents(); const { currentUser } = useAuth(); const { isDesktop } = useResponsive(); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const handleCreateEvent = async (eventData: EventFormData) => { if (!currentUser) return; @@ -37,21 +41,22 @@ export default function CreateScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F8F9FA', - }, - content: { - flex: 1, - padding: 24, - maxWidth: 800, - width: '100%', - alignSelf: 'center', - }, - contentDesktop: { - padding: 48, - // On desktop, maybe add some shadow/card effect if desired, - // but keeping it clean and flat as requested "embedded in the layout". - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flex: 1, + padding: 24, + maxWidth: 800, + width: '100%', + alignSelf: 'center', + }, + contentDesktop: { + padding: 48, + // On desktop, maybe add some shadow/card effect if desired, + // but keeping it clean and flat as requested "embedded in the layout". + }, + }); diff --git a/apps/client/app/(tabs)/explore.tsx b/apps/client/app/(tabs)/explore.tsx deleted file mode 100644 index 71518f9..0000000 --- a/apps/client/app/(tabs)/explore.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { Collapsible } from '@/components/ui/collapsible'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Fonts } from '@/constants/theme'; - -export default function TabTwoScreen() { - return ( - - }> - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/apps/client/app/(tabs)/find.tsx b/apps/client/app/(tabs)/find.tsx index c806bcb..72ba97e 100644 --- a/apps/client/app/(tabs)/find.tsx +++ b/apps/client/app/(tabs)/find.tsx @@ -12,30 +12,30 @@ import { EventDetailSidebar } from '@/components/events/EventDetailSidebar'; import { FilterDrawer } from '@/components/layout/FilterDrawer'; import { Event, EventCategory } from '@/types/event'; import { useResponsive } from '@/hooks/useResponsive'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; const QUICK_FILTERS: EventCategory[] = ['Career', 'Food', 'Fun', 'Tech', 'Sports', 'Social']; function FindScreenContent() { const params = useLocalSearchParams(); const { currentUser } = useAuth(); - const { events } = useEvents(); const { filteredEvents, searchQuery, searchMode, selectedCategories, - clubEvents, - socialEvents, activeFilterCount, setSearchQuery, setSearchMode, toggleCategory, - toggleEventType, clearAllFilters, } = useFilters(); const { settings } = useSettings(); const { isMobile } = useResponsive(); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const [selectedEvent, setSelectedEvent] = useState(null); const [showFilters, setShowFilters] = useState(false); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); @@ -175,17 +175,7 @@ function FindScreenContent() { )} {/* Filter Drawer */} - setShowFilters(false)} - selectedCategories={selectedCategories} - onCategoryToggle={toggleCategory} - clubEvents={clubEvents} - socialEvents={socialEvents} - onEventTypeToggle={toggleEventType} - onClearFilters={clearAllFilters} - onApply={() => {}} - /> + setShowFilters(false)} /> {/* Event Detail Sidebar */} + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row', + padding: 16, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: 12, + }, + searchBar: { + flex: 1, + }, + viewToggle: { + flexDirection: 'row', + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + overflow: 'hidden', + }, + viewButton: { + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.surface, + }, + viewButtonActive: { + backgroundColor: colors.primary, + }, + viewIcon: { + fontSize: 18, + }, + quickFilters: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: 12, + }, + filterButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: colors.surfaceAlt, + borderRadius: 8, + gap: 6, + }, + filterIcon: { + fontSize: 16, + }, + filterButtonText: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + filterBadge: { + backgroundColor: colors.primary, + borderRadius: 10, + width: 20, + height: 20, + justifyContent: 'center', + alignItems: 'center', + }, + filterBadgeText: { + fontSize: 11 * fontScale, + fontWeight: 'bold', + color: colors.onPrimary, + }, + quickFiltersContent: { + gap: 8, + }, + quickFilterPill: { + marginRight: 0, + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 8, + }, + clearButtonText: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.primary, + }, + myEventsButton: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: colors.surfaceAlt, + borderRadius: 8, + borderWidth: 2, + borderColor: 'transparent', + }, + myEventsButtonActive: { + backgroundColor: colors.dangerSoft, + borderColor: colors.primary, + }, + myEventsText: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + myEventsTextActive: { + color: colors.primary, + }, + resultsHeader: { + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: colors.surface, + }, + resultsCount: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textSecondary, + }, + listContent: { + padding: 16, + }, + listContentCompact: { + padding: 8, + }, + gridItem: { + flex: 1, + margin: 8, + }, + listItem: { + marginBottom: 0, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + emptyIcon: { + fontSize: 64, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 20 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + marginBottom: 8, + }, + emptyText: { + fontSize: 16 * fontScale, + color: colors.textSecondary, + textAlign: 'center', + }, + }); diff --git a/apps/client/app/(tabs)/index.tsx b/apps/client/app/(tabs)/index.tsx index 342c26c..6f19e7d 100644 --- a/apps/client/app/(tabs)/index.tsx +++ b/apps/client/app/(tabs)/index.tsx @@ -3,6 +3,8 @@ import { View, StyleSheet } from 'react-native'; import { router } from 'expo-router'; import { useEvents } from '@/contexts/EventsContext'; import { useSettings } from '@/contexts/SettingsContext'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; import { useAuth } from '@/contexts/AuthContext'; import { useResponsive } from '@/hooks/useResponsive'; import { FilterProvider, useFilters } from '@/contexts/FilterContext'; @@ -10,48 +12,51 @@ import { RecommendationsList } from '@/components/recommendations/Recommendation import { EventDetailSidebar } from '@/components/events/EventDetailSidebar'; import { FilterDrawer } from '@/components/layout/FilterDrawer'; import { Event } from '@/types/event'; -import { getRandomEvents, getUpcomingEvents } from '@/utils/eventHelpers'; +import { getUpcomingEvents } from '@/utils/eventHelpers'; +import { useUserInterests, rankEventsForUser } from '@/hooks/useRecommendations'; +import { useScheduledEvents, getWeekKey } from '@/hooks/useScheduledEvents'; function HomeScreenContent() { const { events } = useEvents(); const { settings } = useSettings(); const { currentUser } = useAuth(); - const { isMobile, isDesktop } = useResponsive(); - const { - filteredEvents, - selectedCategories, - clubEvents, - socialEvents, - toggleCategory, - toggleEventType, - clearAllFilters, - } = useFilters(); + const { isDesktop } = useResponsive(); + const { filteredEvents } = useFilters(); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const [selectedEvent, setSelectedEvent] = useState(null); const [showFilters, setShowFilters] = useState(false); - // Get recommendations based on user preferences - const recommendations = useMemo(() => { - let recommendedEvents = filteredEvents.length > 0 ? filteredEvents : events; - - // Filter by user's category interests if available - if (currentUser?.preferences.categoryInterests.length) { - const interested = recommendedEvents.filter((event) => - event.categories.some((cat) => - currentUser.preferences.categoryInterests.includes(cat) - ) - ); - if (interested.length > 0) { - recommendedEvents = interested; - } - } + // Events the user engaged with (scheduled, RSVP'd, created) form the + // interest profile that the recommendation engine mines. + const { allScheduledIds } = useScheduledEvents(currentUser?.id, getWeekKey(new Date())); + const engagedEvents = useMemo(() => { + const userId = currentUser?.id; + const createdIds = new Set(currentUser?.createdEvents ?? []); + const scheduledIds = new Set(allScheduledIds); + return events.filter( + (event) => + scheduledIds.has(event.id) || + createdIds.has(event.id) || + (userId != null && event.attendees.some((a) => a.userId === userId)) + ); + }, [events, currentUser, allScheduledIds]); - // Get upcoming events only - recommendedEvents = getUpcomingEvents(recommendedEvents); + const { topInterests } = useUserInterests({ events: engagedEvents }); - // Randomize for variety - return getRandomEvents(recommendedEvents, 20); - }, [events, filteredEvents, currentUser]); + // Ranked recommendations: interest profile + explicit category preferences + // + popularity, over upcoming events (respecting any active filters). + const recommendations = useMemo(() => { + const base = filteredEvents.length > 0 ? filteredEvents : events; + const upcoming = getUpcomingEvents(base); + const ranked = rankEventsForUser( + upcoming, + topInterests, + currentUser?.preferences.categoryInterests ?? [] + ); + return ranked.slice(0, 20); + }, [events, filteredEvents, currentUser, topInterests]); // Check if user's default home page is calendar if (settings.defaultHomePage === 'calendar' && isDesktop) { @@ -70,17 +75,7 @@ function HomeScreenContent() { /> {/* Filter Drawer */} - setShowFilters(false)} - selectedCategories={selectedCategories} - onCategoryToggle={toggleCategory} - clubEvents={clubEvents} - socialEvents={socialEvents} - onEventTypeToggle={toggleEventType} - onClearFilters={clearAllFilters} - onApply={() => {}} - /> + setShowFilters(false)} /> {/* Event Detail Sidebar */} + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + }); diff --git a/apps/client/app/(tabs)/profile.tsx b/apps/client/app/(tabs)/profile.tsx index 7d82ea1..52fa11f 100644 --- a/apps/client/app/(tabs)/profile.tsx +++ b/apps/client/app/(tabs)/profile.tsx @@ -18,6 +18,8 @@ import { useResponsive } from '@/hooks/useResponsive'; import { useSlack } from '@/contexts/SlackContext'; import { supabase } from '@/lib/supabase'; import { Ionicons } from '@expo/vector-icons'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; type ProfileTab = 'activity' | 'account' | 'preferences' | 'appearance'; @@ -32,12 +34,15 @@ export default function ProfileScreen() { const [editingAccount, setEditingAccount] = useState(false); const [editName, setEditName] = useState(currentUser?.name ?? ''); const [editUniversity, setEditUniversity] = useState(currentUser?.university ?? ''); + const currentName = currentUser?.name; + const currentUniversity = currentUser?.university; useEffect(() => { - if (currentUser) { - setEditName(currentUser.name); - setEditUniversity(currentUser.university); - } - }, [currentUser?.name, currentUser?.university]); + if (currentName != null) setEditName(currentName); + if (currentUniversity != null) setEditUniversity(currentUniversity); + }, [currentName, currentUniversity]); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const slackStyles = React.useMemo(() => createSlackStyles(colors, fontScale), [colors, fontScale]); if (!currentUser) { return null; @@ -136,12 +141,9 @@ export default function ProfileScreen() { )} - Password - •••••••• + Sign-in + Managed by Google (CMU account) - router.push('/settings/account')}> - Change - ); @@ -165,7 +167,7 @@ export default function ProfileScreen() { }, }) } - trackColor={{ false: '#767577', true: '#FF6B6B' }} + trackColor={{ false: colors.border, true: colors.primary }} /> @@ -183,7 +185,7 @@ export default function ProfileScreen() { }, }) } - trackColor={{ false: '#767577', true: '#FF6B6B' }} + trackColor={{ false: colors.border, true: colors.primary }} /> @@ -198,7 +200,7 @@ export default function ProfileScreen() { }, }) } - trackColor={{ false: '#767577', true: '#FF6B6B' }} + trackColor={{ false: colors.border, true: colors.primary }} /> @@ -216,7 +218,7 @@ export default function ProfileScreen() { value={botUrlInput} onChangeText={setBotUrlInput} placeholder="http://localhost:3001" - placeholderTextColor="#9CA3AF" + placeholderTextColor={colors.textTertiary} autoCapitalize="none" autoCorrect={false} /> @@ -229,7 +231,7 @@ export default function ProfileScreen() { disabled={slack.isConnecting} > {slack.isConnecting ? ( - + ) : ( {slack.isConnected ? 'Reconnect' : 'Connect'} @@ -254,7 +256,7 @@ export default function ProfileScreen() { {/* Channel selector */} {slack.isConnected && ( <> - Select Channels + Select Channels {slack.isLoadingChannels ? ( ) : slack.channels.length === 0 ? ( @@ -301,7 +303,7 @@ export default function ProfileScreen() { disabled={slack.isImporting || slack.config.selectedChannelIds.length === 0} > {slack.isImporting ? ( - + ) : ( Import Events from Slack @@ -318,7 +320,7 @@ export default function ProfileScreen() { {/* Import status */} {slack.lastImportTime && ( - + Last import: {slack.lastImportTime.toLocaleString()} ({slack.importedCount} events) @@ -330,7 +332,7 @@ export default function ProfileScreen() { @@ -358,7 +360,7 @@ export default function ProfileScreen() { updateSettings({ theme: value ? 'dark' : 'light' })} - trackColor={{ false: '#767577', true: '#FF6B6B' }} + trackColor={{ false: colors.border, true: colors.primary }} /> @@ -366,7 +368,7 @@ export default function ProfileScreen() { updateSettings({ compactView: value })} - trackColor={{ false: '#767577', true: '#FF6B6B' }} + trackColor={{ false: colors.border, true: colors.primary }} /> @@ -407,7 +409,7 @@ export default function ProfileScreen() { {' · '}{type === 'created' ? 'Created' : 'Saved'} - + ))} @@ -440,33 +442,33 @@ export default function ProfileScreen() { style={[styles.desktopMenuItem, activeTab === 'activity' && styles.desktopMenuItemActive]} onPress={() => setActiveTab('activity')} > - + Activity setActiveTab('account')} > - + Account setActiveTab('preferences')} > - + Preferences setActiveTab('appearance')} > - + Appearance - - Log Out + + Log Out @@ -511,27 +513,27 @@ export default function ProfileScreen() { style={styles.menuItem} onPress={() => router.push('/settings/account')} > - + Account Settings - + router.push('/settings/preferences')} > - + Preferences - + router.push('/settings/appearance')} > - + Appearance - + @@ -542,462 +544,464 @@ export default function ProfileScreen() { ); } -const styles = StyleSheet.create({ - // Mobile Styles - container: { - flex: 1, - backgroundColor: '#F8F9FA', - }, - header: { - alignItems: 'center', - padding: 32, - backgroundColor: '#FFFFFF', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - avatar: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: '#FF6B6B', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - avatarText: { - fontSize: 32, - fontWeight: 'bold', - color: '#FFFFFF', - }, - name: { - fontSize: 24, - fontWeight: 'bold', - color: '#1F2937', - marginBottom: 4, - }, - email: { - fontSize: 14, - color: '#6B7280', - marginBottom: 4, - }, - university: { - fontSize: 14, - color: '#9CA3AF', - }, - stats: { - flexDirection: 'row', - backgroundColor: '#FFFFFF', - padding: 20, - marginTop: 8, - }, - statItem: { - flex: 1, - alignItems: 'center', - }, - statValue: { - fontSize: 24, - fontWeight: 'bold', - color: '#1F2937', - marginBottom: 4, - }, - statLabel: { - fontSize: 12, - color: '#6B7280', - }, - statDivider: { - width: 1, - backgroundColor: '#E5E7EB', - }, - menu: { - backgroundColor: '#FFFFFF', - marginTop: 8, - }, - menuItem: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - menuIcon: { - marginRight: 12, - }, - menuText: { - flex: 1, - fontSize: 16, - color: '#1F2937', - }, - logoutButton: { - margin: 16, - padding: 16, - backgroundColor: '#FFFFFF', - borderRadius: 8, - alignItems: 'center', - borderWidth: 1, - borderColor: '#FF6B6B', - }, - logoutText: { - fontSize: 16, - fontWeight: '600', - color: '#FF6B6B', - }, +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + // Mobile Styles + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + alignItems: 'center', + padding: 32, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + avatar: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + avatarText: { + fontSize: 32 * fontScale, + fontWeight: 'bold', + color: colors.onPrimary, + }, + name: { + fontSize: 24 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + marginBottom: 4, + }, + email: { + fontSize: 14 * fontScale, + color: colors.textSecondary, + marginBottom: 4, + }, + university: { + fontSize: 14 * fontScale, + color: colors.textTertiary, + }, + stats: { + flexDirection: 'row', + backgroundColor: colors.surface, + padding: 20, + marginTop: 8, + }, + statItem: { + flex: 1, + alignItems: 'center', + }, + statValue: { + fontSize: 24 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + marginBottom: 4, + }, + statLabel: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + }, + statDivider: { + width: 1, + backgroundColor: colors.border, + }, + menu: { + backgroundColor: colors.surface, + marginTop: 8, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + menuIcon: { + marginRight: 12, + }, + menuText: { + flex: 1, + fontSize: 16 * fontScale, + color: colors.textPrimary, + }, + logoutButton: { + margin: 16, + padding: 16, + backgroundColor: colors.surface, + borderRadius: 8, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.primary, + }, + logoutText: { + fontSize: 16 * fontScale, + fontWeight: '600', + color: colors.primary, + }, - // Desktop Styles - desktopContainer: { - flex: 1, - flexDirection: 'row', - backgroundColor: '#FAFAFA', - width: '100%', - padding: 32, - gap: 32, - }, - desktopSidebar: { - width: 280, - backgroundColor: '#FFFFFF', - borderRadius: 12, - padding: 24, - borderWidth: 1, - borderColor: '#E5E7EB', - alignSelf: 'flex-start', - }, - desktopProfileHeader: { - alignItems: 'flex-start', - marginBottom: 32, - }, - desktopAvatar: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: '#FF6B6B', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - desktopAvatarText: { - fontSize: 32, - fontWeight: 'bold', - color: '#FFFFFF', - }, - desktopName: { - fontSize: 20, - fontWeight: 'bold', - color: '#1F2937', - marginBottom: 4, - }, - desktopEditButton: { - backgroundColor: '#F3F4F6', - paddingVertical: 8, - paddingHorizontal: 16, - borderRadius: 8, - width: '100%', - alignItems: 'center', - }, - desktopEditButtonText: { - fontSize: 14, - fontWeight: '600', - color: '#374151', - }, - desktopMenu: { - gap: 8, - }, - desktopMenuItem: { - flexDirection: 'row', - alignItems: 'center', - padding: 12, - borderRadius: 8, - }, - desktopMenuItemActive: { - backgroundColor: '#FFF1F1', // Light red/orange background - }, - desktopMenuIcon: { - marginRight: 12, - }, - desktopMenuText: { - fontSize: 15, - color: '#374151', - fontWeight: '500', - }, - desktopMenuTextActive: { - color: '#FF6B6B', - fontWeight: '600', - }, - desktopLogoutItem: { - marginTop: 16, - borderTopWidth: 1, - borderTopColor: '#F3F4F6', - paddingTop: 16, - }, - desktopMainContent: { - flex: 1, - }, - desktopStatsRow: { - flexDirection: 'row', - backgroundColor: '#FFFFFF', - padding: 24, - borderRadius: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - marginBottom: 24, - }, - desktopContentSection: { - backgroundColor: '#FFFFFF', - padding: 24, - borderRadius: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - minHeight: 400, - }, - settingsSection: { - backgroundColor: '#FFFFFF', - padding: 24, - borderRadius: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - minHeight: 400, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - color: '#1F2937', - marginBottom: 24, - }, - settingRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: '#F3F4F6', - }, - settingLabelValue: { - flex: 1, - minWidth: 0, - }, - settingLabel: { - fontSize: 15, - color: '#374151', - fontWeight: '500', - marginBottom: 4, - }, - settingValue: { - fontSize: 14, - color: '#6B7280', - }, - settingInput: { - fontSize: 14, - color: '#1F2937', - borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 6, - paddingHorizontal: 10, - paddingVertical: 8, - marginTop: 4, - }, - editRow: { - flexDirection: 'row', - gap: 8, - }, - editButton: { - paddingHorizontal: 12, - paddingVertical: 6, - backgroundColor: '#F3F4F6', - borderRadius: 6, - }, - editButtonText: { - fontSize: 13, - color: '#374151', - fontWeight: '500', - }, - cancelButton: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 6, - backgroundColor: '#E5E7EB', - }, - cancelButtonText: { - fontSize: 13, - color: '#374151', - fontWeight: '500', - }, - placeholderText: { - color: '#9CA3AF', - fontStyle: 'italic', - }, - activityList: { - gap: 0, - }, - activityItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 14, - paddingHorizontal: 0, - borderBottomWidth: 1, - borderBottomColor: '#F3F4F6', - }, - activityItemContent: { - flex: 1, - minWidth: 0, - }, - activityItemTitle: { - fontSize: 15, - fontWeight: '600', - color: '#1F2937', - marginBottom: 2, - }, - activityItemMeta: { - fontSize: 13, - color: '#6B7280', - }, -}); + // Desktop Styles + desktopContainer: { + flex: 1, + flexDirection: 'row', + backgroundColor: colors.background, + width: '100%', + padding: 32, + gap: 32, + }, + desktopSidebar: { + width: 280, + backgroundColor: colors.surface, + borderRadius: 12, + padding: 24, + borderWidth: 1, + borderColor: colors.border, + alignSelf: 'flex-start', + }, + desktopProfileHeader: { + alignItems: 'flex-start', + marginBottom: 32, + }, + desktopAvatar: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + desktopAvatarText: { + fontSize: 32 * fontScale, + fontWeight: 'bold', + color: colors.onPrimary, + }, + desktopName: { + fontSize: 20 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + marginBottom: 4, + }, + desktopEditButton: { + backgroundColor: colors.surfaceAlt, + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + width: '100%', + alignItems: 'center', + }, + desktopEditButtonText: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + desktopMenu: { + gap: 8, + }, + desktopMenuItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 8, + }, + desktopMenuItemActive: { + backgroundColor: colors.dangerSoft, // Light red/orange background + }, + desktopMenuIcon: { + marginRight: 12, + }, + desktopMenuText: { + fontSize: 15 * fontScale, + color: colors.textPrimary, + fontWeight: '500', + }, + desktopMenuTextActive: { + color: colors.primary, + fontWeight: '600', + }, + desktopLogoutItem: { + marginTop: 16, + borderTopWidth: 1, + borderTopColor: colors.surfaceAlt, + paddingTop: 16, + }, + desktopMainContent: { + flex: 1, + }, + desktopStatsRow: { + flexDirection: 'row', + backgroundColor: colors.surface, + padding: 24, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + marginBottom: 24, + }, + desktopContentSection: { + backgroundColor: colors.surface, + padding: 24, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + minHeight: 400, + }, + settingsSection: { + backgroundColor: colors.surface, + padding: 24, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + minHeight: 400, + }, + sectionTitle: { + fontSize: 18 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 24, + }, + settingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: colors.surfaceAlt, + }, + settingLabelValue: { + flex: 1, + minWidth: 0, + }, + settingLabel: { + fontSize: 15 * fontScale, + color: colors.textPrimary, + fontWeight: '500', + marginBottom: 4, + }, + settingValue: { + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + settingInput: { + fontSize: 14 * fontScale, + color: colors.textPrimary, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 8, + marginTop: 4, + }, + editRow: { + flexDirection: 'row', + gap: 8, + }, + editButton: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: colors.surfaceAlt, + borderRadius: 6, + }, + editButtonText: { + fontSize: 13 * fontScale, + color: colors.textPrimary, + fontWeight: '500', + }, + cancelButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + backgroundColor: colors.border, + }, + cancelButtonText: { + fontSize: 13 * fontScale, + color: colors.textPrimary, + fontWeight: '500', + }, + placeholderText: { + color: colors.textTertiary, + fontStyle: 'italic', + }, + activityList: { + gap: 0, + }, + activityItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 0, + borderBottomWidth: 1, + borderBottomColor: colors.surfaceAlt, + }, + activityItemContent: { + flex: 1, + minWidth: 0, + }, + activityItemTitle: { + fontSize: 15 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 2, + }, + activityItemMeta: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + }, + }); -const slackStyles = StyleSheet.create({ - divider: { - height: 1, - backgroundColor: '#E5E7EB', - marginTop: 20, - marginBottom: 8, - }, - description: { - fontSize: 13, - color: '#6B7280', - marginBottom: 12, - }, - inputRow: { - flexDirection: 'row', - gap: 8, - marginBottom: 8, - }, - input: { - flex: 1, - backgroundColor: '#FFFFFF', - borderRadius: 8, - borderWidth: 1, - borderColor: '#D1D5DB', - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 14, - color: '#1F2937', - }, - button: { - backgroundColor: '#611f69', - borderRadius: 8, - paddingHorizontal: 16, - paddingVertical: 10, - justifyContent: 'center', - alignItems: 'center', - minWidth: 90, - }, - buttonDisabled: { - opacity: 0.5, - }, - buttonText: { - color: '#FFFFFF', - fontWeight: '600', - fontSize: 14, - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 4, - paddingVertical: 4, - }, - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: '#10B981', - }, - statusText: { - fontSize: 13, - color: '#10B981', - fontWeight: '500', - }, - errorRow: { - backgroundColor: '#FEF2F2', - borderRadius: 8, - padding: 12, - marginVertical: 4, - }, - errorText: { - fontSize: 13, - color: '#DC2626', - }, - channelList: { - backgroundColor: '#FFFFFF', - borderRadius: 8, - overflow: 'hidden', - marginBottom: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - }, - channel: { - flexDirection: 'row', - alignItems: 'center', - padding: 12, - borderBottomWidth: 1, - borderBottomColor: '#F3F4F6', - }, - channelSelected: { - backgroundColor: '#F5F0F6', - }, - channelName: { - fontSize: 14, - fontWeight: '500', - color: '#1F2937', - }, - channelNameSelected: { - color: '#611f69', - fontWeight: '600', - }, - channelPurpose: { - fontSize: 12, - color: '#9CA3AF', - marginTop: 2, - }, - checkbox: { - width: 22, - height: 22, - borderRadius: 4, - borderWidth: 2, - borderColor: '#D1D5DB', - justifyContent: 'center', - alignItems: 'center', - }, - checkboxChecked: { - borderColor: '#611f69', - backgroundColor: '#611f69', - }, - checkboxMark: { - color: '#FFFFFF', - fontSize: 13, - fontWeight: 'bold', - }, - importButton: { - backgroundColor: '#611f69', - borderRadius: 8, - paddingVertical: 14, - alignItems: 'center', - marginBottom: 8, - }, - importButtonText: { - color: '#FFFFFF', - fontWeight: '600', - fontSize: 15, - }, - clearButton: { - borderRadius: 8, - borderWidth: 1, - borderColor: '#DC2626', - paddingVertical: 10, - alignItems: 'center', - marginTop: 8, - marginBottom: 16, - }, - clearButtonText: { - color: '#DC2626', - fontWeight: '500', - fontSize: 14, - }, -}); +const createSlackStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + divider: { + height: 1, + backgroundColor: colors.border, + marginTop: 20, + marginBottom: 8, + }, + description: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + marginBottom: 12, + }, + inputRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 8, + }, + input: { + flex: 1, + backgroundColor: colors.surface, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14 * fontScale, + color: colors.textPrimary, + }, + button: { + backgroundColor: '#611f69', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 10, + justifyContent: 'center', + alignItems: 'center', + minWidth: 90, + }, + buttonDisabled: { + opacity: 0.5, + }, + buttonText: { + color: colors.onPrimary, + fontWeight: '600', + fontSize: 14 * fontScale, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + paddingVertical: 4, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.success, + }, + statusText: { + fontSize: 13 * fontScale, + color: colors.success, + fontWeight: '500', + }, + errorRow: { + backgroundColor: colors.dangerSoft, + borderRadius: 8, + padding: 12, + marginVertical: 4, + }, + errorText: { + fontSize: 13 * fontScale, + color: colors.danger, + }, + channelList: { + backgroundColor: colors.surface, + borderRadius: 8, + overflow: 'hidden', + marginBottom: 12, + borderWidth: 1, + borderColor: colors.border, + }, + channel: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderBottomWidth: 1, + borderBottomColor: colors.surfaceAlt, + }, + channelSelected: { + backgroundColor: '#F5F0F6', + }, + channelName: { + fontSize: 14 * fontScale, + fontWeight: '500', + color: colors.textPrimary, + }, + channelNameSelected: { + color: '#611f69', + fontWeight: '600', + }, + channelPurpose: { + fontSize: 12 * fontScale, + color: colors.textTertiary, + marginTop: 2, + }, + checkbox: { + width: 22, + height: 22, + borderRadius: 4, + borderWidth: 2, + borderColor: colors.border, + justifyContent: 'center', + alignItems: 'center', + }, + checkboxChecked: { + borderColor: '#611f69', + backgroundColor: '#611f69', + }, + checkboxMark: { + color: colors.onPrimary, + fontSize: 13 * fontScale, + fontWeight: 'bold', + }, + importButton: { + backgroundColor: '#611f69', + borderRadius: 8, + paddingVertical: 14, + alignItems: 'center', + marginBottom: 8, + }, + importButtonText: { + color: colors.onPrimary, + fontWeight: '600', + fontSize: 15 * fontScale, + }, + clearButton: { + borderRadius: 8, + borderWidth: 1, + borderColor: colors.danger, + paddingVertical: 10, + alignItems: 'center', + marginTop: 8, + marginBottom: 16, + }, + clearButtonText: { + color: colors.danger, + fontWeight: '500', + fontSize: 14 * fontScale, + }, + }); diff --git a/apps/client/app/_layout.tsx b/apps/client/app/_layout.tsx index 3215e4c..1f16774 100644 --- a/apps/client/app/_layout.tsx +++ b/apps/client/app/_layout.tsx @@ -26,7 +26,6 @@ function ThemedStack() { - diff --git a/apps/client/app/demo-recs.tsx b/apps/client/app/demo-recs.tsx deleted file mode 100644 index e7000bc..0000000 --- a/apps/client/app/demo-recs.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react' -import CalendarWithRecs from '@/components/calendar/CalendarWithRecs' -import currentWeekEvents from '@/data/currentWeekEvents.json' -import useUserInterests from '@/hooks/useRecommendations' - -const eventsJson = (currentWeekEvents as any[]).map(e => ({ - id: e.id, - title: e.title, - startTime: e.startTime, - endTime: e.endTime, - categories: e.categories || [], - tags: e.tags || e.categories || [], - rsvpCounts: e.rsvpCounts || null, -})) - -function InterestsPanel({ events }: { events: any[] }) { - const [search, setSearch] = React.useState('') - const { - topInterests, - interests, - allEventsJson, - recommendedEvents, - isLoading, - } = useUserInterests({ events, search }) - - return ( - - ) -} - -export default function DemoRecsRoute() { - return ( -
-
- -
- -
- ) -} diff --git a/apps/client/app/event/[id].tsx b/apps/client/app/event/[id].tsx new file mode 100644 index 0000000..5585e68 --- /dev/null +++ b/apps/client/app/event/[id].tsx @@ -0,0 +1,364 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + ActivityIndicator, + Image, +} from 'react-native'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useEvents } from '@/contexts/EventsContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { CategoryPill } from '@/components/ui/CategoryPill'; +import { Button } from '@/components/ui/Button'; +import { Event, RSVPStatus } from '@/types/event'; +import { fetchEventAPI } from '@/lib/api'; +import { formatDate, formatTimeRange } from '@/utils/dateHelpers'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; + +export default function EventDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { getEventById, updateRSVP, getRSVPStatus } = useEvents(); + const { currentUser } = useAuth(); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + + const contextEvent = id ? getEventById(id) : undefined; + const [fetchedEvent, setFetchedEvent] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isUpdatingRSVP, setIsUpdatingRSVP] = useState(false); + + const event = contextEvent ?? fetchedEvent ?? undefined; + + // Deep links can land here before the events list loads (or reference an + // event that isn't in the current list) — fetch it directly as a fallback. + useEffect(() => { + if (contextEvent || !id) return; + let cancelled = false; + setIsLoading(true); + fetchEventAPI(id) + .then((result) => { + if (!cancelled) setFetchedEvent(result); + }) + .catch(() => { + // Offline mode or fetch failure: fall through to not-found UI + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [contextEvent, id]); + + const userRSVP: RSVPStatus = + event && currentUser ? getRSVPStatus(event.id, currentUser.id) : null; + + const handleRSVP = async (status: RSVPStatus) => { + if (!event || !currentUser) return; + setIsUpdatingRSVP(true); + try { + await updateRSVP(event.id, currentUser.id, status); + } finally { + setIsUpdatingRSVP(false); + } + }; + + if (isLoading) { + return ( + + + + ); + } + + if (!event) { + return ( + + Event not found + + This event may have been removed or the link is invalid. + + - - - - - - -
-

Suggestions ({suggestions.length})

- {(!selStartISO || !selEndISO) &&
Select a range to see suggestions.
} - {suggestions.map(s => ( -
-
{s.name}
-
{new Date(s.start_ts).toLocaleString()} – {new Date(s.end_ts).toLocaleString()}
-
{(s.tags||[]).join(', ')}
-
- ))} -
- - - console.log('Picked', ev)} - /> - - ) -} diff --git a/apps/client/components/calendar/EventDisplayCard.tsx b/apps/client/components/calendar/EventDisplayCard.tsx index d040021..e8a2c88 100644 --- a/apps/client/components/calendar/EventDisplayCard.tsx +++ b/apps/client/components/calendar/EventDisplayCard.tsx @@ -3,6 +3,8 @@ import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-nati import { Event } from '@/types/event'; import { formatTime, formatDate } from '@/utils/dateHelpers'; import { Ionicons } from '@expo/vector-icons'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface EventDisplayCardProps { event: Event; @@ -21,8 +23,8 @@ export const EventDisplayCard: React.FC = ({ onUnschedule, onToggleExpand, }) => { - const startDate = new Date(event.startTime); - const endDate = new Date(event.endTime); + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); if (isExpanded) { return ( @@ -39,7 +41,7 @@ export const EventDisplayCard: React.FC = ({ onUnschedule(); }} > - +
) ) : ( @@ -51,19 +53,19 @@ export const EventDisplayCard: React.FC = ({ onSchedule(); }} > - + ) )} - + {formatDate(event.startTime)} - + {formatTime(event.startTime)} - {formatTime(event.endTime)} @@ -71,7 +73,7 @@ export const EventDisplayCard: React.FC = ({ {event.location && ( - + {event.location} )} @@ -124,7 +126,7 @@ export const EventDisplayCard: React.FC = ({ onUnschedule(); }} > - + ) ) : ( @@ -136,21 +138,21 @@ export const EventDisplayCard: React.FC = ({ onSchedule(); }} > - + ) )} - + {formatDate(event.startTime)} - + {formatTime(event.startTime)} - {formatTime(event.endTime)} @@ -158,7 +160,7 @@ export const EventDisplayCard: React.FC = ({ {event.location && ( - + {event.location} @@ -181,131 +183,131 @@ export const EventDisplayCard: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - backgroundColor: '#FFFFFF', - borderRadius: 8, - padding: 12, - marginBottom: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 2, - elevation: 1, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 8, - }, - title: { - fontSize: 14, - fontWeight: '600', - color: '#1F2937', - flex: 1, - marginRight: 8, - }, - iconButton: { - width: 24, - height: 24, - justifyContent: 'center', - alignItems: 'center', - }, - infoRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 6, - gap: 6, - }, - infoText: { - fontSize: 12, - color: '#6B7280', - flex: 1, - }, - categoriesContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - marginTop: 8, - gap: 6, - }, - categoryBadge: { - backgroundColor: '#F3F4F6', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - }, - categoryText: { - fontSize: 10, - color: '#6B7280', - }, - moreCategories: { - fontSize: 10, - color: '#9CA3AF', - alignSelf: 'center', - }, - expandedContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: '#FFFFFF', - borderRadius: 8, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 12, - elevation: 8, - zIndex: 1000, - }, - expandedContent: { - flex: 1, - padding: 16, - }, - expandedHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 16, - }, - expandedTitle: { - fontSize: 20, - fontWeight: 'bold', - color: '#1F2937', - flex: 1, - marginRight: 8, - }, - descriptionSection: { - marginTop: 16, - paddingTop: 16, - borderTopWidth: 1, - borderTopColor: '#E5E7EB', - }, - descriptionTitle: { - fontSize: 14, - fontWeight: '600', - color: '#1F2937', - marginBottom: 8, - }, - descriptionText: { - fontSize: 14, - color: '#6B7280', - lineHeight: 20, - }, - closeButton: { - backgroundColor: '#FF6B6B', - borderRadius: 8, - padding: 12, - alignItems: 'center', - marginTop: 20, - }, - closeButtonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: '600', - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + backgroundColor: colors.surface, + borderRadius: 8, + padding: 12, + marginBottom: 12, + borderWidth: 1, + borderColor: colors.border, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + title: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + flex: 1, + marginRight: 8, + }, + iconButton: { + width: 24, + height: 24, + justifyContent: 'center', + alignItems: 'center', + }, + infoRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + gap: 6, + }, + infoText: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + flex: 1, + }, + categoriesContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + marginTop: 8, + gap: 6, + }, + categoryBadge: { + backgroundColor: colors.surfaceAlt, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + }, + categoryText: { + fontSize: 10 * fontScale, + color: colors.textSecondary, + }, + moreCategories: { + fontSize: 10 * fontScale, + color: colors.textTertiary, + alignSelf: 'center', + }, + expandedContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: colors.surface, + borderRadius: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + zIndex: 1000, + }, + expandedContent: { + flex: 1, + padding: 16, + }, + expandedHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 16, + }, + expandedTitle: { + fontSize: 20 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + flex: 1, + marginRight: 8, + }, + descriptionSection: { + marginTop: 16, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + descriptionTitle: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 8, + }, + descriptionText: { + fontSize: 14 * fontScale, + color: colors.textSecondary, + lineHeight: 20, + }, + closeButton: { + backgroundColor: colors.primary, + borderRadius: 8, + padding: 12, + alignItems: 'center', + marginTop: 20, + }, + closeButtonText: { + color: colors.onPrimary, + fontSize: 16 * fontScale, + fontWeight: '600', + }, + }); diff --git a/apps/client/components/calendar/TimeColumn.tsx b/apps/client/components/calendar/TimeColumn.tsx index 1a2430c..90fac22 100644 --- a/apps/client/components/calendar/TimeColumn.tsx +++ b/apps/client/components/calendar/TimeColumn.tsx @@ -1,7 +1,12 @@ import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; export const TimeColumn: React.FC = () => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + // Generate time slots from 12 AM to 11 PM const timeSlots = Array.from({ length: 24 }).map((_, hour) => { const period = hour >= 12 ? 'PM' : 'AM'; @@ -20,25 +25,26 @@ export const TimeColumn: React.FC = () => { ); }; -const styles = StyleSheet.create({ - container: { - width: 60, - backgroundColor: '#FAFAFA', - borderRightWidth: 1, - borderRightColor: '#E5E7EB', - }, - timeSlot: { - height: 60, - justifyContent: 'flex-start', - paddingTop: 4, - paddingRight: 8, - alignItems: 'flex-end', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - timeText: { - fontSize: 11, - color: '#6B7280', - fontWeight: '500', - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + width: 60, + backgroundColor: colors.surfaceAlt, + borderRightWidth: 1, + borderRightColor: colors.border, + }, + timeSlot: { + height: 60, + justifyContent: 'flex-start', + paddingTop: 4, + paddingRight: 8, + alignItems: 'flex-end', + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + timeText: { + fontSize: 11 * fontScale, + color: colors.textSecondary, + fontWeight: '500', + }, + }); diff --git a/apps/client/components/calendar/WeekView.tsx b/apps/client/components/calendar/WeekView.tsx index 7eec821..7835403 100644 --- a/apps/client/components/calendar/WeekView.tsx +++ b/apps/client/components/calendar/WeekView.tsx @@ -4,6 +4,8 @@ import { Event } from '@/types/event'; import { layoutEvents, EventWithLayout } from '@/utils/eventLayout'; import { getEventsByDay } from '@/utils/eventHelpers'; import { formatTime } from '@/utils/dateHelpers'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface WeekViewProps { weekDays: Date[]; @@ -38,6 +40,8 @@ const TIME_COLUMN_WIDTH = 60; const MIN_DAY_WIDTH = 140; // Increased min width for better readability export const WeekView: React.FC = ({ weekDays, events, onEventPress, onSelectionChange, externalSelection }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const [dayColumnWidth, setDayColumnWidth] = useState(MIN_DAY_WIDTH); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState<{ dayIndex: number; timeIndex: number; y: number } | null>(null); @@ -370,8 +374,7 @@ export const WeekView: React.FC = ({ weekDays, events, onEventPre style={styles.horizontalScroll} contentContainerStyle={{ flexGrow: 1 }} > - = ({ weekDays, events, onEventPre {/* Body Grid */} - {/* Selection Box Overlay */} {(isDragging || finalSelection) && (() => { @@ -465,118 +473,119 @@ export const WeekView: React.FC = ({ weekDays, events, onEventPre ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#FFFFFF', - borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 8, - overflow: 'hidden', - }, - horizontalScroll: { - flex: 1, - }, - verticalScroll: { - flex: 1, - }, - headerRow: { - flexDirection: 'row', - backgroundColor: '#FFFFFF', // Must have background to hide scrolling content - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - zIndex: 100, - ...Platform.select({ - web: { - boxShadow: '0 1px 2px rgba(0,0,0,0.05)', // Subtle shadow for depth - }, - default: { - elevation: 2, - } - }) - }, - timeColumnHeader: { - width: TIME_COLUMN_WIDTH, - borderRightWidth: 1, - borderRightColor: '#E5E7EB', - backgroundColor: '#FAFAFA', - }, - dayHeader: { - flex: 1, - minWidth: MIN_DAY_WIDTH, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 4, - borderRightWidth: 1, - borderRightColor: '#E5E7EB', - backgroundColor: '#FFFFFF', - }, - dayName: { - fontSize: 11, - fontWeight: '600', - color: '#6B7280', - letterSpacing: 0.5, - marginBottom: 4, - }, - dayNumber: { - fontSize: 20, - fontWeight: '300', - color: '#1F2937', - }, - gridBody: { - flexDirection: 'column', - }, - timeRow: { - flexDirection: 'row', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - timeSlot: { - width: TIME_COLUMN_WIDTH, - height: HOUR_HEIGHT, - justifyContent: 'flex-start', - paddingTop: 4, - paddingRight: 8, - alignItems: 'flex-end', - borderRightWidth: 1, - borderRightColor: '#E5E7EB', - backgroundColor: '#FAFAFA', - }, - timeText: { - fontSize: 11, - color: '#6B7280', - fontWeight: '500', - }, - dayCell: { - flex: 1, - minWidth: MIN_DAY_WIDTH, - height: HOUR_HEIGHT, // Must match timeSlot height - borderRightWidth: 1, - borderRightColor: '#E5E7EB', - position: 'relative', - }, - eventBlock: { - position: 'absolute', - borderRadius: 4, - padding: 6, - zIndex: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, - elevation: 2, - overflow: 'hidden', - }, - eventTitle: { - fontSize: 12, - fontWeight: '700', - color: '#FFFFFF', - marginBottom: 2, - }, - eventTime: { - fontSize: 10, - color: 'rgba(255, 255, 255, 0.9)', - fontWeight: '500', - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + overflow: 'hidden', + }, + horizontalScroll: { + flex: 1, + }, + verticalScroll: { + flex: 1, + }, + headerRow: { + flexDirection: 'row', + backgroundColor: colors.surface, // Must have background to hide scrolling content + borderBottomWidth: 1, + borderBottomColor: colors.border, + zIndex: 100, + ...Platform.select({ + web: { + boxShadow: '0 1px 2px rgba(0,0,0,0.05)', // Subtle shadow for depth + }, + default: { + elevation: 2, + } + }) + }, + timeColumnHeader: { + width: TIME_COLUMN_WIDTH, + borderRightWidth: 1, + borderRightColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + dayHeader: { + flex: 1, + minWidth: MIN_DAY_WIDTH, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 4, + borderRightWidth: 1, + borderRightColor: colors.border, + backgroundColor: colors.surface, + }, + dayName: { + fontSize: 11 * fontScale, + fontWeight: '600', + color: colors.textSecondary, + letterSpacing: 0.5, + marginBottom: 4, + }, + dayNumber: { + fontSize: 20 * fontScale, + fontWeight: '300', + color: colors.textPrimary, + }, + gridBody: { + flexDirection: 'column', + }, + timeRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + timeSlot: { + width: TIME_COLUMN_WIDTH, + height: HOUR_HEIGHT, + justifyContent: 'flex-start', + paddingTop: 4, + paddingRight: 8, + alignItems: 'flex-end', + borderRightWidth: 1, + borderRightColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + timeText: { + fontSize: 11 * fontScale, + color: colors.textSecondary, + fontWeight: '500', + }, + dayCell: { + flex: 1, + minWidth: MIN_DAY_WIDTH, + height: HOUR_HEIGHT, // Must match timeSlot height + borderRightWidth: 1, + borderRightColor: colors.border, + position: 'relative', + }, + eventBlock: { + position: 'absolute', + borderRadius: 4, + padding: 6, + zIndex: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + overflow: 'hidden', + }, + eventTitle: { + fontSize: 12 * fontScale, + fontWeight: '700', + color: colors.onPrimary, + marginBottom: 2, + }, + eventTime: { + fontSize: 10 * fontScale, + color: 'rgba(255, 255, 255, 0.9)', + fontWeight: '500', + }, + }); diff --git a/apps/client/components/events/CreateEventForm.tsx b/apps/client/components/events/CreateEventForm.tsx index 58084dd..df2f296 100644 --- a/apps/client/components/events/CreateEventForm.tsx +++ b/apps/client/components/events/CreateEventForm.tsx @@ -18,6 +18,8 @@ import { validateLocation, validateTimeRange, } from '@/utils/validation'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; // TOGGLE FOR AUTO-FILL BUTTON const SHOW_AUTO_FILL = true; @@ -61,6 +63,8 @@ export const CreateEventForm: React.FC = ({ onSubmit, isSubmitting = false, }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const [errors, setErrors] = useState>({}); // Form state @@ -79,6 +83,12 @@ export const CreateEventForm: React.FC = ({ const [attendeeVisibility, setAttendeeVisibility] = useState<'public' | 'private'>('public'); const [selectedColor, setSelectedColor] = useState(COLOR_OPTIONS[0]); const [tags, setTags] = useState(''); + const [imageUrl, setImageUrl] = useState(''); + const [recurringFrequency, setRecurringFrequency] = useState< + 'none' | 'daily' | 'weekly' | 'monthly' + >('none'); + const [recurringInterval, setRecurringInterval] = useState('1'); + const [recurringEndDate, setRecurringEndDate] = useState(''); const autoFillForm = () => { const today = new Date(); @@ -148,6 +158,20 @@ export const CreateEventForm: React.FC = ({ newErrors.capacity = 'Capacity must be a positive number'; } + if (imageUrl && !/^https?:\/\/.+/.test(imageUrl.trim())) { + newErrors.imageUrl = 'Image must be an http(s) URL'; + } + + if (recurringFrequency !== 'none') { + const interval = Number(recurringInterval); + if (isNaN(interval) || interval < 1 || !Number.isInteger(interval)) { + newErrors.recurringInterval = 'Repeat interval must be a whole number ≥ 1'; + } + if (recurringEndDate && !/^\d{4}-\d{2}-\d{2}$/.test(recurringEndDate)) { + newErrors.recurringEndDate = 'Use YYYY-MM-DD'; + } + } + setErrors(newErrors); return Object.keys(newErrors).length === 0; }; @@ -173,6 +197,15 @@ export const CreateEventForm: React.FC = ({ attendeeVisibility, color: selectedColor, tags: tags.split(',').map((t) => t.trim()).filter(Boolean), + imageUrl: imageUrl.trim() || undefined, + recurring: + recurringFrequency !== 'none' + ? { + frequency: recurringFrequency, + interval: Number(recurringInterval) || 1, + endDate: recurringEndDate || undefined, + } + : undefined, }; await onSubmit(eventData); @@ -264,6 +297,71 @@ export const CreateEventForm: React.FC = ({ + {/* Repeat Section */} + + Repeat + + {( + [ + { id: 'none', label: 'Does not repeat' }, + { id: 'daily', label: 'Daily' }, + { id: 'weekly', label: 'Weekly' }, + { id: 'monthly', label: 'Monthly' }, + ] as const + ).map((option) => ( + setRecurringFrequency(option.id)} + > + + {option.label} + + + ))} + + + {recurringFrequency !== 'none' && ( + + + + + + + + + )} + + {/* Location Section */} Location @@ -277,6 +375,20 @@ export const CreateEventForm: React.FC = ({ /> + {/* Image Section */} + + Event Image + + + {/* Categories Section */} Categories * @@ -308,8 +420,8 @@ export const CreateEventForm: React.FC = ({ @@ -321,8 +433,8 @@ export const CreateEventForm: React.FC = ({ @@ -377,131 +489,157 @@ export const CreateEventForm: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#FFFFFF', - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 24, - paddingHorizontal: 4, - }, - title: { - fontSize: 28, - fontWeight: 'bold', - color: '#1F2937', - }, - autoFillButton: { - paddingVertical: 8, - paddingHorizontal: 16, - backgroundColor: '#F3F4F6', - borderRadius: 8, - }, - autoFillText: { - fontSize: 13, - color: '#6B7280', - fontWeight: '600', - }, - scrollContent: { - paddingBottom: 40, - }, - section: { - marginBottom: 32, - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 20, - borderWidth: 1, - borderColor: '#F3F4F6', - }, - shadow: { - // Soft shadow for professional look - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.03, - shadowRadius: 8, - elevation: 2, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - color: '#1F2937', - marginBottom: 20, - }, - input: { - backgroundColor: '#FFFFFF', - }, - textArea: { - height: 100, - textAlignVertical: 'top', - }, - row: { - flexDirection: 'row', - gap: 16, - }, - categoryGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - errorText: { - fontSize: 12, - color: '#DC2626', - marginTop: 4, - }, - switchRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: '#F3F4F6', - }, - switchLabel: { - flex: 1, - marginRight: 12, - }, - switchText: { - fontSize: 16, - color: '#1F2937', - fontWeight: '500', - }, - switchSubtext: { - fontSize: 13, - color: '#6B7280', - marginTop: 4, - }, - colorGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 16, - }, - colorOption: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: 'transparent', - }, - colorOptionSelected: { - borderColor: '#1F2937', - transform: [{ scale: 1.1 }], - }, - colorCheckmark: { - fontSize: 22, - color: '#FFFFFF', - fontWeight: 'bold', - }, - footer: { - marginTop: 24, - }, - submitButton: { - width: '100%', - height: 56, - borderRadius: 12, - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.surface, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 24, + paddingHorizontal: 4, + }, + title: { + fontSize: 28 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + }, + autoFillButton: { + paddingVertical: 8, + paddingHorizontal: 16, + backgroundColor: colors.surfaceAlt, + borderRadius: 8, + }, + autoFillText: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + fontWeight: '600', + }, + scrollContent: { + paddingBottom: 40, + }, + section: { + marginBottom: 32, + backgroundColor: colors.surface, + borderRadius: 16, + padding: 20, + borderWidth: 1, + borderColor: colors.surfaceAlt, + }, + shadow: { + // Soft shadow for professional look + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.03, + shadowRadius: 8, + elevation: 2, + }, + sectionTitle: { + fontSize: 18 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 20, + }, + recurringOptions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 16, + }, + recurringOption: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 14, + }, + recurringOptionActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + recurringOptionText: { + fontSize: 14 * fontScale, + fontWeight: '500', + color: colors.textPrimary, + }, + recurringOptionTextActive: { + color: colors.onPrimary, + }, + input: { + backgroundColor: colors.surface, + }, + textArea: { + height: 100, + textAlignVertical: 'top', + }, + row: { + flexDirection: 'row', + gap: 16, + }, + categoryGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + errorText: { + fontSize: 12 * fontScale, + color: colors.danger, + marginTop: 4, + }, + switchRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: colors.surfaceAlt, + }, + switchLabel: { + flex: 1, + marginRight: 12, + }, + switchText: { + fontSize: 16 * fontScale, + color: colors.textPrimary, + fontWeight: '500', + }, + switchSubtext: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + marginTop: 4, + }, + colorGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 16, + }, + colorOption: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + colorOptionSelected: { + borderColor: colors.textPrimary, + transform: [{ scale: 1.1 }], + }, + colorCheckmark: { + fontSize: 22 * fontScale, + color: colors.onPrimary, + fontWeight: 'bold', + }, + footer: { + marginTop: 24, + }, + submitButton: { + width: '100%', + height: 56, + borderRadius: 12, + }, + }); diff --git a/apps/client/components/events/EventCard.tsx b/apps/client/components/events/EventCard.tsx index f5d9fbf..ecac2c5 100644 --- a/apps/client/components/events/EventCard.tsx +++ b/apps/client/components/events/EventCard.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useRef } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native'; +import { View, Text, TouchableOpacity, StyleSheet, Animated, Image } from 'react-native'; import { Event } from '@/types/event'; import { formatDate, formatTimeRange } from '@/utils/dateHelpers'; import { CategoryPill } from '@/components/ui/CategoryPill'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface EventCardProps { event: Event; @@ -11,12 +13,22 @@ interface EventCardProps { } export const EventCard: React.FC = ({ event, onPress, index = 0 }) => { + const { colors, fontScale, reduceMotion } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const totalRSVPs = event.rsvpCounts.going + event.rsvpCounts.maybe; const fadeAnim = useRef(new Animated.Value(0)).current; const slideAnim = useRef(new Animated.Value(20)).current; const scaleAnim = useRef(new Animated.Value(0.95)).current; useEffect(() => { + if (reduceMotion) { + // Skip the entrance animation: jump straight to the final values + fadeAnim.setValue(1); + slideAnim.setValue(0); + scaleAnim.setValue(1); + return; + } Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, @@ -39,9 +51,10 @@ export const EventCard: React.FC = ({ event, onPress, index = 0 useNativeDriver: true, }), ]).start(); - }, [event.id]); + }, [event.id, reduceMotion, index, fadeAnim, slideAnim, scaleAnim]); const handlePressIn = () => { + if (reduceMotion) return; Animated.spring(scaleAnim, { toValue: 0.97, useNativeDriver: true, @@ -49,6 +62,7 @@ export const EventCard: React.FC = ({ event, onPress, index = 0 }; const handlePressOut = () => { + if (reduceMotion) return; Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, @@ -72,6 +86,11 @@ export const EventCard: React.FC = ({ event, onPress, index = 0 {/* Color Bar */} + {/* Flyer Image */} + {event.imageUrl ? ( + + ) : null} + {/* Content */} {/* Header */} @@ -96,6 +115,7 @@ export const EventCard: React.FC = ({ event, onPress, index = 0 🕒 {formatDate(event.startTime)} • {formatTimeRange(event.startTime, event.endTime)} + {event.recurring ? ' 🔁' : ''} @@ -153,102 +173,106 @@ export const EventCard: React.FC = ({ event, onPress, index = 0 ); }; -const styles = StyleSheet.create({ - card: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - marginBottom: 16, - }, - colorBar: { - height: 4, - }, - content: { - padding: 16, - }, - header: { - flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: 12, - gap: 8, - }, - title: { - flex: 1, - fontSize: 18, - fontWeight: 'bold', - color: '#1F2937', - }, - badge: { - backgroundColor: '#8B7FFF', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, - }, - socialBadge: { - backgroundColor: '#FF6BA8', - }, - badgeText: { - fontSize: 10, - fontWeight: '600', - color: '#FFFFFF', - textTransform: 'uppercase', - }, - infoRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - gap: 8, - }, - infoIcon: { - fontSize: 14, - }, - infoText: { - flex: 1, - fontSize: 14, - color: '#6B7280', - }, - categories: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - marginTop: 12, - marginBottom: 12, - }, - moreCategories: { - fontSize: 12, - color: '#9CA3AF', - alignSelf: 'center', - }, - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingTop: 12, - borderTopWidth: 1, - borderTopColor: '#E5E7EB', - }, - rsvpInfo: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - rsvpIcon: { - fontSize: 14, - color: '#6BCF7F', - }, - rsvpText: { - fontSize: 13, - color: '#6B7280', - }, - capacity: { - fontSize: 13, - color: '#FF6B6B', - fontWeight: '500', - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderRadius: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + marginBottom: 16, + }, + colorBar: { + height: 4, + }, + image: { + width: '100%', + height: 140, + }, + content: { + padding: 16, + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 12, + gap: 8, + }, + title: { + flex: 1, + fontSize: 18 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + }, + badge: { + backgroundColor: '#8B7FFF', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + }, + socialBadge: { + backgroundColor: '#FF6BA8', + }, + badgeText: { + fontSize: 10 * fontScale, + fontWeight: '600', + color: colors.onPrimary, + textTransform: 'uppercase', + }, + infoRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + gap: 8, + }, + infoIcon: { + fontSize: 14, + }, + infoText: { + flex: 1, + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + categories: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginTop: 12, + marginBottom: 12, + }, + moreCategories: { + fontSize: 12 * fontScale, + color: colors.textTertiary, + alignSelf: 'center', + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + rsvpInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + rsvpIcon: { + fontSize: 14, + color: colors.success, + }, + rsvpText: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + }, + capacity: { + fontSize: 13 * fontScale, + color: colors.primary, + fontWeight: '500', + }, + }); diff --git a/apps/client/components/events/EventDetailSidebar.tsx b/apps/client/components/events/EventDetailSidebar.tsx index aca88fe..5287136 100644 --- a/apps/client/components/events/EventDetailSidebar.tsx +++ b/apps/client/components/events/EventDetailSidebar.tsx @@ -5,6 +5,7 @@ import { ScrollView, TouchableOpacity, StyleSheet, + Image, } from 'react-native'; import { AnimatedDrawer } from '@/components/ui/AnimatedDrawer'; import { Button } from '@/components/ui/Button'; @@ -14,6 +15,8 @@ import { formatFullDate, formatTimeRange } from '@/utils/dateHelpers'; import { useResponsive } from '@/hooks/useResponsive'; import { useAuth } from '@/contexts/AuthContext'; import { useEvents } from '@/contexts/EventsContext'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface EventDetailSidebarProps { event: Event | null; @@ -26,6 +29,8 @@ export const EventDetailSidebar: React.FC = ({ visible, onClose, }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const { isMobile } = useResponsive(); const { currentUser } = useAuth(); const { updateRSVP, getRSVPStatus } = useEvents(); @@ -67,6 +72,11 @@ export const EventDetailSidebar: React.FC = ({ {/* Content */} + {/* Flyer Image */} + {event.imageUrl ? ( + + ) : null} + {/* Date & Time */} @@ -79,6 +89,13 @@ export const EventDetailSidebar: React.FC = ({ {formatTimeRange(event.startTime, event.endTime)} + {event.recurring && ( + + 🔁 Repeats {event.recurring.frequency} + {event.recurring.interval > 1 ? ` (every ${event.recurring.interval})` : ''} + {event.recurring.endDate ? ` until ${event.recurring.endDate}` : ''} + + )} @@ -221,139 +238,151 @@ export const EventDetailSidebar: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#FFFFFF', - }, - header: { - padding: 20, - }, - headerContent: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - headerTitle: { - flex: 1, - fontSize: 24, - fontWeight: 'bold', - color: '#FFFFFF', - marginRight: 12, - }, - closeButton: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: 'rgba(255, 255, 255, 0.3)', - justifyContent: 'center', - alignItems: 'center', - }, - closeIcon: { - fontSize: 20, - color: '#FFFFFF', - fontWeight: 'bold', - }, - content: { - flex: 1, - }, - section: { - padding: 20, - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - infoRow: { - flexDirection: 'row', - gap: 12, - }, - infoIcon: { - fontSize: 20, - }, - infoContent: { - flex: 1, - }, - infoLabel: { - fontSize: 12, - fontWeight: '600', - color: '#6B7280', - textTransform: 'uppercase', - marginBottom: 4, - }, - infoValue: { - fontSize: 16, - color: '#1F2937', - marginBottom: 2, - }, - organizerType: { - fontSize: 12, - color: '#8B7FFF', - fontWeight: '600', - marginTop: 4, - }, - sectionTitle: { - fontSize: 14, - fontWeight: '600', - color: '#6B7280', - textTransform: 'uppercase', - marginBottom: 12, - }, - description: { - fontSize: 16, - color: '#374151', - lineHeight: 24, - }, - categories: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - capacityInfo: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 8, - }, - capacityLabel: { - fontSize: 14, - color: '#6B7280', - }, - capacityValue: { - fontSize: 14, - fontWeight: '600', - color: '#1F2937', - }, - capacityBar: { - height: 8, - backgroundColor: '#E5E7EB', - borderRadius: 4, - overflow: 'hidden', - }, - capacityFill: { - height: '100%', - borderRadius: 4, - }, - rsvpStats: { - flexDirection: 'row', - justifyContent: 'space-around', - }, - rsvpStat: { - alignItems: 'center', - }, - rsvpCount: { - fontSize: 24, - fontWeight: 'bold', - color: '#1F2937', - }, - rsvpLabel: { - fontSize: 12, - color: '#6B7280', - marginTop: 4, - }, - footer: { - flexDirection: 'row', - padding: 16, - gap: 8, - borderTopWidth: 1, - borderTopColor: '#E5E7EB', - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.surface, + }, + header: { + padding: 20, + }, + headerContent: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + headerTitle: { + flex: 1, + fontSize: 24 * fontScale, + fontWeight: 'bold', + color: colors.onPrimary, + marginRight: 12, + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + closeIcon: { + fontSize: 20 * fontScale, + color: colors.onPrimary, + fontWeight: 'bold', + }, + content: { + flex: 1, + }, + image: { + width: '100%', + height: 180, + borderRadius: 12, + marginBottom: 16, + }, + recurringNote: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + marginTop: 4, + }, + section: { + padding: 20, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + infoRow: { + flexDirection: 'row', + gap: 12, + }, + infoIcon: { + fontSize: 20, + }, + infoContent: { + flex: 1, + }, + infoLabel: { + fontSize: 12 * fontScale, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + marginBottom: 4, + }, + infoValue: { + fontSize: 16 * fontScale, + color: colors.textPrimary, + marginBottom: 2, + }, + organizerType: { + fontSize: 12 * fontScale, + color: '#8B7FFF', + fontWeight: '600', + marginTop: 4, + }, + sectionTitle: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + marginBottom: 12, + }, + description: { + fontSize: 16 * fontScale, + color: colors.textPrimary, + lineHeight: 24, + }, + categories: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + capacityInfo: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 8, + }, + capacityLabel: { + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + capacityValue: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + capacityBar: { + height: 8, + backgroundColor: colors.border, + borderRadius: 4, + overflow: 'hidden', + }, + capacityFill: { + height: '100%', + borderRadius: 4, + }, + rsvpStats: { + flexDirection: 'row', + justifyContent: 'space-around', + }, + rsvpStat: { + alignItems: 'center', + }, + rsvpCount: { + fontSize: 24 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + }, + rsvpLabel: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + marginTop: 4, + }, + footer: { + flexDirection: 'row', + padding: 16, + gap: 8, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + }); diff --git a/apps/client/components/external-link.tsx b/apps/client/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/apps/client/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/apps/client/components/hello-wave.tsx b/apps/client/components/hello-wave.tsx deleted file mode 100644 index 5def547..0000000 --- a/apps/client/components/hello-wave.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Animated from 'react-native-reanimated'; - -export function HelloWave() { - return ( - - 👋 - - ); -} diff --git a/apps/client/components/layout/DesktopNav.tsx b/apps/client/components/layout/DesktopNav.tsx index ddedc27..e94c297 100644 --- a/apps/client/components/layout/DesktopNav.tsx +++ b/apps/client/components/layout/DesktopNav.tsx @@ -3,18 +3,27 @@ import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native import { router, usePathname } from 'expo-router'; import { useAuth } from '@/contexts/AuthContext'; import { Ionicons } from '@expo/vector-icons'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; // Re-using the SpinningPetalLogo from index.tsx (or a smaller version) // Ideally this should be in a shared component file, but for now: -const SmallPetalLogo = () => ( - - - - - -); +const SmallPetalLogo = () => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + + return ( + + + + + + ); +}; export const DesktopNav = () => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const pathname = usePathname(); const { currentUser, logout } = useAuth(); @@ -56,10 +65,10 @@ export const DesktopNav = () => { onPress={() => router.push(item.path as any)} activeOpacity={0.7} > - { onPress={logout} activeOpacity={0.7} > - + Logout @@ -105,146 +114,147 @@ export const DesktopNav = () => { ); }; -const styles = StyleSheet.create({ - container: { - height: 70, - backgroundColor: '#FFFFFF', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - ...Platform.select({ - web: { - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 4, - }, - default: { - elevation: 2, - }, - }), - }, - navContent: { - flexDirection: 'row', - alignItems: 'center', - height: '100%', - paddingHorizontal: 32, - maxWidth: 1600, - alignSelf: 'center', - width: '100%', - }, - brand: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - marginRight: 48, - }, - // Logo Styles - logoContainer: { - width: 32, - height: 32, - position: 'relative', - justifyContent: 'center', - alignItems: 'center', - }, - petal: { - position: 'absolute', - width: 10, - height: 32, - backgroundColor: '#FF6B6B', - borderRadius: 50, - opacity: 0.9, - }, - petalVertical: { - transform: [{ rotate: '0deg' }], - }, - petalRotated1: { - transform: [{ rotate: '60deg' }], - }, - petalRotated2: { - transform: [{ rotate: '-60deg' }], - }, - brandName: { - fontSize: 24, - fontWeight: 'bold', - color: '#FF6B6B', - }, - navItems: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - flex: 1, - }, - navItem: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 8, - // transition: 'all 0.2s ease', // Transition not supported in native styles - }, - navItemActive: { - backgroundColor: '#FFF1F1', // Lighter red/orange background - }, - navLabel: { - fontSize: 15, - fontWeight: '500', - color: '#6B7280', - }, - navLabelActive: { - color: '#FF6B6B', - fontWeight: '600', - }, - userSection: { - flexDirection: 'row', - alignItems: 'center', - gap: 16, - marginLeft: 24, - }, - userInfo: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - userAvatar: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: '#FF6B6B', - justifyContent: 'center', - alignItems: 'center', - }, - userInitial: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: 'bold', - }, - userDetails: { - maxWidth: 150, - }, - userName: { - fontSize: 14, - fontWeight: '600', - color: '#1F2937', - }, - userEmail: { - fontSize: 12, - color: '#6B7280', - }, - logoutButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 8, - borderWidth: 1, - borderColor: '#E5E7EB', - backgroundColor: '#FFFFFF', - }, - logoutText: { - fontSize: 14, - fontWeight: '500', - color: '#6B7280', - }, -}); +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + height: 70, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.border, + ...Platform.select({ + web: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 4, + }, + default: { + elevation: 2, + }, + }), + }, + navContent: { + flexDirection: 'row', + alignItems: 'center', + height: '100%', + paddingHorizontal: 32, + maxWidth: 1600, + alignSelf: 'center', + width: '100%', + }, + brand: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginRight: 48, + }, + // Logo Styles + logoContainer: { + width: 32, + height: 32, + position: 'relative', + justifyContent: 'center', + alignItems: 'center', + }, + petal: { + position: 'absolute', + width: 10, + height: 32, + backgroundColor: colors.primary, + borderRadius: 50, + opacity: 0.9, + }, + petalVertical: { + transform: [{ rotate: '0deg' }], + }, + petalRotated1: { + transform: [{ rotate: '60deg' }], + }, + petalRotated2: { + transform: [{ rotate: '-60deg' }], + }, + brandName: { + fontSize: 24 * fontScale, + fontWeight: 'bold', + color: colors.primary, + }, + navItems: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flex: 1, + }, + navItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 8, + // transition: 'all 0.2s ease', // Transition not supported in native styles + }, + navItemActive: { + backgroundColor: 'rgba(255, 107, 107, 0.12)', // Lighter red/orange background + }, + navLabel: { + fontSize: 15 * fontScale, + fontWeight: '500', + color: colors.textSecondary, + }, + navLabelActive: { + color: colors.primary, + fontWeight: '600', + }, + userSection: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + marginLeft: 24, + }, + userInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + userAvatar: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + }, + userInitial: { + color: colors.onPrimary, + fontSize: 16 * fontScale, + fontWeight: 'bold', + }, + userDetails: { + maxWidth: 150, + }, + userName: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + userEmail: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + }, + logoutButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + logoutText: { + fontSize: 14 * fontScale, + fontWeight: '500', + color: colors.textSecondary, + }, + }); diff --git a/apps/client/components/layout/FilterDrawer.tsx b/apps/client/components/layout/FilterDrawer.tsx index 2671ef4..9566d78 100644 --- a/apps/client/components/layout/FilterDrawer.tsx +++ b/apps/client/components/layout/FilterDrawer.tsx @@ -3,6 +3,7 @@ import { View, Text, TouchableOpacity, + TextInput, StyleSheet, ScrollView, } from 'react-native'; @@ -10,18 +11,14 @@ import { AnimatedDrawer } from '@/components/ui/AnimatedDrawer'; import { CategoryPill } from '@/components/ui/CategoryPill'; import { Button } from '@/components/ui/Button'; import { EventCategory } from '@/types/event'; +import { useFilters } from '@/contexts/FilterContext'; import { useResponsive } from '@/hooks/useResponsive'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface FilterDrawerProps { visible: boolean; onClose: () => void; - selectedCategories: EventCategory[]; - onCategoryToggle: (category: EventCategory) => void; - clubEvents: boolean; - socialEvents: boolean; - onEventTypeToggle: (type: 'clubEvents' | 'socialEvents') => void; - onClearFilters: () => void; - onApply: () => void; } const ALL_CATEGORIES: EventCategory[] = [ @@ -39,18 +36,58 @@ const ALL_CATEGORIES: EventCategory[] = [ 'Wellness', ]; -export const FilterDrawer: React.FC = ({ - visible, - onClose, - selectedCategories, - onCategoryToggle, - clubEvents, - socialEvents, - onEventTypeToggle, - onClearFilters, - onApply, -}) => { +const TIME_OF_DAY_OPTIONS: { + id: 'morning' | 'afternoon' | 'evening' | 'night'; + label: string; + hint: string; +}[] = [ + { id: 'morning', label: 'Morning', hint: '6am–12pm' }, + { id: 'afternoon', label: 'Afternoon', hint: '12–5pm' }, + { id: 'evening', label: 'Evening', hint: '5–9pm' }, + { id: 'night', label: 'Night', hint: '9pm–6am' }, +]; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export const FilterDrawer: React.FC = ({ visible, onClose }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const { isMobile } = useResponsive(); + const { + selectedCategories, + clubEvents, + socialEvents, + dateRange, + location, + timeOfDay, + hasAvailability, + toggleCategory, + toggleEventType, + setDateRange, + clearDateRange, + setLocation, + setTimeOfDay, + setHasAvailability, + clearAllFilters, + } = useFilters(); + + // Local text state for the date inputs so partial typing doesn't clobber + // the applied range; committed once both ends are valid dates. + const [startText, setStartText] = React.useState(dateRange?.start ?? ''); + const [endText, setEndText] = React.useState(dateRange?.end ?? ''); + + React.useEffect(() => { + setStartText(dateRange?.start ?? ''); + setEndText(dateRange?.end ?? ''); + }, [dateRange]); + + const commitDateRange = (start: string, end: string) => { + if (DATE_PATTERN.test(start) && DATE_PATTERN.test(end)) { + setDateRange(start, end); + } else if (!start && !end) { + clearDateRange(); + } + }; return ( = ({ onEventTypeToggle('clubEvents')} + onPress={() => toggleEventType('clubEvents')} > {clubEvents && } @@ -87,7 +124,7 @@ export const FilterDrawer: React.FC = ({ onEventTypeToggle('socialEvents')} + onPress={() => toggleEventType('socialEvents')} > {socialEvents && } @@ -106,29 +143,105 @@ export const FilterDrawer: React.FC = ({ key={category} category={category} active={selectedCategories.includes(category)} - onPress={() => onCategoryToggle(category)} + onPress={() => toggleCategory(category)} size="medium" /> ))} - {/* Date Range - Placeholder */} + {/* Date Range */} - Date Range - Coming soon + + Date Range + {dateRange && ( + clearDateRange()}> + Clear + + )} + + + + From + { + setStartText(text); + commitDateRange(text, endText); + }} + placeholder="YYYY-MM-DD" + placeholderTextColor={colors.textTertiary} + maxLength={10} + autoCapitalize="none" + /> + + + To + { + setEndText(text); + commitDateRange(startText, text); + }} + placeholder="YYYY-MM-DD" + placeholderTextColor={colors.textTertiary} + maxLength={10} + autoCapitalize="none" + /> + + - {/* Time of Day - Placeholder */} + {/* Time of Day */} Time of Day - Coming soon + + {TIME_OF_DAY_OPTIONS.map((option) => { + const active = timeOfDay === option.id; + return ( + setTimeOfDay(active ? undefined : option.id)} + > + + {option.label} + + + {option.hint} + + + ); + })} + - {/* Location - Placeholder */} + {/* Location */} Location - Coming soon + setLocation(text)} + placeholder="e.g., Wiegand Gym, Gates" + placeholderTextColor={colors.textTertiary} + /> + + + {/* Availability */} + + Availability + setHasAvailability(!hasAvailability)} + > + + {hasAvailability && } + + Only events with open spots + @@ -136,17 +249,14 @@ export const FilterDrawer: React.FC = ({ - ))} - - -
- setSearch(e.target.value)} - style={{ flex:1, padding:'8px 12px', border:'1px solid #ddd', borderRadius:999 }} - /> - - setK(Math.max(1, Math.min(20, Number(e.target.value) || 1)))} - style={{ width:70, padding:'6px 8px', border:'1px solid #ddd', borderRadius:8 }} - /> -
- - {(!aStartISO || !aEndISO) &&
Drag-select a time range on your calendar.
} - {aStartISO && aEndISO && suggestions.length === 0 &&
No matches. Try different tags or a wider window.
} - - {suggestions.map(s => ( -
onPick?.(s)} - style={{ border:'1px solid #eee', borderRadius:12, padding:12, marginBottom:10, cursor: onPick ? 'pointer' : 'default' }} - > -
{s.name}
-
- {new Date(s.start_ts).toLocaleString()} – {new Date(s.end_ts).toLocaleString()} -
-
- {(s.tags||[]).map(t => ( - {t} - ))} -
-
- Attendees: {(s.attendees||[]).length} -
-
- ))} - - ) -} diff --git a/apps/client/components/recommendations/RecommendationsList.tsx b/apps/client/components/recommendations/RecommendationsList.tsx index d4583de..83a1e12 100644 --- a/apps/client/components/recommendations/RecommendationsList.tsx +++ b/apps/client/components/recommendations/RecommendationsList.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { View, Text, FlatList, StyleSheet, TouchableOpacity } from 'react-native'; import { Event } from '@/types/event'; import { RecommendationCard } from './RecommendationCard'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface RecommendationsListProps { events: Event[]; @@ -20,11 +22,11 @@ const getRecommendationReason = (event: Event, index: number): string => { 'Similar to events you liked', 'Recommended for you', ]; - + if (event.rsvpCounts.going > 50) return 'Popular event'; if (event.isSocialEvent) return 'Social event nearby'; if (event.isClubEvent) return 'Club event'; - + return reasons[index % reasons.length]; }; @@ -35,6 +37,9 @@ export const RecommendationsList: React.FC = ({ showFilters = false, onFilterPress, }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + if (events.length === 0) { return ( @@ -77,58 +82,58 @@ export const RecommendationsList: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 16, - backgroundColor: '#FFFFFF', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - headerTitle: { - fontSize: 18, - fontWeight: 'bold', - color: '#1F2937', - }, - filterButton: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: '#F3F4F6', - justifyContent: 'center', - alignItems: 'center', - }, - filterIcon: { - fontSize: 18, - }, - listContent: { - padding: 16, - }, - emptyState: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 32, - }, - emptyIcon: { - fontSize: 64, - marginBottom: 16, - }, - emptyTitle: { - fontSize: 20, - fontWeight: 'bold', - color: '#1F2937', - marginBottom: 8, - }, - emptyText: { - fontSize: 16, - color: '#6B7280', - textAlign: 'center', - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + headerTitle: { + fontSize: 18 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + }, + filterButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.surfaceAlt, + justifyContent: 'center', + alignItems: 'center', + }, + filterIcon: { + fontSize: 18, + }, + listContent: { + padding: 16, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + emptyIcon: { + fontSize: 64, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 20 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + marginBottom: 8, + }, + emptyText: { + fontSize: 16 * fontScale, + color: colors.textSecondary, + textAlign: 'center', + }, + }); diff --git a/apps/client/components/themed-text.tsx b/apps/client/components/themed-text.tsx deleted file mode 100644 index d79d0a1..0000000 --- a/apps/client/components/themed-text.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { StyleSheet, Text, type TextProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedTextProps = TextProps & { - lightColor?: string; - darkColor?: string; - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; -}; - -export function ThemedText({ - style, - lightColor, - darkColor, - type = 'default', - ...rest -}: ThemedTextProps) { - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); - - return ( - - ); -} - -const styles = StyleSheet.create({ - default: { - fontSize: 16, - lineHeight: 24, - }, - defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, - }, - subtitle: { - fontSize: 20, - fontWeight: 'bold', - }, - link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', - }, -}); diff --git a/apps/client/components/themed-view.tsx b/apps/client/components/themed-view.tsx deleted file mode 100644 index 6f181d8..0000000 --- a/apps/client/components/themed-view.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; -}; - -export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { - const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); - - return ; -} diff --git a/apps/client/components/ui/AnimatedDrawer.tsx b/apps/client/components/ui/AnimatedDrawer.tsx index 71acf44..7782500 100644 --- a/apps/client/components/ui/AnimatedDrawer.tsx +++ b/apps/client/components/ui/AnimatedDrawer.tsx @@ -4,7 +4,7 @@ import { TouchableOpacity, StyleSheet, Modal, - Dimensions, + DimensionValue, ViewStyle, } from 'react-native'; import Animated, { @@ -14,6 +14,8 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { useResponsive } from '@/hooks/useResponsive'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; type DrawerPosition = 'left' | 'right' | 'bottom'; @@ -22,8 +24,8 @@ interface AnimatedDrawerProps { onClose: () => void; position?: DrawerPosition; children: React.ReactNode; - width?: number | string; - height?: number | string; + width?: DimensionValue; + height?: DimensionValue; containerStyle?: ViewStyle; } @@ -36,6 +38,8 @@ export const AnimatedDrawer: React.FC = ({ height, containerStyle, }) => { + const { colors, fontScale, reduceMotion } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const { isMobile, width: screenWidth, height: screenHeight } = useResponsive(); const translateX = useSharedValue(0); const translateY = useSharedValue(0); @@ -46,7 +50,7 @@ export const AnimatedDrawer: React.FC = ({ const drawerHeight = height || (position === 'bottom' ? '70%' : '100%'); // Calculate initial positions - const getInitialPosition = () => { + const getInitialPosition = React.useCallback(() => { switch (position) { case 'left': return { x: -screenWidth, y: 0 }; @@ -57,11 +61,25 @@ export const AnimatedDrawer: React.FC = ({ default: return { x: 0, y: 0 }; } - }; + }, [position, screenWidth, screenHeight]); useEffect(() => { const initial = getInitialPosition(); - + + if (reduceMotion) { + // Skip animations: snap the drawer/overlay to their target values + if (visible) { + translateX.value = withTiming(0, { duration: 0 }); + translateY.value = withTiming(0, { duration: 0 }); + opacity.value = withTiming(1, { duration: 0 }); + } else { + translateX.value = withTiming(initial.x, { duration: 0 }); + translateY.value = withTiming(initial.y, { duration: 0 }); + opacity.value = withTiming(0, { duration: 0 }); + } + return; + } + if (visible) { translateX.value = withSpring(0, { damping: 20, @@ -83,7 +101,7 @@ export const AnimatedDrawer: React.FC = ({ }); opacity.value = withTiming(0, { duration: 200 }); } - }, [visible, position]); + }, [visible, position, reduceMotion, getInitialPosition, translateX, translateY, opacity]); const drawerAnimatedStyle = useAnimatedStyle(() => ({ transform: [ @@ -150,22 +168,22 @@ export const AnimatedDrawer: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - overlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, - drawer: { - position: 'absolute', - backgroundColor: '#FFFFFF', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 8, - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.overlay, + }, + drawer: { + position: 'absolute', + backgroundColor: colors.surface, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 8, + }, + }); diff --git a/apps/client/components/ui/Button.tsx b/apps/client/components/ui/Button.tsx index 6a7e6d5..88f8b5f 100644 --- a/apps/client/components/ui/Button.tsx +++ b/apps/client/components/ui/Button.tsx @@ -7,6 +7,8 @@ import { ViewStyle, TextStyle, } from 'react-native'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'; export type ButtonSize = 'small' | 'medium' | 'large'; @@ -36,6 +38,9 @@ export const Button: React.FC = ({ textStyle, icon, }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const buttonStyles = [ styles.button, styles[variant], @@ -62,7 +67,7 @@ export const Button: React.FC = ({ > {loading ? ( ) : ( @@ -75,76 +80,78 @@ export const Button: React.FC = ({ ); }; -const styles = StyleSheet.create({ - button: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - borderRadius: 8, - gap: 8, - }, - fullWidth: { - width: '100%', - }, - // Variants - primary: { - backgroundColor: '#FF6B6B', - }, - secondary: { - backgroundColor: '#8B7FFF', - }, - outline: { - backgroundColor: 'transparent', - borderWidth: 2, - borderColor: '#FF6B6B', - }, - ghost: { - backgroundColor: 'transparent', - }, - // Sizes - smallButton: { - height: 36, - paddingHorizontal: 16, - }, - mediumButton: { - height: 44, - paddingHorizontal: 20, - }, - largeButton: { - height: 52, - paddingHorizontal: 24, - }, - // Text styles - text: { - fontWeight: '600', - }, - primaryText: { - color: '#FFFFFF', - }, - secondaryText: { - color: '#FFFFFF', - }, - outlineText: { - color: '#FF6B6B', - }, - ghostText: { - color: '#FF6B6B', - }, - smallText: { - fontSize: 14, - }, - mediumText: { - fontSize: 16, - }, - largeText: { - fontSize: 18, - }, - // States - disabled: { - opacity: 0.5, - }, - disabledText: { - opacity: 0.7, - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + button: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 8, + gap: 8, + }, + fullWidth: { + width: '100%', + }, + // Variants + primary: { + backgroundColor: colors.primary, + }, + secondary: { + backgroundColor: '#8B7FFF', + }, + outline: { + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: colors.primary, + }, + ghost: { + backgroundColor: 'transparent', + }, + // Sizes + smallButton: { + height: 36, + paddingHorizontal: 16, + }, + mediumButton: { + height: 44, + paddingHorizontal: 20, + }, + largeButton: { + height: 52, + paddingHorizontal: 24, + }, + // Text styles + text: { + fontWeight: '600', + }, + primaryText: { + color: colors.onPrimary, + }, + secondaryText: { + color: colors.onPrimary, + }, + outlineText: { + color: colors.primary, + }, + ghostText: { + color: colors.primary, + }, + smallText: { + fontSize: 14 * fontScale, + }, + mediumText: { + fontSize: 16 * fontScale, + }, + largeText: { + fontSize: 18 * fontScale, + }, + // States + disabled: { + backgroundColor: colors.surfaceAlt, + opacity: 0.5, + }, + disabledText: { + color: colors.textTertiary, + opacity: 0.7, + }, + }); diff --git a/apps/client/components/ui/CategoryPill.tsx b/apps/client/components/ui/CategoryPill.tsx index d0ad73b..385ab4f 100644 --- a/apps/client/components/ui/CategoryPill.tsx +++ b/apps/client/components/ui/CategoryPill.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { TouchableOpacity, Text, StyleSheet, ViewStyle } from 'react-native'; import { EventCategory } from '@/types/event'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface CategoryPillProps { category: EventCategory; @@ -19,13 +21,16 @@ export const CategoryPill: React.FC = ({ style, size = 'medium', }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const backgroundColor = active - ? color || '#FF6B6B' + ? color || colors.primary : 'transparent'; - - const borderColor = color || '#FF6B6B'; - - const textColor = active ? '#FFFFFF' : color || '#FF6B6B'; + + const borderColor = color || colors.primary; + + const textColor = active ? colors.onPrimary : color || colors.primary; return ( = ({ ); }; -const styles = StyleSheet.create({ - pill: { - borderRadius: 20, - borderWidth: 1.5, - justifyContent: 'center', - alignItems: 'center', - }, - smallPill: { - paddingHorizontal: 12, - paddingVertical: 4, - }, - mediumPill: { - paddingHorizontal: 16, - paddingVertical: 8, - }, - largePill: { - paddingHorizontal: 20, - paddingVertical: 10, - }, - text: { - fontWeight: '600', - }, - smallText: { - fontSize: 12, - }, - mediumText: { - fontSize: 14, - }, - largeText: { - fontSize: 16, - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + pill: { + borderRadius: 20, + borderWidth: 1.5, + justifyContent: 'center', + alignItems: 'center', + }, + smallPill: { + paddingHorizontal: 12, + paddingVertical: 4, + }, + mediumPill: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + largePill: { + paddingHorizontal: 20, + paddingVertical: 10, + }, + text: { + fontWeight: '600', + }, + smallText: { + fontSize: 12 * fontScale, + }, + mediumText: { + fontSize: 14 * fontScale, + }, + largeText: { + fontSize: 16 * fontScale, + }, + }); diff --git a/apps/client/components/ui/DateTimePicker.tsx b/apps/client/components/ui/DateTimePicker.tsx index 28b5618..89b57f0 100644 --- a/apps/client/components/ui/DateTimePicker.tsx +++ b/apps/client/components/ui/DateTimePicker.tsx @@ -10,6 +10,8 @@ import { SafeAreaView } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface DateTimePickerProps { label?: string; @@ -28,6 +30,8 @@ export const DateTimePicker: React.FC = ({ placeholder, error, }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const [showPicker, setShowPicker] = useState(false); const [inputValue, setInputValue] = useState(value); @@ -49,7 +53,7 @@ export const DateTimePicker: React.FC = ({ value={inputValue} onChangeText={handleManualChange} placeholder={placeholder} - placeholderTextColor="#9CA3AF" + placeholderTextColor={colors.textTertiary} /> = ({ @@ -73,14 +77,14 @@ export const DateTimePicker: React.FC = ({ setShowPicker(false)} style={styles.closeButton}> - + Select {type === 'date' ? 'Date' : 'Time'} - + {type === 'date' ? ( = ({ // Simple Calendar Component const CalendarPicker = ({ selectedDate, onSelect }: { selectedDate: string; onSelect: (date: string) => void }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const [currentDate, setCurrentDate] = useState(() => { return selectedDate ? new Date(selectedDate) : new Date(); }); @@ -156,13 +162,13 @@ const CalendarPicker = ({ selectedDate, onSelect }: { selectedDate: string; onSe changeMonth(-1)} style={styles.arrowButton}> - + {currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} changeMonth(1)} style={styles.arrowButton}> - + @@ -177,6 +183,8 @@ const CalendarPicker = ({ selectedDate, onSelect }: { selectedDate: string; onSe // Simple Time Picker Component (List of 15min intervals) const TimePicker = ({ selectedTime, onSelect }: { selectedTime: string; onSelect: (time: string) => void }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const times = []; for (let i = 0; i < 24; i++) { for (let j = 0; j < 60; j += 30) { // 30 min intervals @@ -198,7 +206,7 @@ const TimePicker = ({ selectedTime, onSelect }: { selectedTime: string; onSelect {time} {selectedTime === time && ( - + )}
))} @@ -206,150 +214,151 @@ const TimePicker = ({ selectedTime, onSelect }: { selectedTime: string; onSelect ); }; -const styles = StyleSheet.create({ - container: { - marginBottom: 16, - }, - label: { - fontSize: 14, - fontWeight: '500', - color: '#374151', - marginBottom: 6, - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderColor: '#D1D5DB', - borderRadius: 12, // Matching Input component - backgroundColor: '#FFFFFF', - }, - input: { - flex: 1, - padding: 12, - fontSize: 16, - color: '#1F2937', - }, - inputError: { - borderColor: '#DC2626', - }, - iconButton: { - padding: 12, - }, - errorText: { - fontSize: 12, - color: '#DC2626', - marginTop: 4, - }, - - // Full Screen Modal Styles - fullScreenContainer: { - flex: 1, - backgroundColor: '#FFFFFF', - }, - pickerHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - borderBottomColor: '#F3F4F6', - }, - pickerHeaderTitle: { - fontSize: 18, - fontWeight: '600', - color: '#1F2937', - }, - closeButton: { - padding: 8, - }, - placeholderButton: { - width: 40, // Balance header - }, - pickerContent: { - flex: 1, - padding: 24, - }, +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + marginBottom: 16, + }, + label: { + fontSize: 14 * fontScale, + fontWeight: '500', + color: colors.textPrimary, + marginBottom: 6, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, // Matching Input component + backgroundColor: colors.surface, + }, + input: { + flex: 1, + padding: 12, + fontSize: 16 * fontScale, + color: colors.textPrimary, + }, + inputError: { + borderColor: colors.danger, + }, + iconButton: { + padding: 12, + }, + errorText: { + fontSize: 12 * fontScale, + color: colors.danger, + marginTop: 4, + }, + + // Full Screen Modal Styles + fullScreenContainer: { + flex: 1, + backgroundColor: colors.surface, + }, + pickerHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.surfaceAlt, + }, + pickerHeaderTitle: { + fontSize: 18 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + closeButton: { + padding: 8, + }, + placeholderButton: { + width: 40, // Balance header + }, + pickerContent: { + flex: 1, + padding: 24, + }, - // Calendar Styles - calendar: { - width: '100%', - }, - calendarHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 24, - }, - arrowButton: { - padding: 8, - borderRadius: 8, - backgroundColor: '#F9FAFB', - }, - monthTitle: { - fontSize: 18, - fontWeight: '600', - color: '#1F2937', - }, - weekDays: { - flexDirection: 'row', - marginBottom: 12, - }, - weekDayText: { - flex: 1, - textAlign: 'center', - fontSize: 13, - color: '#9CA3AF', - fontWeight: '600', - }, - daysGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - }, - dayCell: { - width: '14.28%', - aspectRatio: 1, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 8, - }, - dayCellSelected: { - backgroundColor: '#FF6B6B', - borderRadius: 12, - }, - dayText: { - fontSize: 16, - color: '#374151', - }, - dayTextSelected: { - color: '#FFFFFF', - fontWeight: '600', - }, + // Calendar Styles + calendar: { + width: '100%', + }, + calendarHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 24, + }, + arrowButton: { + padding: 8, + borderRadius: 8, + backgroundColor: colors.surfaceAlt, + }, + monthTitle: { + fontSize: 18 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + weekDays: { + flexDirection: 'row', + marginBottom: 12, + }, + weekDayText: { + flex: 1, + textAlign: 'center', + fontSize: 13 * fontScale, + color: colors.textTertiary, + fontWeight: '600', + }, + daysGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + dayCell: { + width: '14.28%', + aspectRatio: 1, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 8, + }, + dayCellSelected: { + backgroundColor: colors.primary, + borderRadius: 12, + }, + dayText: { + fontSize: 16 * fontScale, + color: colors.textPrimary, + }, + dayTextSelected: { + color: colors.onPrimary, + fontWeight: '600', + }, - // Time Picker Styles - timePicker: { - flex: 1, - }, - timeOption: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 16, - paddingHorizontal: 12, - borderBottomWidth: 1, - borderBottomColor: '#F3F4F6', - }, - timeOptionSelected: { - backgroundColor: '#FFF1F1', - borderRadius: 8, - borderBottomWidth: 0, - }, - timeText: { - fontSize: 16, - color: '#374151', - }, - timeTextSelected: { - color: '#FF6B6B', - fontWeight: '600', - }, -}); + // Time Picker Styles + timePicker: { + flex: 1, + }, + timeOption: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 12, + borderBottomWidth: 1, + borderBottomColor: colors.surfaceAlt, + }, + timeOptionSelected: { + backgroundColor: 'rgba(255, 107, 107, 0.12)', + borderRadius: 8, + borderBottomWidth: 0, + }, + timeText: { + fontSize: 16 * fontScale, + color: colors.textPrimary, + }, + timeTextSelected: { + color: colors.primary, + fontWeight: '600', + }, + }); diff --git a/apps/client/components/ui/Input.tsx b/apps/client/components/ui/Input.tsx index da511a9..14fcda8 100644 --- a/apps/client/components/ui/Input.tsx +++ b/apps/client/components/ui/Input.tsx @@ -7,6 +7,8 @@ import { TextInputProps, ViewStyle, } from 'react-native'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface InputProps extends TextInputProps { label?: string; @@ -27,82 +29,85 @@ export const Input: React.FC = ({ style, ...textInputProps }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + return ( {label && {label}} - + {leftIcon && {leftIcon}} - + - + {rightIcon && {rightIcon}} - + {error && {error}} {helperText && !error && {helperText}} ); }; -const styles = StyleSheet.create({ - container: { - marginBottom: 16, - }, - label: { - fontSize: 14, - fontWeight: '600', - color: '#374151', - marginBottom: 8, - }, - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderColor: '#D1D5DB', - borderRadius: 12, - backgroundColor: '#FFFFFF', - }, - inputContainerError: { - borderColor: '#DC2626', - }, - input: { - flex: 1, - height: 48, - paddingHorizontal: 16, - fontSize: 16, - color: '#1F2937', - }, - inputWithLeftIcon: { - paddingLeft: 8, - }, - inputWithRightIcon: { - paddingRight: 8, - }, - leftIcon: { - paddingLeft: 12, - }, - rightIcon: { - paddingRight: 12, - }, - errorText: { - fontSize: 12, - color: '#DC2626', - marginTop: 4, - }, - helperText: { - fontSize: 12, - color: '#6B7280', - marginTop: 4, - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + marginBottom: 16, + }, + label: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 8, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + backgroundColor: colors.surface, + }, + inputContainerError: { + borderColor: colors.danger, + }, + input: { + flex: 1, + height: 48, + paddingHorizontal: 16, + fontSize: 16 * fontScale, + color: colors.textPrimary, + }, + inputWithLeftIcon: { + paddingLeft: 8, + }, + inputWithRightIcon: { + paddingRight: 8, + }, + leftIcon: { + paddingLeft: 12, + }, + rightIcon: { + paddingRight: 12, + }, + errorText: { + fontSize: 12 * fontScale, + color: colors.danger, + marginTop: 4, + }, + helperText: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + marginTop: 4, + }, + }); diff --git a/apps/client/components/ui/Modal.tsx b/apps/client/components/ui/Modal.tsx index 7a34845..2087401 100644 --- a/apps/client/components/ui/Modal.tsx +++ b/apps/client/components/ui/Modal.tsx @@ -11,6 +11,8 @@ import { ViewStyle, } from 'react-native'; import { useResponsive } from '@/hooks/useResponsive'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface ModalProps { visible: boolean; @@ -31,6 +33,8 @@ export const Modal: React.FC = ({ fullScreen = false, containerStyle, }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const { isMobile } = useResponsive(); const shouldBeFullScreen = fullScreen || isMobile; @@ -80,70 +84,70 @@ export const Modal: React.FC = ({ ); }; -const styles = StyleSheet.create({ - keyboardAvoid: { - flex: 1, - }, - overlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'center', - alignItems: 'center', - }, - modalContainer: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - width: '90%', - maxWidth: 600, - maxHeight: '80%', - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 12, - }, - modalContainerFullScreen: { - width: '100%', - height: '100%', - maxHeight: '100%', - borderRadius: 0, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 20, - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - title: { - fontSize: 20, - fontWeight: 'bold', - color: '#1F2937', - flex: 1, - }, - closeButton: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: '#F3F4F6', - justifyContent: 'center', - alignItems: 'center', - }, - closeIcon: { - fontSize: 20, - color: '#6B7280', - }, - content: { - flex: 1, - }, - contentContainer: { - padding: 20, - }, - footer: { - padding: 20, - borderTopWidth: 1, - borderTopColor: '#E5E7EB', - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + keyboardAvoid: { + flex: 1, + }, + overlay: { + flex: 1, + backgroundColor: colors.overlay, + justifyContent: 'center', + alignItems: 'center', + }, + modalContainer: { + backgroundColor: colors.surface, + borderRadius: 16, + width: '90%', + maxWidth: 600, + maxHeight: '80%', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 12, + }, + modalContainerFullScreen: { + width: '100%', + height: '100%', + maxHeight: '100%', + borderRadius: 0, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 20, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + title: { + fontSize: 20 * fontScale, + fontWeight: 'bold', + color: colors.textPrimary, + flex: 1, + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: colors.surfaceAlt, + justifyContent: 'center', + alignItems: 'center', + }, + closeIcon: { + fontSize: 20, + color: colors.textSecondary, + }, + content: { + flex: 1, + }, + contentContainer: { + padding: 20, + }, + footer: { + padding: 20, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + }); diff --git a/apps/client/components/ui/SearchBar.tsx b/apps/client/components/ui/SearchBar.tsx index 0c9e375..7f02148 100644 --- a/apps/client/components/ui/SearchBar.tsx +++ b/apps/client/components/ui/SearchBar.tsx @@ -9,6 +9,8 @@ import { ViewStyle, } from 'react-native'; import { SearchMode } from '@/types/settings'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; interface SearchBarProps { value: string; @@ -27,6 +29,8 @@ export const SearchBar: React.FC = ({ placeholder = 'Search events...', containerStyle, }) => { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const [showModeSelector, setShowModeSelector] = useState(false); const searchModeLabels: Record = { @@ -44,7 +48,7 @@ export const SearchBar: React.FC = ({ value={value} onChangeText={onChangeText} placeholder={placeholder} - placeholderTextColor="#9CA3AF" + placeholderTextColor={colors.textTertiary} /> {value.length > 0 && ( = ({ ); }; -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - gap: 8, - }, - searchContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#FFFFFF', - borderRadius: 8, - borderWidth: 1, - borderColor: '#D1D5DB', - paddingHorizontal: 12, - height: 44, - }, - searchIcon: { - fontSize: 18, - marginRight: 8, - }, - input: { - flex: 1, - fontSize: 16, - color: '#1F2937', - }, - clearButton: { - padding: 4, - }, - clearIcon: { - fontSize: 16, - color: '#9CA3AF', - }, - modeButton: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#FFFFFF', - borderRadius: 8, - borderWidth: 1, - borderColor: '#D1D5DB', - paddingHorizontal: 12, - height: 44, - gap: 6, - }, - modeButtonText: { - fontSize: 14, - color: '#374151', - fontWeight: '500', - }, - dropdownIcon: { - fontSize: 10, - color: '#6B7280', - }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - }, - modeSelector: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - padding: 8, - minWidth: 200, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - elevation: 8, - }, - modeOption: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 12, - borderRadius: 8, - }, - modeOptionActive: { - backgroundColor: '#FEE2E2', - }, - modeOptionText: { - fontSize: 16, - color: '#374151', - }, - modeOptionTextActive: { - color: '#FF6B6B', - fontWeight: '600', - }, - checkmark: { - fontSize: 16, - color: '#FF6B6B', - fontWeight: 'bold', - }, -}); - +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flexDirection: 'row', + gap: 8, + }, + searchContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 12, + height: 44, + }, + searchIcon: { + fontSize: 18, + marginRight: 8, + }, + input: { + flex: 1, + fontSize: 16 * fontScale, + color: colors.textPrimary, + }, + clearButton: { + padding: 4, + }, + clearIcon: { + fontSize: 16, + color: colors.textTertiary, + }, + modeButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 12, + height: 44, + gap: 6, + }, + modeButtonText: { + fontSize: 14 * fontScale, + color: colors.textPrimary, + fontWeight: '500', + }, + dropdownIcon: { + fontSize: 10, + color: colors.textSecondary, + }, + modalOverlay: { + flex: 1, + backgroundColor: colors.overlay, + justifyContent: 'center', + alignItems: 'center', + }, + modeSelector: { + backgroundColor: colors.surface, + borderRadius: 12, + padding: 8, + minWidth: 200, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + }, + modeOption: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 12, + borderRadius: 8, + }, + modeOptionActive: { + backgroundColor: colors.dangerSoft, + }, + modeOptionText: { + fontSize: 16 * fontScale, + color: colors.textPrimary, + }, + modeOptionTextActive: { + color: colors.primary, + fontWeight: '600', + }, + checkmark: { + fontSize: 16, + color: colors.primary, + fontWeight: 'bold', + }, + }); diff --git a/apps/client/components/ui/collapsible.tsx b/apps/client/components/ui/collapsible.tsx deleted file mode 100644 index 6345fde..0000000 --- a/apps/client/components/ui/collapsible.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; - - return ( - - setIsOpen((value) => !value)} - activeOpacity={0.8}> - - - {title} - - {isOpen && {children}} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - content: { - marginTop: 6, - marginLeft: 24, - }, -}); diff --git a/apps/client/constants/theme.ts b/apps/client/constants/theme.ts index f06facd..22d49fb 100644 --- a/apps/client/constants/theme.ts +++ b/apps/client/constants/theme.ts @@ -27,6 +27,117 @@ export const Colors = { }, }; +/** + * Semantic app palette used by useAppTheme(). Every screen/component should + * pull colors from here (via the hook) instead of hardcoding hex values, so + * dark mode and high-contrast mode apply everywhere. + */ +export interface AppPalette { + /** Page background */ + background: string; + /** Cards, drawers, sheets */ + surface: string; + /** Subtle secondary surface (chips, inactive buttons, table stripes) */ + surfaceAlt: string; + /** Hairline borders and dividers */ + border: string; + /** Headline / primary body text */ + textPrimary: string; + /** Secondary text */ + textSecondary: string; + /** Tertiary / placeholder text */ + textTertiary: string; + /** Brand primary (buttons, active states) */ + primary: string; + /** Text/icons rendered on top of primary */ + onPrimary: string; + /** Success accents */ + success: string; + /** Errors / destructive accents */ + danger: string; + /** Soft danger background (error banners) */ + dangerSoft: string; + /** Soft info background (notices) */ + infoSoft: string; + /** Text on infoSoft */ + infoText: string; + /** Scrim behind modals/drawers */ + overlay: string; +} + +const lightPalette: AppPalette = { + background: '#F8F9FA', + surface: '#FFFFFF', + surfaceAlt: '#F3F4F6', + border: '#E5E7EB', + textPrimary: '#1F2937', + textSecondary: '#6B7280', + textTertiary: '#9CA3AF', + primary: '#FF6B6B', + onPrimary: '#FFFFFF', + success: '#6BCF7F', + danger: '#DC2626', + dangerSoft: '#FEE2E2', + infoSoft: '#EFF6FF', + infoText: '#1E40AF', + overlay: 'rgba(0, 0, 0, 0.5)', +}; + +const lightHighContrastPalette: AppPalette = { + ...lightPalette, + background: '#FFFFFF', + surfaceAlt: '#EDEEF1', + border: '#6B7280', + textPrimary: '#000000', + textSecondary: '#1F2937', + textTertiary: '#374151', + primary: '#D63333', + danger: '#B91C1C', + infoText: '#1E3A8A', +}; + +const darkPalette: AppPalette = { + background: '#111418', + surface: '#1C2127', + surfaceAlt: '#262C34', + border: '#3A424D', + textPrimary: '#F3F4F6', + textSecondary: '#B0B8C1', + textTertiary: '#7D8590', + primary: '#FF6B6B', + onPrimary: '#FFFFFF', + success: '#6BCF7F', + danger: '#F87171', + dangerSoft: '#3F1D1D', + infoSoft: '#1E2A44', + infoText: '#93C5FD', + overlay: 'rgba(0, 0, 0, 0.65)', +}; + +const darkHighContrastPalette: AppPalette = { + ...darkPalette, + background: '#000000', + surface: '#101317', + surfaceAlt: '#1A1F26', + border: '#9CA3AF', + textPrimary: '#FFFFFF', + textSecondary: '#E5E7EB', + textTertiary: '#C3C9D1', + primary: '#FF8585', +}; + +export const AppPalettes = { + light: { default: lightPalette, highContrast: lightHighContrastPalette }, + dark: { default: darkPalette, highContrast: darkHighContrastPalette }, +} as const; + +/** Font-size multipliers for the Settings "Font Size" preference. */ +export const FontScales = { + small: 0.9, + medium: 1, + large: 1.15, +} as const; + export const Fonts = Platform.select({ ios: { /** iOS `UIFontDescriptorSystemDesignDefault` */ diff --git a/apps/client/contexts/EventsContext.tsx b/apps/client/contexts/EventsContext.tsx index 6c161e1..5b3ede5 100644 --- a/apps/client/contexts/EventsContext.tsx +++ b/apps/client/contexts/EventsContext.tsx @@ -1,7 +1,16 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { Event, RSVPStatus, EventFormData } from '@/types/event'; -import { fetchEvents, createEventAPI, updateEventAPI, deleteEventAPI } from '@/lib/api'; +import { + fetchEvents, + fetchEventAPI, + createEventAPI, + updateEventAPI, + deleteEventAPI, + setRSVPAPI, + SupabaseNotConfiguredError, +} from '@/lib/api'; import { useAuth } from '@/contexts/AuthContext'; +import { dedupeAgainst } from '@/utils/dedupe'; import allEventsData from '@/data/allEvents.json'; interface EventsContextType { @@ -41,7 +50,9 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children }) setEvents(fallbackEvents); } } catch (error) { - console.error('Failed to load events from Supabase:', error); + if (!(error instanceof SupabaseNotConfiguredError)) { + console.error('Failed to load events from Supabase:', error); + } const fallbackEvents = (allEventsData as Event[]).filter((e) => !e.id.startsWith('gcal-')); setEvents(fallbackEvents); } finally { @@ -94,10 +105,7 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children }) }); } - await updateEventAPI(eventId, { - rsvpCounts: newCounts, - attendees: filteredAttendees, - }); + // Optimistic local update so the UI responds immediately setEvents((prev) => prev.map((e) => e.id === eventId @@ -110,6 +118,23 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children }) : e ) ); + + try { + // Write the user's own RSVP row; a DB trigger recomputes the aggregates + await setRSVPAPI(eventId, userId, status); + // Pull the authoritative aggregates back (handles concurrent RSVPs) + const fresh = await fetchEventAPI(eventId); + if (fresh) { + setEvents((prev) => prev.map((e) => (e.id === eventId ? fresh : e))); + } + } catch (error) { + if (error instanceof SupabaseNotConfiguredError) { + // Offline demo mode: the optimistic local state is the state + return; + } + console.error('Failed to persist RSVP, reverting:', error); + setEvents((prev) => prev.map((e) => (e.id === eventId ? event : e))); + } }; const getRSVPStatus = (eventId: string, userId: string): RSVPStatus => { @@ -129,14 +154,15 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children }) /** * Add externally-sourced events (e.g. from Slack) into the events list. - * Deduplicates by event id — existing events with the same id are replaced. + * Same-id events are replaced; events that duplicate an existing event from + * another source (similar title, close start time) are dropped. */ const addExternalEvents = (newEvents: Event[]) => { setEvents((prev) => { const newIds = new Set(newEvents.map((e) => e.id)); // Remove old versions of these events, then append the new ones const filtered = prev.filter((e) => !newIds.has(e.id)); - return [...filtered, ...newEvents]; + return [...filtered, ...dedupeAgainst(filtered, newEvents)]; }); }; diff --git a/apps/client/contexts/FilterContext.tsx b/apps/client/contexts/FilterContext.tsx index 6abe099..291e724 100644 --- a/apps/client/contexts/FilterContext.tsx +++ b/apps/client/contexts/FilterContext.tsx @@ -1,8 +1,10 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { Event, EventCategory } from '@/types/event'; -import { SearchMode } from '@/types/settings'; +import { DateRange, SearchMode } from '@/types/settings'; import { useEventFilters } from '@/hooks/useEventFilters'; +type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night'; + interface FilterContextType { filteredEvents: Event[]; activeFilterCount: number; @@ -11,11 +13,20 @@ interface FilterContextType { selectedCategories: EventCategory[]; clubEvents: boolean; socialEvents: boolean; + dateRange?: DateRange; + location?: string; + timeOfDay?: TimeOfDay; + hasAvailability?: boolean; setSearchQuery: (query: string) => void; setSearchMode: (mode: SearchMode) => void; toggleCategory: (category: EventCategory) => void; setCategories: (categories: EventCategory[]) => void; toggleEventType: (type: 'clubEvents' | 'socialEvents') => void; + setDateRange: (start: string, end: string) => void; + clearDateRange: () => void; + setLocation: (location: string) => void; + setTimeOfDay: (timeOfDay: TimeOfDay | undefined) => void; + setHasAvailability: (hasAvailability: boolean) => void; clearFilters: () => void; clearAllFilters: () => void; } @@ -37,6 +48,11 @@ export const FilterProvider: React.FC = ({ children, events toggleEventType, setSearchQuery, setSearchMode, + setDateRange, + clearDateRange, + setLocation, + setTimeOfDay, + setHasAvailability, clearFilters, clearAllFilters, } = useEventFilters(events); @@ -49,11 +65,20 @@ export const FilterProvider: React.FC = ({ children, events selectedCategories: filters.categories, clubEvents: filters.eventTypes.clubEvents, socialEvents: filters.eventTypes.socialEvents, + dateRange: filters.dateRange, + location: filters.location, + timeOfDay: filters.timeOfDay, + hasAvailability: filters.hasAvailability, setSearchQuery, setSearchMode, toggleCategory, setCategories, toggleEventType, + setDateRange, + clearDateRange, + setLocation, + setTimeOfDay, + setHasAvailability, clearFilters, clearAllFilters, }; @@ -68,4 +93,3 @@ export const useFilters = (): FilterContextType => { } return context; }; - diff --git a/apps/client/contexts/GoogleAuthContext.tsx b/apps/client/contexts/GoogleAuthContext.tsx index cd4d6e5..d2b0d22 100644 --- a/apps/client/contexts/GoogleAuthContext.tsx +++ b/apps/client/contexts/GoogleAuthContext.tsx @@ -57,10 +57,6 @@ export const GoogleAuthProvider: React.FC<{ children: ReactNode }> = ({ children return; } - // #region agent log - if (typeof fetch !== 'undefined') fetch('http://127.0.0.1:7249/ingest/6ce6a0bd-b1d8-4a58-95c8-c0ef781b168b',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'GoogleAuthContext.tsx:onAuthStateChange',message:'Session from onAuthStateChange',data:{hasSession:!!session,hasProviderToken:!!session?.provider_token},timestamp:Date.now(),hypothesisId:'C'})}).catch(()=>{}); - // #endregion - if (session?.provider_token) providerTokenRef.current = session.provider_token; if (!session) providerTokenRef.current = null; setGoogleSession(session); @@ -183,10 +179,6 @@ export const GoogleAuthProvider: React.FC<{ children: ReactNode }> = ({ children expiresAt: session.expires_at, }); - // #region agent log - if (typeof fetch !== 'undefined') fetch('http://127.0.0.1:7249/ingest/6ce6a0bd-b1d8-4a58-95c8-c0ef781b168b',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'GoogleAuthContext.tsx:refreshSession',message:'refreshSession returned',data:{hasSession:!!session,hasProviderToken:!!session?.provider_token},timestamp:Date.now(),hypothesisId:'A'})}).catch(()=>{}); - // #endregion - setGoogleSession(session); return session; } catch (err) { diff --git a/apps/client/hooks/use-theme-color.ts b/apps/client/hooks/use-theme-color.ts deleted file mode 100644 index 0cbc3a6..0000000 --- a/apps/client/hooks/use-theme-color.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Learn more about light and dark modes: - * https://docs.expo.dev/guides/color-schemes/ - */ - -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function useThemeColor( - props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark -) { - const theme = useColorScheme() ?? 'light'; - const colorFromProps = props[theme]; - - if (colorFromProps) { - return colorFromProps; - } else { - return Colors[theme][colorName]; - } -} diff --git a/apps/client/hooks/useAppTheme.ts b/apps/client/hooks/useAppTheme.ts new file mode 100644 index 0000000..423d44e --- /dev/null +++ b/apps/client/hooks/useAppTheme.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { useSettings } from '@/contexts/SettingsContext'; +import { AppPalette, AppPalettes, FontScales } from '@/constants/theme'; + +export interface AppTheme { + /** Semantic color palette for the active theme + contrast setting */ + colors: AppPalette; + /** Multiplier for text sizes (Settings -> Font Size) */ + fontScale: number; + /** True when animations should be skipped (Settings -> Reduce Motion) */ + reduceMotion: boolean; + isDark: boolean; + highContrast: boolean; +} + +/** + * The one hook every screen/component should use for colors, font scaling, + * and motion preferences. Resolves the user's theme (light/dark/system), + * high-contrast setting, and font size from SettingsContext. + */ +export function useAppTheme(): AppTheme { + const { settings, currentTheme } = useSettings(); + + const highContrast = settings.accessibility?.highContrast ?? false; + const reduceMotion = settings.accessibility?.reduceMotion ?? false; + const fontScale = FontScales[settings.fontSize] ?? 1; + + const colors = AppPalettes[currentTheme][highContrast ? 'highContrast' : 'default']; + + return useMemo( + () => ({ + colors, + fontScale, + reduceMotion, + isDark: currentTheme === 'dark', + highContrast, + }), + [colors, fontScale, reduceMotion, currentTheme, highContrast] + ); +} diff --git a/apps/client/hooks/useAuth.ts b/apps/client/hooks/useAuth.ts index b589772..90952ec 100644 --- a/apps/client/hooks/useAuth.ts +++ b/apps/client/hooks/useAuth.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { User, AuthCredentials, SignupData } from '@/types/user'; +import { EventCategory } from '@/types/event'; import { useGoogleAuth } from '@/contexts/GoogleAuthContext'; import { fetchUserProfile, upsertUserProfile, updateUserProfile, updateUserProfilePreferences } from '@/lib/userProfilesApi'; import { fetchCreatedEventIds } from '@/lib/api'; @@ -20,7 +21,7 @@ function extractUniversityFromEmail(email: string): string { } const defaultUserPreferences = { - categoryInterests: [] as string[], + categoryInterests: [] as EventCategory[], eventTypePreferences: { clubEvents: true, socialEvents: true, diff --git a/apps/client/hooks/useEventReminders.ts b/apps/client/hooks/useEventReminders.ts new file mode 100644 index 0000000..e324609 --- /dev/null +++ b/apps/client/hooks/useEventReminders.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; +import { Event } from '@/types/event'; +import { storage } from '@/lib/storage'; + +/** + * Browser-notification reminders for scheduled events (web only). + * + * While the app is open, schedules a Notification REMINDER_LEAD_MS before + * each upcoming scheduled event. Fired reminders are recorded in storage so + * a reload doesn't re-notify for the same occurrence. Native platforms are a + * no-op (remote push would require an EAS build + push service). + */ + +const REMINDER_LEAD_MS = 30 * 60 * 1000; // 30 minutes before start +const MAX_TIMEOUT_MS = 12 * 60 * 60 * 1000; // only arm timers for the next 12h +const FIRED_KEY = 'universify_fired_reminders'; + +async function loadFired(): Promise> { + try { + const raw = await storage.getItem(FIRED_KEY); + return new Set(raw ? (JSON.parse(raw) as string[]) : []); + } catch { + return new Set(); + } +} + +async function saveFired(fired: Set): Promise { + // Keep the record bounded; old entries are for events long past + const entries = Array.from(fired).slice(-200); + await storage.setItem(FIRED_KEY, JSON.stringify(entries)); +} + +export function useEventReminders(scheduledEvents: Event[], enabled: boolean) { + const timersRef = useRef[]>([]); + + useEffect(() => { + if (Platform.OS !== 'web' || typeof window === 'undefined') return; + if (!enabled || typeof Notification === 'undefined') return; + if (scheduledEvents.length === 0) return; + + let cancelled = false; + + const arm = async () => { + if (Notification.permission === 'default') { + try { + await Notification.requestPermission(); + } catch { + return; + } + } + if (Notification.permission !== 'granted' || cancelled) return; + + const fired = await loadFired(); + const now = Date.now(); + + for (const event of scheduledEvents) { + const start = Date.parse(event.startTime); + if (!Number.isFinite(start)) continue; + const fireAt = start - REMINDER_LEAD_MS; + const delay = fireAt - now; + const key = `${event.id}@${event.startTime}`; + + if (delay > MAX_TIMEOUT_MS || fired.has(key)) continue; + if (delay < -REMINDER_LEAD_MS) continue; // already started + + const timer = setTimeout(() => { + try { + new Notification(`Starting soon: ${event.title}`, { + body: `${new Date(event.startTime).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + })}${event.location ? ` • ${event.location}` : ''}`, + tag: key, + }); + } catch { + // Notification constructor can throw on some platforms; ignore + } + loadFired().then((current) => { + current.add(key); + saveFired(current).catch(() => {}); + }); + }, Math.max(delay, 0)); + + timersRef.current.push(timer); + } + }; + + arm(); + + const timers = timersRef.current; + return () => { + cancelled = true; + timers.forEach(clearTimeout); + timersRef.current = []; + }; + }, [scheduledEvents, enabled]); +} diff --git a/apps/client/hooks/useRecommendations.ts b/apps/client/hooks/useRecommendations.ts index 72de17e..070df42 100644 --- a/apps/client/hooks/useRecommendations.ts +++ b/apps/client/hooks/useRecommendations.ts @@ -1,334 +1,22 @@ import { useMemo } from 'react' import { useEvents } from '../contexts/EventsContext' import type { Event } from '../types/event' -import allEventsData from '../data/allEvents.json' - -type TimeBucket = 'morning' | 'afternoon' | 'evening' | 'night' - -export type Interest = { - key: string - label: string - type: 'tag' | 'category' | 'time' - count: number - score: number -} - -function normalizeWord(w: string) { - if (!w) return '' - let s = w.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').trim() - if (s.endsWith('s') && s.length > 3) s = s.slice(0, -1) - return s -} - -function hourToBucket(h: number): TimeBucket { - if (h >= 6 && h < 12) return 'morning' - if (h >= 12 && h < 17) return 'afternoon' - if (h >= 17 && h < 22) return 'evening' - return 'night' -} - -/** - * Analyze events and return ranked interests. - * - extracts interests from the *title* into tags - * - counts categories - * - weights by popularity (rsvp going) - * - computes time-of-day preferences - */ -export function analyzeEvents( - events: Event[], - startISO?: string, - endISO?: string -) { - // optionally filter events to only those overlapping the provided window - let source = events || [] - if (startISO && endISO) { - const A0 = Date.parse(startISO) - const A1 = Date.parse(endISO) - if (Number.isFinite(A0) && Number.isFinite(A1) && A1 > A0) { - source = source.filter(e => { - const s = Date.parse((e as any).startTime || (e as any).start_ts || '') - const t = Date.parse((e as any).endTime || (e as any).end_ts || '') - if (!Number.isFinite(s) || !Number.isFinite(t)) return false - const overlap = Math.max(0, Math.min(A1, t) - Math.max(A0, s)) - return overlap > 0 - }) - } - } - - const tagCounts = new Map() - const catCounts = new Map() - const timeCounts = new Map([ - ['morning', { count: 0, weight: 0 }], - ['afternoon', { count: 0, weight: 0 }], - ['evening', { count: 0, weight: 0 }], - ['night', { count: 0, weight: 0 }], - ]) - - for (const e of source || []) { - // determine popularity weight - const pop = (e as any).rsvpCounts - ? Math.log1p(((e as any).rsvpCounts.going || 0)) + 1 - : 1 - - // categories - for (const c of (e as any).categories || []) { - const k = normalizeWord(c) - if (!k) continue - const cur = catCounts.get(k) || { count: 0, weight: 0 } - cur.count += 1 - cur.weight += pop - catCounts.set(k, cur) - } - - // interests (tags) extracted from the *title* - const stopwords = new Set([ - 'the', - 'and', - 'a', - 'an', - 'in', - 'on', - 'at', - 'for', - 'of', - 'to', - 'with', - 'by', - 'from', - 'is', - 'are', - 'this', - 'that', - 'your', - 'you', - 'meet', - 'session', - 'night', - 'event', - 'club', - 'cmu', - 'university', - 'online', - ]) - const title = ((e as any).title || (e as any).name || '').toString() - const rawTokens = normalizeWord(title).split(/\s+/).filter(Boolean) - const filtered = rawTokens.filter(t => !stopwords.has(t)) - const tokensForNgrams = filtered.length ? filtered : rawTokens - - // generate n-grams for n = 1..3 - const maxN = 3 - if (!Array.isArray(tokensForNgrams) || tokensForNgrams.length === 0) { - // nothing useful in this title - } else { - for (let n = 1; n <= maxN; n++) { - if (tokensForNgrams.length < n) continue - for (let i = 0; i <= tokensForNgrams.length - n; i++) { - const slice = tokensForNgrams.slice(i, i + n) - // skip grams that include very short tokens - if (slice.some(s => s.length < 2)) continue - const gram = slice.join(' ') - // skip overly short grams - if (gram.length < 3) continue - const k = gram - const cur = tagCounts.get(k) || { count: 0, weight: 0 } - cur.count += 1 - cur.weight += pop - tagCounts.set(k, cur) - } - } - } - - // time bucket - const s = Date.parse((e as any).startTime || (e as any).start_ts || '') - if (!Number.isFinite(s)) continue - const dt = new Date(s) - const bucket = hourToBucket(dt.getUTCHours()) - const curT = timeCounts.get(bucket)! - curT.count += 1 - curT.weight += pop - timeCounts.set(bucket, curT) - } - - // build interest objects - const tags: Interest[] = Array.from(tagCounts.entries()).map(([k, v]) => ({ - key: `tag:${k}`, - label: k, - type: 'tag', - count: v.count, - score: v.weight, - })) - - const categories: Interest[] = Array.from(catCounts.entries()).map( - ([k, v]) => ({ - key: `category:${k}`, - label: k, - type: 'category', - count: v.count, - score: v.weight, - }) - ) - - const times: Interest[] = Array.from(timeCounts.entries()).map(([k, v]) => ({ - key: `time:${k}`, - label: k, - type: 'time', - count: v.count, - score: v.weight, - })) - - // sort each list by score descending - tags.sort((a, b) => b.score - a.score) - categories.sort((a, b) => b.score - a.score) - times.sort((a, b) => b.score - a.score) - - // combined top interests (merge top N from each type) - const combined = [ - ...tags.slice(0, 10), - ...categories.slice(0, 10), - ...times.slice(0, 4), - ] - combined.sort((a, b) => b.score - a.score) - - return { tags, categories, times, top: combined.slice(0, 10) } -} - -/** - * Build a separate JSON representation of all events happening. - */ -export function buildEventsJson(events: Event[]) { - return (events || []).map(e => { - const anyE = e as any - return { - id: anyE.id, - title: anyE.title || anyE.name || '', - startTime: anyE.startTime || anyE.start_ts || null, - endTime: anyE.endTime || anyE.end_ts || null, - categories: anyE.categories || [], - tags: anyE.tags || [], - rsvpCounts: anyE.rsvpCounts || null, - } - }) -} - -/** - * Given a search string and user interests, return events the user - * might be interested in. - * - Matches search text against title - * - Boosts matches that contain top interest phrases - */ -export function getRecommendedEvents( - events: Event[], - topInterests: Interest[], - search: string -) { - const normSearch = normalizeWord(search || '') - if (!normSearch) return [] - - const searchTokens = normSearch.split(/\s+/).filter(Boolean) - if (!searchTokens.length) return [] - - const topInterestLabels = topInterests.map(i => i.label.toLowerCase()) - - const scored: { event: Event; score: number }[] = [] - - for (const e of events || []) { - const anyE = e as any - const titleRaw = (anyE.title || anyE.name || '').toString() - const titleNorm = normalizeWord(titleRaw) - if (!titleNorm) continue - - let score = 0 - - // Direct match with search terms in title - for (const token of searchTokens) { - if (titleNorm.includes(token)) { - score += 2 - } - } - - // Match user interests (from titles) - for (const label of topInterestLabels) { - if (label && titleNorm.includes(label)) { - score += 3 - } - } - - if (score > 0) { - scored.push({ event: e, score }) - } - } - - scored.sort((a, b) => b.score - a.score) - - return scored.map(s => s.event) -} - -/** - * Get suggestions for a specific time range based on user interests. - * Returns events that overlap with the time range, scored by user interests. - */ -export function getSuggestionsForTimeRange( - events: Event[], - topInterests: Interest[], - startISO: string, - endISO: string, - limit: number = 5 -): Event[] { - if (!startISO || !endISO) return [] - - const rangeStart = Date.parse(startISO) - const rangeEnd = Date.parse(endISO) - - if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd) || rangeEnd <= rangeStart) { - return [] - } - - const topInterestLabels = topInterests.map(i => i.label.toLowerCase()) - const scored: { event: Event; score: number }[] = [] - - for (const e of events || []) { - const anyE = e as any - const eventStart = Date.parse(anyE.startTime || anyE.start_ts || '') - const eventEnd = Date.parse(anyE.endTime || anyE.end_ts || '') - - // Check if event overlaps with the time range - if (!Number.isFinite(eventStart) || !Number.isFinite(eventEnd)) continue - const overlap = Math.max(0, Math.min(rangeEnd, eventEnd) - Math.max(rangeStart, eventStart)) - if (overlap <= 0) continue - - // Score based on user interests - const titleRaw = (anyE.title || anyE.name || '').toString() - const titleNorm = normalizeWord(titleRaw) - if (!titleNorm) continue - - let score = 0 - - // Match user interests (from titles) - for (const label of topInterestLabels) { - if (label && titleNorm.includes(label)) { - score += 3 - } - } - - // Boost score based on popularity - const pop = anyE.rsvpCounts - ? Math.log1p((anyE.rsvpCounts.going || 0)) + 1 - : 1 - score += pop - - // Prefer events that fit better in the time range - const eventDuration = eventEnd - eventStart - const rangeDuration = rangeEnd - rangeStart - if (eventDuration <= rangeDuration) { - score += 2 - } - - scored.push({ event: e, score }) - } - - scored.sort((a, b) => b.score - a.score) - return scored.slice(0, limit).map(s => s.event) -} +import { + analyzeEvents, + buildEventsJson, + getRecommendedEvents, + getSuggestionsForTimeRange, +} from '@/utils/recommendationEngine' + +// Re-export the pure engine so existing imports keep working +export { + analyzeEvents, + buildEventsJson, + getRecommendedEvents, + getSuggestionsForTimeRange, + rankEventsForUser, +} from '@/utils/recommendationEngine' +export type { Interest } from '@/utils/recommendationEngine' export type UseRecommendationsOptions = { events?: Event[] @@ -380,14 +68,14 @@ export function useUserInterests(opts?: UseRecommendationsOptions) { } /** - * Hook to get suggestions for a specific time range - * Uses allEvents.json for recommendations instead of context events + * Hook to get suggestions for a specific time range. + * Defaults to live context events (which already fall back to bundled data + * when Supabase is unavailable). */ export function useSuggestions(opts?: UseSuggestionsOptions) { const { events: override, startISO, endISO, limit = 5 } = opts || {} const ctx = useEvents() - // Use allEvents.json for recommendations (same as events in "find" tab) - const sourceEvents = (override ?? (allEventsData as Event[])) as Event[] + const sourceEvents = (override ?? ctx.events ?? []) as Event[] const interests = useUserInterests({ events: sourceEvents }) diff --git a/apps/client/lib/api.ts b/apps/client/lib/api.ts index e0c9375..b8ec52f 100644 --- a/apps/client/lib/api.ts +++ b/apps/client/lib/api.ts @@ -1,5 +1,17 @@ -import { supabase } from '@/lib/supabase'; -import { Event, EventFormData } from '@/types/event'; +import { supabase, isSupabaseConfigured } from '@/lib/supabase'; +import { Event, EventCategory, EventFormData, RSVPStatus } from '@/types/event'; + +/** Thrown when a network call is attempted without Supabase credentials. */ +export class SupabaseNotConfiguredError extends Error { + constructor() { + super('Supabase is not configured; running in offline demo mode'); + this.name = 'SupabaseNotConfiguredError'; + } +} + +function requireSupabase(): void { + if (!isSupabaseConfigured) throw new SupabaseNotConfiguredError(); +} function transformDbEventToEvent(dbEvent: Record): Event { return { @@ -9,7 +21,7 @@ function transformDbEventToEvent(dbEvent: Record): Event { startTime: dbEvent.start_time as string, endTime: dbEvent.end_time as string, location: (dbEvent.location as string) || '', - categories: (dbEvent.categories as string[]) || [], + categories: (dbEvent.categories as EventCategory[]) || [], organizer: { id: (dbEvent.organizer_id as string) || '', name: (dbEvent.organizer_name as string) || '', @@ -60,6 +72,7 @@ function transformEventFormToDb(eventData: EventFormData, userId: string, organi capacity: eventData.capacity, recurring: eventData.recurring, tags: eventData.tags, + image_url: eventData.imageUrl ?? null, }; } @@ -91,6 +104,7 @@ function transformEventToDb(updates: Partial): Record { } export const fetchEvents = async (): Promise => { + requireSupabase(); const { data, error } = await supabase .from('events') .select('*') @@ -101,6 +115,7 @@ export const fetchEvents = async (): Promise => { }; export const fetchCreatedEventIds = async (userId: string): Promise => { + requireSupabase(); const { data, error } = await supabase .from('events') .select('id') @@ -114,6 +129,7 @@ export const createEventAPI = async ( userId: string, organizerName: string = 'Current User' ): Promise => { + requireSupabase(); const dbEvent = transformEventFormToDb(eventData, userId, organizerName); const { data, error } = await supabase.from('events').insert([dbEvent]).select().single(); @@ -123,6 +139,7 @@ export const createEventAPI = async ( }; export const updateEventAPI = async (eventId: string, updates: Partial): Promise => { + requireSupabase(); const dbUpdates = transformEventToDb(updates); const { error } = await supabase.from('events').update(dbUpdates).eq('id', eventId); @@ -130,7 +147,52 @@ export const updateEventAPI = async (eventId: string, updates: Partial): }; export const deleteEventAPI = async (eventId: string): Promise => { + requireSupabase(); const { error } = await supabase.from('events').delete().eq('id', eventId); if (error) throw error; }; + +/** + * Upsert (or clear) the current user's RSVP for an event. + * + * Writes go to the per-user event_rsvps table (which the user is allowed to + * write under RLS); a database trigger keeps events.rsvp_counts and + * events.attendees in sync. Passing null status removes the RSVP. + */ +export const setRSVPAPI = async ( + eventId: string, + userId: string, + status: RSVPStatus +): Promise => { + requireSupabase(); + if (status === null) { + const { error } = await supabase + .from('event_rsvps') + .delete() + .eq('event_id', eventId) + .eq('user_id', userId); + if (error) throw error; + return; + } + + const { error } = await supabase + .from('event_rsvps') + .upsert( + { event_id: eventId, user_id: userId, status }, + { onConflict: 'event_id,user_id' } + ); + if (error) throw error; +}; + +/** Fetch the fresh server-side aggregate state of one event (counts + attendees). */ +export const fetchEventAPI = async (eventId: string): Promise => { + requireSupabase(); + const { data, error } = await supabase + .from('events') + .select('*') + .eq('id', eventId) + .maybeSingle(); + if (error) throw error; + return data ? transformDbEventToEvent(data as Record) : null; +}; diff --git a/apps/client/lib/supabase.ts b/apps/client/lib/supabase.ts index b2f0d5d..ff5020e 100644 --- a/apps/client/lib/supabase.ts +++ b/apps/client/lib/supabase.ts @@ -4,15 +4,26 @@ import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL ?? ''; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? ''; -if (!supabaseUrl || !supabaseAnonKey) { +/** + * True when real Supabase credentials are present. When false the app runs in + * offline/demo mode: reads fall back to bundled mock data and writes are kept + * in local state only. Callers should check this before hitting the network. + */ +export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseAnonKey); + +if (!isSupabaseConfigured) { console.warn( - 'Missing Supabase credentials. Add EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to .env' + 'Missing Supabase credentials. Add EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to .env. Running in offline demo mode.' ); } -// Create and export Supabase client -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +// createClient throws on an empty URL, which would crash the bundle at module +// load. Fall back to a syntactically valid placeholder; isSupabaseConfigured +// gates all real usage so the placeholder client is never actually queried. +export const supabase = createClient( + isSupabaseConfigured ? supabaseUrl : 'https://offline-demo.invalid', + isSupabaseConfigured ? supabaseAnonKey : 'offline-demo-anon-key' +); // Export types for use throughout the app export type { Session, User as SupabaseUser } from '@supabase/supabase-js'; - diff --git a/apps/client/package.json b/apps/client/package.json index c977b75..0a43ec5 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -4,11 +4,13 @@ "version": "1.0.0", "scripts": { "start": "expo start", - "build": "expo export:web", - "vercel-build": "expo export:web", + "build": "expo export --platform web", + "vercel-build": "expo export --platform web", + "typecheck": "tsc --noEmit", "reset-project": "node ./scripts/reset-project.js", "seed": "npx tsx scripts/seedEvents.ts", "test:backend": "npx tsx scripts/test-backend.ts", + "test:unit": "tsx --test tests/*.test.ts", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:health": "npx tsx scripts/health-check.ts", @@ -51,6 +53,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@types/node-fetch": "^2.6.13", "@types/react": "~19.1.0", "dotenv": "^17.3.1", "eslint": "^9.25.0", diff --git a/apps/client/tests/dedupe.test.ts b/apps/client/tests/dedupe.test.ts new file mode 100644 index 0000000..7f5501f --- /dev/null +++ b/apps/client/tests/dedupe.test.ts @@ -0,0 +1,52 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + normalizeTitle, + titleSimilarity, + isLikelyDuplicate, + dedupeAgainst, +} from '../utils/dedupe'; + +test('normalizeTitle strips punctuation and collapses whitespace', () => { + assert.equal(normalizeTitle('Poker Night @ Wiegand!!'), 'poker night wiegand'); + assert.equal(normalizeTitle(' Hello World '), 'hello world'); + assert.equal(normalizeTitle(''), ''); +}); + +test('titleSimilarity is 1 for identical titles and 0 for disjoint ones', () => { + assert.equal(titleSimilarity('Poker Night', 'poker night'), 1); + assert.equal(titleSimilarity('Poker Night', 'Chess Club'), 0); +}); + +test('titleSimilarity is high for reworded duplicates', () => { + const sim = titleSimilarity('Poker Night @ Wiegand', 'Poker Night Wiegand Gym'); + assert.ok(sim >= 0.5, `expected >= 0.5, got ${sim}`); +}); + +test('isLikelyDuplicate requires both title similarity and time proximity', () => { + const base = { id: 'a', title: 'Poker Night', startTime: '2026-08-14T19:00:00Z' }; + const sameSoon = { id: 'b', title: 'Poker night!', startTime: '2026-08-14T19:30:00Z' }; + const sameNextDay = { id: 'c', title: 'Poker night!', startTime: '2026-08-15T19:00:00Z' }; + const differentSoon = { id: 'd', title: 'Robotics Demo', startTime: '2026-08-14T19:00:00Z' }; + + assert.equal(isLikelyDuplicate(base, sameSoon), true); + assert.equal(isLikelyDuplicate(base, sameNextDay), false); + assert.equal(isLikelyDuplicate(base, differentSoon), false); +}); + +test('same id is always a duplicate', () => { + const a = { id: 'x', title: 'A', startTime: 'invalid' }; + const b = { id: 'x', title: 'B', startTime: 'also invalid' }; + assert.equal(isLikelyDuplicate(a, b), true); +}); + +test('dedupeAgainst drops cross-source duplicates and internal repeats', () => { + const existing = [{ id: 'slack-1', title: 'Poker Night', startTime: '2026-08-14T19:00:00Z' }]; + const incoming = [ + { id: 'disc-1', title: 'Poker Night!', startTime: '2026-08-14T19:15:00Z' }, // dup of existing + { id: 'disc-2', title: 'Robotics Demo', startTime: '2026-08-14T18:00:00Z' }, // fresh + { id: 'disc-3', title: 'Robotics demo', startTime: '2026-08-14T18:30:00Z' }, // dup of disc-2 + ]; + const kept = dedupeAgainst(existing, incoming); + assert.deepEqual(kept.map((e) => e.id), ['disc-2']); +}); diff --git a/apps/client/tests/eventLayout.test.ts b/apps/client/tests/eventLayout.test.ts new file mode 100644 index 0000000..3d17123 --- /dev/null +++ b/apps/client/tests/eventLayout.test.ts @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { layoutEvents } from '../utils/eventLayout'; +import type { Event } from '../types/event'; + +function makeEvent(id: string, startTime: string, endTime: string): Event { + return { + id, + title: id, + description: '', + startTime, + endTime, + location: '', + categories: [], + organizer: { id: 'org', name: 'Org', type: 'club' }, + color: '#FF6B6B', + rsvpEnabled: true, + rsvpCounts: { going: 0, maybe: 0, notGoing: 0 }, + attendees: [], + attendeeVisibility: 'public', + isClubEvent: false, + isSocialEvent: false, + tags: [], + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }; +} + +test('non-overlapping events each get the full width', () => { + const laid = layoutEvents([ + makeEvent('a', '2026-08-14T10:00:00Z', '2026-08-14T11:00:00Z'), + makeEvent('b', '2026-08-14T12:00:00Z', '2026-08-14T13:00:00Z'), + ]); + for (const event of laid) { + assert.equal(event.totalColumns, 1, `${event.id} should be alone in its group`); + assert.equal(event.column, 0); + } +}); + +test('two overlapping events split into two columns', () => { + const laid = layoutEvents([ + makeEvent('a', '2026-08-14T10:00:00Z', '2026-08-14T12:00:00Z'), + makeEvent('b', '2026-08-14T11:00:00Z', '2026-08-14T13:00:00Z'), + ]); + const byId = Object.fromEntries(laid.map((e) => [e.id, e])); + assert.equal(byId.a.totalColumns, 2); + assert.equal(byId.b.totalColumns, 2); + assert.notEqual(byId.a.column, byId.b.column, 'overlapping events must not share a column'); +}); + +test('chain of three overlapping events shares a group', () => { + const laid = layoutEvents([ + makeEvent('a', '2026-08-14T10:00:00Z', '2026-08-14T11:30:00Z'), + makeEvent('b', '2026-08-14T11:00:00Z', '2026-08-14T12:30:00Z'), + makeEvent('c', '2026-08-14T12:00:00Z', '2026-08-14T13:30:00Z'), + ]); + const byId = Object.fromEntries(laid.map((e) => [e.id, e])); + // a and c don't directly overlap, so they can reuse the same column while + // b (overlapping both) must sit in a different one + assert.notEqual(byId.a.column, byId.b.column); + assert.notEqual(byId.b.column, byId.c.column); +}); + +test('empty input produces empty output', () => { + assert.deepEqual(layoutEvents([]), []); +}); diff --git a/apps/client/tests/recommendationEngine.test.ts b/apps/client/tests/recommendationEngine.test.ts new file mode 100644 index 0000000..9ca6fc7 --- /dev/null +++ b/apps/client/tests/recommendationEngine.test.ts @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + analyzeEvents, + rankEventsForUser, + getSuggestionsForTimeRange, +} from '../utils/recommendationEngine'; +import type { Event } from '../types/event'; + +function makeEvent(overrides: Partial & { id: string }): Event { + return { + title: 'Untitled', + description: '', + startTime: '2026-08-14T19:00:00.000Z', + endTime: '2026-08-14T20:00:00.000Z', + location: '', + categories: [], + organizer: { id: 'org', name: 'Org', type: 'club' }, + color: '#FF6B6B', + rsvpEnabled: true, + rsvpCounts: { going: 0, maybe: 0, notGoing: 0 }, + attendees: [], + attendeeVisibility: 'public', + isClubEvent: true, + isSocialEvent: false, + tags: [], + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + }; +} + +test('analyzeEvents mines title n-grams and categories weighted by popularity', () => { + const engaged = [ + makeEvent({ + id: 'a', + title: 'Poker Tournament Finals', + categories: ['Fun'], + rsvpCounts: { going: 50, maybe: 0, notGoing: 0 }, + }), + makeEvent({ id: 'b', title: 'Poker Practice', categories: ['Fun'] }), + ]; + const { top, categories } = analyzeEvents(engaged); + assert.ok(top.length > 0, 'expected some interests'); + const labels = top.map((i) => i.label); + assert.ok(labels.includes('poker'), `expected "poker" among ${labels.join(', ')}`); + assert.equal(categories[0]?.label, 'fun'); +}); + +test('rankEventsForUser puts interest matches above popular strangers', () => { + const engaged = [ + makeEvent({ id: 'seen-1', title: 'Chess Club Meetup', categories: ['Fun'] }), + makeEvent({ id: 'seen-2', title: 'Chess Tournament', categories: ['Fun'] }), + ]; + const { top } = analyzeEvents(engaged); + + const candidates = [ + makeEvent({ + id: 'popular-unrelated', + title: 'Career Fair Kickoff', + categories: ['Career'], + rsvpCounts: { going: 40, maybe: 0, notGoing: 0 }, + }), + makeEvent({ id: 'chess-match', title: 'Late Night Chess', categories: ['Fun'] }), + ]; + + const ranked = rankEventsForUser(candidates, top, []); + assert.equal(ranked[0].id, 'chess-match'); +}); + +test('explicit category preference beats mined profile alone', () => { + const candidates = [ + makeEvent({ id: 'tech-talk', title: 'Systems Talk', categories: ['Tech'] }), + makeEvent({ id: 'brunch', title: 'Sunday Brunch', categories: ['Food'] }), + ]; + const ranked = rankEventsForUser(candidates, [], ['Tech']); + assert.equal(ranked[0].id, 'tech-talk'); +}); + +test('getSuggestionsForTimeRange only returns overlapping events, capped at limit', () => { + const events = [ + makeEvent({ + id: 'in-range', + startTime: '2026-08-14T19:00:00.000Z', + endTime: '2026-08-14T20:00:00.000Z', + title: 'In Range', + }), + makeEvent({ + id: 'out-of-range', + startTime: '2026-08-15T19:00:00.000Z', + endTime: '2026-08-15T20:00:00.000Z', + title: 'Out Of Range', + }), + ]; + const suggestions = getSuggestionsForTimeRange( + events, + [], + '2026-08-14T18:00:00.000Z', + '2026-08-14T22:00:00.000Z', + 5 + ); + assert.deepEqual(suggestions.map((e) => e.id), ['in-range']); +}); diff --git a/apps/client/tests/recurringEvents.test.ts b/apps/client/tests/recurringEvents.test.ts new file mode 100644 index 0000000..58e9cb0 --- /dev/null +++ b/apps/client/tests/recurringEvents.test.ts @@ -0,0 +1,129 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + expandRecurringEvent, + expandRecurringEvents, + baseEventId, +} from '../utils/recurringEvents'; +import type { Event } from '../types/event'; + +function makeEvent(overrides: Partial = {}): Event { + return { + id: 'evt-1', + title: 'Weekly Standup', + description: '', + startTime: '2026-08-03T14:00:00.000Z', // a Monday + endTime: '2026-08-03T15:00:00.000Z', + location: 'Gates', + categories: ['Tech'], + organizer: { id: 'org', name: 'Org', type: 'club' }, + color: '#FF6B6B', + rsvpEnabled: true, + rsvpCounts: { going: 0, maybe: 0, notGoing: 0 }, + attendees: [], + attendeeVisibility: 'public', + isClubEvent: true, + isSocialEvent: false, + tags: [], + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + }; +} + +test('baseEventId strips occurrence suffixes and leaves plain ids alone', () => { + assert.equal(baseEventId('evt-1::2026-08-10'), 'evt-1'); + assert.equal(baseEventId('evt-1'), 'evt-1'); +}); + +test('non-recurring events produce no occurrences', () => { + const occurrences = expandRecurringEvent( + makeEvent(), + new Date('2026-08-01T00:00:00Z'), + new Date('2026-08-31T00:00:00Z') + ); + assert.equal(occurrences.length, 0); +}); + +test('weekly recurrence generates one occurrence per week, skipping the original', () => { + const event = makeEvent({ recurring: { frequency: 'weekly', interval: 1 } }); + const occurrences = expandRecurringEvent( + event, + new Date('2026-08-01T00:00:00Z'), + new Date('2026-08-31T23:59:59Z') + ); + // Aug 3 is the original; occurrences: Aug 10, 17, 24, 31 + assert.deepEqual( + occurrences.map((o) => o.startTime.slice(0, 10)), + ['2026-08-10', '2026-08-17', '2026-08-24', '2026-08-31'] + ); + // Duration preserved (1 hour) + for (const o of occurrences) { + assert.equal(Date.parse(o.endTime) - Date.parse(o.startTime), 60 * 60 * 1000); + } + // Synthetic ids map back to the base event + for (const o of occurrences) { + assert.equal(baseEventId(o.id), 'evt-1'); + } +}); + +test('recurrence respects the pattern end date', () => { + const event = makeEvent({ + recurring: { frequency: 'weekly', interval: 1, endDate: '2026-08-17' }, + }); + const occurrences = expandRecurringEvent( + event, + new Date('2026-08-01T00:00:00Z'), + new Date('2026-08-31T23:59:59Z') + ); + assert.deepEqual( + occurrences.map((o) => o.startTime.slice(0, 10)), + ['2026-08-10', '2026-08-17'] + ); +}); + +test('daily recurrence with interval 2 skips alternate days', () => { + const event = makeEvent({ recurring: { frequency: 'daily', interval: 2 } }); + const occurrences = expandRecurringEvent( + event, + new Date('2026-08-03T00:00:00Z'), + new Date('2026-08-09T23:59:59Z') + ); + assert.deepEqual( + occurrences.map((o) => o.startTime.slice(0, 10)), + ['2026-08-05', '2026-08-07', '2026-08-09'] + ); +}); + +test('monthly recurrence clamps to shorter months', () => { + const event = makeEvent({ + startTime: '2026-01-31T18:00:00.000Z', + endTime: '2026-01-31T19:00:00.000Z', + recurring: { frequency: 'monthly', interval: 1 }, + }); + const occurrences = expandRecurringEvent( + event, + new Date('2026-02-01T00:00:00Z'), + new Date('2026-03-31T23:59:59Z') + ); + // Feb has 28 days in 2026 + assert.deepEqual( + occurrences.map((o) => o.startTime.slice(0, 10)), + ['2026-02-28', '2026-03-31'] + ); +}); + +test('expandRecurringEvents keeps originals and appends occurrences', () => { + const recurring = makeEvent({ recurring: { frequency: 'weekly', interval: 1 } }); + const plain = makeEvent({ id: 'evt-2', recurring: undefined }); + const result = expandRecurringEvents( + [recurring, plain], + new Date('2026-08-01T00:00:00Z'), + new Date('2026-08-16T23:59:59Z') + ); + const ids = result.map((e) => e.id); + assert.ok(ids.includes('evt-1')); + assert.ok(ids.includes('evt-2')); + assert.ok(ids.includes('evt-1::2026-08-10')); + assert.equal(result.length, 3); // two originals + the Aug 10 occurrence +}); diff --git a/apps/client/types/event.ts b/apps/client/types/event.ts index 3c2f2a7..af5985f 100644 --- a/apps/client/types/event.ts +++ b/apps/client/types/event.ts @@ -78,5 +78,6 @@ export interface EventFormData { color: string; tags: string[]; recurring?: RecurringPattern; + imageUrl?: string; } diff --git a/apps/client/types/settings.ts b/apps/client/types/settings.ts index e7e66b5..11a4f27 100644 --- a/apps/client/types/settings.ts +++ b/apps/client/types/settings.ts @@ -51,13 +51,3 @@ export interface UserSettings { }; } -export interface AppSettings { - version: string; - apiEndpoint?: string; - features: { - googleCalendarSync: boolean; - slackIntegration: boolean; - discordIntegration: boolean; - instagramScraping: boolean; - }; -} diff --git a/apps/client/utils/dedupe.ts b/apps/client/utils/dedupe.ts new file mode 100644 index 0000000..d4aec8c --- /dev/null +++ b/apps/client/utils/dedupe.ts @@ -0,0 +1,75 @@ +/** + * Cross-source event deduplication. + * + * Events arrive from multiple ingestion paths (app, Slack bot, Discord bot). + * The same announcement posted in two places produces two events with + * different ids, so id-based dedup is not enough. Two events are treated as + * likely duplicates when their titles are similar AND their start times are + * close together. + * + * The same algorithm is mirrored in apps/slack-bot/src/dedupe.ts and + * apps/discord-event-bot/src/dedupe.js — keep the three in sync. + */ + +export interface DedupeCandidate { + id: string; + title: string; + startTime: string; // ISO 8601 +} + +/** Lowercase, strip punctuation/emoji, collapse whitespace. */ +export function normalizeTitle(title: string): string { + return (title || '') + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Jaccard similarity over title token sets, in [0, 1]. + * "Poker Night @ Wiegand" vs "poker night wiegand gym" -> high. + */ +export function titleSimilarity(a: string, b: string): number { + const tokensA = new Set(normalizeTitle(a).split(' ').filter(Boolean)); + const tokensB = new Set(normalizeTitle(b).split(' ').filter(Boolean)); + if (tokensA.size === 0 || tokensB.size === 0) return 0; + + let intersection = 0; + for (const t of tokensA) if (tokensB.has(t)) intersection++; + const union = tokensA.size + tokensB.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +const SIMILARITY_THRESHOLD = 0.6; +const TIME_WINDOW_MS = 2 * 60 * 60 * 1000; // 2 hours + +/** True when two events probably describe the same real-world announcement. */ +export function isLikelyDuplicate(a: DedupeCandidate, b: DedupeCandidate): boolean { + if (a.id === b.id) return true; + + const startA = Date.parse(a.startTime); + const startB = Date.parse(b.startTime); + if (!Number.isFinite(startA) || !Number.isFinite(startB)) return false; + if (Math.abs(startA - startB) > TIME_WINDOW_MS) return false; + + return titleSimilarity(a.title, b.title) >= SIMILARITY_THRESHOLD; +} + +/** + * Return the subset of `incoming` that does not duplicate anything in + * `existing` (by id or by title/time similarity), and does not duplicate an + * earlier entry of `incoming` itself. + */ +export function dedupeAgainst( + existing: DedupeCandidate[], + incoming: T[] +): T[] { + const kept: T[] = []; + for (const candidate of incoming) { + const duplicatesExisting = existing.some((e) => isLikelyDuplicate(e, candidate)); + const duplicatesKept = kept.some((e) => isLikelyDuplicate(e, candidate)); + if (!duplicatesExisting && !duplicatesKept) kept.push(candidate); + } + return kept; +} diff --git a/apps/client/utils/recommendationEngine.ts b/apps/client/utils/recommendationEngine.ts new file mode 100644 index 0000000..5e48c14 --- /dev/null +++ b/apps/client/utils/recommendationEngine.ts @@ -0,0 +1,415 @@ +/** + * Pure recommendation engine — no React, no context, safe to unit-test. + * The hooks in hooks/useRecommendations.ts wrap these functions. + */ +import type { Event } from '@/types/event' + + +type TimeBucket = 'morning' | 'afternoon' | 'evening' | 'night' + +export type Interest = { + key: string + label: string + type: 'tag' | 'category' | 'time' + count: number + score: number +} + +function normalizeWord(w: string) { + if (!w) return '' + let s = w.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').trim() + if (s.endsWith('s') && s.length > 3) s = s.slice(0, -1) + return s +} + +/** + * Tokenize free text with PER-TOKEN normalization. normalizeWord only stems + * a trailing "s" on the whole string, so "Late Night Chess" would keep + * "night" plural-agnostic but lose the final "s" of "chess" while + * "Chess Club" kept it — titles and mined interest labels then disagree. + * Normalizing token-by-token keeps both sides consistent. + */ +function normalizeTokens(text: string): string[] { + if (!text) return [] + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter(Boolean) + .map(t => (t.endsWith('s') && t.length > 3 ? t.slice(0, -1) : t)) +} + +function normalizeText(text: string): string { + return normalizeTokens(text).join(' ') +} + +function hourToBucket(h: number): TimeBucket { + if (h >= 6 && h < 12) return 'morning' + if (h >= 12 && h < 17) return 'afternoon' + if (h >= 17 && h < 22) return 'evening' + return 'night' +} + +/** + * Analyze events and return ranked interests. + * - extracts interests from the *title* into tags + * - counts categories + * - weights by popularity (rsvp going) + * - computes time-of-day preferences + */ +export function analyzeEvents( + events: Event[], + startISO?: string, + endISO?: string +) { + // optionally filter events to only those overlapping the provided window + let source = events || [] + if (startISO && endISO) { + const A0 = Date.parse(startISO) + const A1 = Date.parse(endISO) + if (Number.isFinite(A0) && Number.isFinite(A1) && A1 > A0) { + source = source.filter(e => { + const s = Date.parse((e as any).startTime || (e as any).start_ts || '') + const t = Date.parse((e as any).endTime || (e as any).end_ts || '') + if (!Number.isFinite(s) || !Number.isFinite(t)) return false + const overlap = Math.max(0, Math.min(A1, t) - Math.max(A0, s)) + return overlap > 0 + }) + } + } + + const tagCounts = new Map() + const catCounts = new Map() + const timeCounts = new Map([ + ['morning', { count: 0, weight: 0 }], + ['afternoon', { count: 0, weight: 0 }], + ['evening', { count: 0, weight: 0 }], + ['night', { count: 0, weight: 0 }], + ]) + + for (const e of source || []) { + // determine popularity weight + const pop = (e as any).rsvpCounts + ? Math.log1p(((e as any).rsvpCounts.going || 0)) + 1 + : 1 + + // categories + for (const c of (e as any).categories || []) { + const k = normalizeWord(c) + if (!k) continue + const cur = catCounts.get(k) || { count: 0, weight: 0 } + cur.count += 1 + cur.weight += pop + catCounts.set(k, cur) + } + + // interests (tags) extracted from the *title* + const stopwords = new Set([ + 'the', + 'and', + 'a', + 'an', + 'in', + 'on', + 'at', + 'for', + 'of', + 'to', + 'with', + 'by', + 'from', + 'is', + 'are', + 'this', + 'that', + 'your', + 'you', + 'meet', + 'session', + 'night', + 'event', + 'club', + 'cmu', + 'university', + 'online', + ]) + const title = ((e as any).title || (e as any).name || '').toString() + const rawTokens = normalizeTokens(title) + const filtered = rawTokens.filter(t => !stopwords.has(t)) + const tokensForNgrams = filtered.length ? filtered : rawTokens + + // generate n-grams for n = 1..3 + const maxN = 3 + if (!Array.isArray(tokensForNgrams) || tokensForNgrams.length === 0) { + // nothing useful in this title + } else { + for (let n = 1; n <= maxN; n++) { + if (tokensForNgrams.length < n) continue + for (let i = 0; i <= tokensForNgrams.length - n; i++) { + const slice = tokensForNgrams.slice(i, i + n) + // skip grams that include very short tokens + if (slice.some(s => s.length < 2)) continue + const gram = slice.join(' ') + // skip overly short grams + if (gram.length < 3) continue + const k = gram + const cur = tagCounts.get(k) || { count: 0, weight: 0 } + cur.count += 1 + cur.weight += pop + tagCounts.set(k, cur) + } + } + } + + // time bucket + const s = Date.parse((e as any).startTime || (e as any).start_ts || '') + if (!Number.isFinite(s)) continue + const dt = new Date(s) + const bucket = hourToBucket(dt.getUTCHours()) + const curT = timeCounts.get(bucket)! + curT.count += 1 + curT.weight += pop + timeCounts.set(bucket, curT) + } + + // build interest objects + const tags: Interest[] = Array.from(tagCounts.entries()).map(([k, v]) => ({ + key: `tag:${k}`, + label: k, + type: 'tag', + count: v.count, + score: v.weight, + })) + + const categories: Interest[] = Array.from(catCounts.entries()).map( + ([k, v]) => ({ + key: `category:${k}`, + label: k, + type: 'category', + count: v.count, + score: v.weight, + }) + ) + + const times: Interest[] = Array.from(timeCounts.entries()).map(([k, v]) => ({ + key: `time:${k}`, + label: k, + type: 'time', + count: v.count, + score: v.weight, + })) + + // sort each list by score descending + tags.sort((a, b) => b.score - a.score) + categories.sort((a, b) => b.score - a.score) + times.sort((a, b) => b.score - a.score) + + // combined top interests (merge top N from each type) + const combined = [ + ...tags.slice(0, 10), + ...categories.slice(0, 10), + ...times.slice(0, 4), + ] + combined.sort((a, b) => b.score - a.score) + + return { tags, categories, times, top: combined.slice(0, 10) } +} + +/** + * Build a separate JSON representation of all events happening. + */ +export function buildEventsJson(events: Event[]) { + return (events || []).map(e => { + const anyE = e as any + return { + id: anyE.id, + title: anyE.title || anyE.name || '', + startTime: anyE.startTime || anyE.start_ts || null, + endTime: anyE.endTime || anyE.end_ts || null, + categories: anyE.categories || [], + tags: anyE.tags || [], + rsvpCounts: anyE.rsvpCounts || null, + } + }) +} + +/** + * Given a search string and user interests, return events the user + * might be interested in. + * - Matches search text against title + * - Boosts matches that contain top interest phrases + */ +export function getRecommendedEvents( + events: Event[], + topInterests: Interest[], + search: string +) { + const normSearch = normalizeText(search || '') + if (!normSearch) return [] + + const searchTokens = normSearch.split(/\s+/).filter(Boolean) + if (!searchTokens.length) return [] + + const topInterestLabels = topInterests.map(i => i.label.toLowerCase()) + + const scored: { event: Event; score: number }[] = [] + + for (const e of events || []) { + const anyE = e as any + const titleRaw = (anyE.title || anyE.name || '').toString() + const titleNorm = normalizeText(titleRaw) + if (!titleNorm) continue + + let score = 0 + + // Direct match with search terms in title + for (const token of searchTokens) { + if (titleNorm.includes(token)) { + score += 2 + } + } + + // Match user interests (from titles) + for (const label of topInterestLabels) { + if (label && titleNorm.includes(label)) { + score += 3 + } + } + + if (score > 0) { + scored.push({ event: e, score }) + } + } + + scored.sort((a, b) => b.score - a.score) + + return scored.map(s => s.event) +} + +/** + * Get suggestions for a specific time range based on user interests. + * Returns events that overlap with the time range, scored by user interests. + */ +export function getSuggestionsForTimeRange( + events: Event[], + topInterests: Interest[], + startISO: string, + endISO: string, + limit: number = 5 +): Event[] { + if (!startISO || !endISO) return [] + + const rangeStart = Date.parse(startISO) + const rangeEnd = Date.parse(endISO) + + if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd) || rangeEnd <= rangeStart) { + return [] + } + + const topInterestLabels = topInterests.map(i => i.label.toLowerCase()) + const scored: { event: Event; score: number }[] = [] + + for (const e of events || []) { + const anyE = e as any + const eventStart = Date.parse(anyE.startTime || anyE.start_ts || '') + const eventEnd = Date.parse(anyE.endTime || anyE.end_ts || '') + + // Check if event overlaps with the time range + if (!Number.isFinite(eventStart) || !Number.isFinite(eventEnd)) continue + const overlap = Math.max(0, Math.min(rangeEnd, eventEnd) - Math.max(rangeStart, eventStart)) + if (overlap <= 0) continue + + // Score based on user interests + const titleRaw = (anyE.title || anyE.name || '').toString() + const titleNorm = normalizeText(titleRaw) + if (!titleNorm) continue + + let score = 0 + + // Match user interests (from titles) + for (const label of topInterestLabels) { + if (label && titleNorm.includes(label)) { + score += 3 + } + } + + // Boost score based on popularity + const pop = anyE.rsvpCounts + ? Math.log1p((anyE.rsvpCounts.going || 0)) + 1 + : 1 + score += pop + + // Prefer events that fit better in the time range + const eventDuration = eventEnd - eventStart + const rangeDuration = rangeEnd - rangeStart + if (eventDuration <= rangeDuration) { + score += 2 + } + + scored.push({ event: e, score }) + } + + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, limit).map(s => s.event) +} + +/** + * Rank candidate events for a user given their interest profile. + * + * - topInterests: n-gram/category/time interests mined (via analyzeEvents) + * from events the user engaged with (scheduled, RSVP'd, created) + * - explicitCategories: categories the user picked in Preferences + * + * Scoring: explicit category match (+4 each), profile category match (+2), + * interest phrase in title (+3), preferred time-of-day bucket (+1), + * popularity (log1p(going) + 1). Ties break toward the sooner event. + * Returns all candidates sorted best-first; callers slice to taste. + */ +export function rankEventsForUser( + candidates: Event[], + topInterests: Interest[], + explicitCategories: string[] = [] +): Event[] { + const tagLabels = topInterests + .filter(i => i.type === 'tag') + .map(i => i.label.toLowerCase()) + const categoryLabels = topInterests + .filter(i => i.type === 'category') + .map(i => i.label.toLowerCase()) + const timeLabels = new Set( + topInterests.filter(i => i.type === 'time').map(i => i.label) + ) + const explicit = explicitCategories.map(c => normalizeWord(c)) + + const scored = (candidates || []).map(e => { + const anyE = e as any + const titleNorm = normalizeText((anyE.title || '').toString()) + const eventCategories: string[] = ((anyE.categories || []) as string[]).map(c => + normalizeWord(c) + ) + + let score = 0 + for (const cat of explicit) { + if (cat && eventCategories.includes(cat)) score += 4 + } + for (const cat of categoryLabels) { + if (cat && eventCategories.includes(cat)) score += 2 + } + for (const label of tagLabels) { + if (label && titleNorm.includes(label)) score += 3 + } + + const start = Date.parse(anyE.startTime || '') + if (Number.isFinite(start) && timeLabels.size > 0) { + if (timeLabels.has(hourToBucket(new Date(start).getUTCHours()))) score += 1 + } + + score += anyE.rsvpCounts ? Math.log1p(anyE.rsvpCounts.going || 0) + 1 : 1 + + return { event: e, score, start: Number.isFinite(start) ? start : Infinity } + }) + + scored.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.start - b.start)) + return scored.map(s => s.event) +} + diff --git a/apps/client/utils/recurringEvents.ts b/apps/client/utils/recurringEvents.ts new file mode 100644 index 0000000..ae03e10 --- /dev/null +++ b/apps/client/utils/recurringEvents.ts @@ -0,0 +1,128 @@ +import { Event } from '@/types/event'; + +/** + * Separator between a base event id and an occurrence date in synthetic + * occurrence ids, e.g. "evt-123::2026-08-19". Occurrences are display-only + * copies; strip the suffix to get back to the real event. + */ +export const OCCURRENCE_ID_SEPARATOR = '::'; + +/** Map a (possibly synthetic occurrence) id back to its base event id. */ +export function baseEventId(id: string): string { + const index = id.indexOf(OCCURRENCE_ID_SEPARATOR); + return index === -1 ? id : id.slice(0, index); +} + +const DAY_MS = 24 * 60 * 60 * 1000; +// Hard cap per event so a malformed pattern can never hang the UI +const MAX_OCCURRENCES = 366; + +function addMonths(date: Date, months: number): Date { + const result = new Date(date); + const targetDay = result.getDate(); + result.setDate(1); + result.setMonth(result.getMonth() + months); + // Clamp to the last day of the target month (Jan 31 + 1 month -> Feb 28/29) + const daysInMonth = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate(); + result.setDate(Math.min(targetDay, daysInMonth)); + return result; +} + +function makeOccurrence(event: Event, start: Date, durationMs: number): Event { + const end = new Date(start.getTime() + durationMs); + const dateKey = start.toISOString().slice(0, 10); + return { + ...event, + id: `${event.id}${OCCURRENCE_ID_SEPARATOR}${dateKey}`, + startTime: start.toISOString(), + endTime: end.toISOString(), + }; +} + +/** + * Generate the occurrences of a single recurring event that overlap + * [rangeStart, rangeEnd]. The original occurrence (the event's own + * start/end) is NOT included — callers already have the base event. + */ +export function expandRecurringEvent( + event: Event, + rangeStart: Date, + rangeEnd: Date +): Event[] { + const pattern = event.recurring; + if (!pattern) return []; + + const interval = Math.max(1, Math.floor(pattern.interval || 1)); + const firstStart = new Date(event.startTime); + const firstEnd = new Date(event.endTime); + if (isNaN(firstStart.getTime()) || isNaN(firstEnd.getTime())) return []; + const durationMs = Math.max(firstEnd.getTime() - firstStart.getTime(), 0); + + // Recurrence stops at the pattern end date (inclusive) or the range end + const patternEnd = pattern.endDate ? new Date(`${pattern.endDate}T23:59:59.999Z`) : null; + const hardEnd = patternEnd && patternEnd < rangeEnd ? patternEnd : rangeEnd; + if (hardEnd < firstStart) return []; + + const occurrences: Event[] = []; + + const pushIfInRange = (start: Date) => { + if (start.getTime() === firstStart.getTime()) return; // skip the original + const end = new Date(start.getTime() + durationMs); + if (end >= rangeStart && start <= hardEnd) { + occurrences.push(makeOccurrence(event, start, durationMs)); + } + }; + + if (pattern.frequency === 'daily') { + for (let i = 1; i <= MAX_OCCURRENCES; i++) { + const start = new Date(firstStart.getTime() + i * interval * DAY_MS); + if (start > hardEnd) break; + pushIfInRange(start); + } + } else if (pattern.frequency === 'weekly') { + const daysOfWeek = + pattern.daysOfWeek && pattern.daysOfWeek.length > 0 + ? pattern.daysOfWeek + : [firstStart.getDay()]; + // Walk week by week from the first occurrence's week + for (let week = 0; week <= MAX_OCCURRENCES; week += interval) { + let anyInFuture = false; + for (const dow of daysOfWeek) { + const dayOffset = (dow - firstStart.getDay() + 7) % 7; + const start = new Date(firstStart.getTime() + (week * 7 + dayOffset) * DAY_MS); + if (start <= hardEnd) anyInFuture = true; + if (start > hardEnd) continue; + pushIfInRange(start); + } + if (!anyInFuture && week > 0) break; + if (occurrences.length >= MAX_OCCURRENCES) break; + } + } else if (pattern.frequency === 'monthly') { + for (let i = 1; i <= MAX_OCCURRENCES; i++) { + const start = addMonths(firstStart, i * interval); + if (start > hardEnd) break; + pushIfInRange(start); + } + } + + return occurrences.slice(0, MAX_OCCURRENCES); +} + +/** + * Expand every recurring event in `events` into display occurrences that + * overlap [rangeStart, rangeEnd], returning the original list plus the + * generated occurrences. + */ +export function expandRecurringEvents( + events: Event[], + rangeStart: Date, + rangeEnd: Date +): Event[] { + const expanded: Event[] = [...events]; + for (const event of events) { + if (event.recurring) { + expanded.push(...expandRecurringEvent(event, rangeStart, rangeEnd)); + } + } + return expanded; +} diff --git a/apps/client/vercel.json b/apps/client/vercel.json index 2ad91c5..fb1d642 100644 --- a/apps/client/vercel.json +++ b/apps/client/vercel.json @@ -1,5 +1,6 @@ { - "installCommand": "pnpm install --no-frozen-lockfile", + "installCommand": "pnpm install --frozen-lockfile", + "outputDirectory": "dist", "rewrites": [ { "source": "/(.*)", "destination": "/index.html" } ] diff --git a/apps/discord-event-bot/AGENTS.md b/apps/discord-event-bot/AGENTS.md index 702302e..a645de6 100644 --- a/apps/discord-event-bot/AGENTS.md +++ b/apps/discord-event-bot/AGENTS.md @@ -32,8 +32,11 @@ Storage: - Persist review sessions locally so restarts do not lose state Backend: -- Do not implement real backend submission yet -- Stub submission with a function that logs the final normalized payload +- Approved events are inserted into the shared Supabase `events` table via + `src/supabaseSubmit.js` (service-role key; see .env.example) +- A cross-source duplicate check (`src/dedupe.js`) runs before insert +- When Supabase env vars are missing, submission falls back to logging the + normalized payload Coding expectations: - Generate complete runnable files, not snippets diff --git a/apps/discord-event-bot/README.md b/apps/discord-event-bot/README.md index 577bda9..4dc1c74 100644 --- a/apps/discord-event-bot/README.md +++ b/apps/discord-event-bot/README.md @@ -14,9 +14,9 @@ A Node.js Discord bot that monitors announcement channels for event posts, parse ## Setup 1. Clone this repository -2. Install dependencies: +2. Install dependencies (this is a pnpm workspace — run from the repo root): ```bash - npm install + pnpm install ``` 3. Create a `.env` file based on `.env.example`: diff --git a/apps/discord-event-bot/package.json b/apps/discord-event-bot/package.json index da75d80..0368613 100644 --- a/apps/discord-event-bot/package.json +++ b/apps/discord-event-bot/package.json @@ -1,6 +1,5 @@ { "name": "discord-event-bot", - "private": true, "version": "1.0.0", "description": "A Discord bot for reviewing event announcements", "main": "src/bot.js", @@ -21,9 +20,9 @@ "chrono-node": "^2.7.0", "discord.js": "^14.14.1", "dotenv": "^16.3.1", - "luxon": "^3.7.2", - "moment-timezone": "^0.5.43" + "luxon": "^3.7.2" }, "packageManager": "pnpm@10.18.3", "private": true } + diff --git a/apps/discord-event-bot/src/dedupe.js b/apps/discord-event-bot/src/dedupe.js new file mode 100644 index 0000000..1d6c44e --- /dev/null +++ b/apps/discord-event-bot/src/dedupe.js @@ -0,0 +1,60 @@ +/** + * Cross-source event deduplication for the Discord bot. + * + * Two events are treated as likely duplicates when their titles are similar + * (Jaccard over tokens) and their start times fall within a 2-hour window. + * + * Mirrors apps/client/utils/dedupe.ts and apps/slack-bot/src/dedupe.ts — + * keep the three in sync. + */ + +const SIMILARITY_THRESHOLD = 0.6; +const TIME_WINDOW_MS = 2 * 60 * 60 * 1000; // 2 hours + +/** Lowercase, strip punctuation/emoji, collapse whitespace. */ +function normalizeTitle(title) { + return (title || "") + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** Jaccard similarity over title token sets, in [0, 1]. */ +function titleSimilarity(a, b) { + const tokensA = new Set(normalizeTitle(a).split(" ").filter(Boolean)); + const tokensB = new Set(normalizeTitle(b).split(" ").filter(Boolean)); + if (tokensA.size === 0 || tokensB.size === 0) return 0; + + let intersection = 0; + for (const t of tokensA) if (tokensB.has(t)) intersection++; + const union = tokensA.size + tokensB.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +/** + * True when `candidate` ({title, startTime}) probably duplicates one of + * `existing` ([{id, title, startTime}]). + */ +function isDuplicate(candidate, existing) { + const candidateStart = Date.parse(candidate.startTime); + if (!Number.isFinite(candidateStart)) return false; + + for (const other of existing) { + const otherStart = Date.parse(other.startTime); + if (!Number.isFinite(otherStart)) continue; + if (Math.abs(candidateStart - otherStart) > TIME_WINDOW_MS) continue; + if (titleSimilarity(candidate.title, other.title) >= SIMILARITY_THRESHOLD) { + return true; + } + } + return false; +} + +module.exports = { + normalizeTitle, + titleSimilarity, + isDuplicate, + TIME_WINDOW_MS, + SIMILARITY_THRESHOLD, +}; diff --git a/apps/discord-event-bot/src/parser.js b/apps/discord-event-bot/src/parser.js deleted file mode 100644 index 5763dae..0000000 --- a/apps/discord-event-bot/src/parser.js +++ /dev/null @@ -1,73 +0,0 @@ -const chrono = require('chrono-node'); -const moment = require('moment-timezone'); - -class EventParser { - parse(messageContent, messageCreatedAt) { - const content = messageContent.toLowerCase(); - - // Extract title - first line or after "event:" - let title = ''; - const lines = messageContent.split('\n'); - for (const line of lines) { - if (line.toLowerCase().includes('event:') || line.toLowerCase().includes('title:')) { - title = line.split(':')[1]?.trim() || ''; - break; - } - } - if (!title && lines.length > 0) { - title = lines[0].trim(); - } - - // Extract date/time using chrono, with reference to message time in NY timezone - const referenceDate = moment(messageCreatedAt).tz('America/New_York').toDate(); - const parsedDate = chrono.parse(messageContent, referenceDate, { timezone: 'America/New_York' }); - let startTime = null; - let endTime = null; - - if (parsedDate.length > 0) { - const result = parsedDate[0]; - startTime = moment(result.start.date()).tz('America/New_York').toISOString(); - if (result.end) { - endTime = moment(result.end.date()).tz('America/New_York').toISOString(); - } else { - endTime = startTime; // If only start time, set end equal to start - } - } - - // Extract location - let location = ''; - for (const line of lines) { - if (line.toLowerCase().includes('location:') || line.toLowerCase().includes('where:')) { - location = line.split(':')[1]?.trim() || ''; - break; - } - } - - // Extract description - everything else - let description = ''; - const descLines = lines.filter(line => - !line.toLowerCase().includes('event:') && - !line.toLowerCase().includes('title:') && - !line.toLowerCase().includes('date:') && - !line.toLowerCase().includes('time:') && - !line.toLowerCase().includes('location:') && - !line.toLowerCase().includes('where:') && - line.trim() - ); - description = descLines.join('\n').trim(); - - return { - title: title || null, - start_time: startTime, - end_time: endTime, - location: location || null, - description: description || null, - organizer_name: null, // Will be set to server name - organizer_type: 'club', - is_club_event: true, - is_social_event: false - }; - } -} - -module.exports = EventParser; \ No newline at end of file diff --git a/apps/discord-event-bot/src/storage.js b/apps/discord-event-bot/src/storage.js deleted file mode 100644 index e7f93cc..0000000 --- a/apps/discord-event-bot/src/storage.js +++ /dev/null @@ -1,53 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -class LocalStorage { - constructor() { - this.storagePath = path.join(__dirname, '..', 'data', 'reviews.json'); - this.ensureStorageDir(); - this.reviews = this.loadReviews(); - } - - ensureStorageDir() { - const dir = path.dirname(this.storagePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - } - - loadReviews() { - try { - if (fs.existsSync(this.storagePath)) { - const data = fs.readFileSync(this.storagePath, 'utf8'); - return JSON.parse(data); - } - } catch (error) { - console.error('Error loading reviews:', error); - } - return {}; - } - - saveReviews() { - try { - fs.writeFileSync(this.storagePath, JSON.stringify(this.reviews, null, 2)); - } catch (error) { - console.error('Error saving reviews:', error); - } - } - - getReview(channelId) { - return this.reviews[channelId]; - } - - setReview(channelId, data) { - this.reviews[channelId] = data; - this.saveReviews(); - } - - deleteReview(channelId) { - delete this.reviews[channelId]; - this.saveReviews(); - } -} - -module.exports = LocalStorage; \ No newline at end of file diff --git a/apps/discord-event-bot/src/supabaseSubmit.js b/apps/discord-event-bot/src/supabaseSubmit.js index cd9f962..bac1fdf 100644 --- a/apps/discord-event-bot/src/supabaseSubmit.js +++ b/apps/discord-event-bot/src/supabaseSubmit.js @@ -1,4 +1,5 @@ const { createClient } = require("@supabase/supabase-js"); +const { isDuplicate, TIME_WINDOW_MS } = require("./dedupe"); /** * Insert an approved Discord event into Supabase `events` (same shape as Expo client). @@ -84,6 +85,33 @@ async function submitApprovedEvent(session) { auth: { persistSession: false, autoRefreshToken: false }, }); + // Cross-source dedupe: skip if an existing event (from the app, Slack, or + // an earlier Discord submission) within ±2h looks like the same announcement. + const startMs = Date.parse(session.draft.start_time); + if (Number.isFinite(startMs)) { + const windowStart = new Date(startMs - TIME_WINDOW_MS).toISOString(); + const windowEnd = new Date(startMs + TIME_WINDOW_MS).toISOString(); + const { data: nearby, error: queryError } = await supabase + .from("events") + .select("id, title, start_time") + .gte("start_time", windowStart) + .lte("start_time", windowEnd); + + if (!queryError && Array.isArray(nearby)) { + const candidates = nearby.map((r) => ({ + id: r.id, + title: r.title || "", + startTime: r.start_time, + })); + if (isDuplicate({ title: session.draft.title, startTime: session.draft.start_time }, candidates)) { + return { + ok: false, + error: "An event with a very similar title already exists around this time — looks like a duplicate.", + }; + } + } + } + const row = draftToRow(session.draft, session); const { data, error } = await supabase.from("events").insert([row]).select("id").single(); diff --git a/apps/slack-bot/.env.example b/apps/slack-bot/.env.example new file mode 100644 index 0000000..5263ef9 --- /dev/null +++ b/apps/slack-bot/.env.example @@ -0,0 +1,29 @@ +# Slack credentials — create an app at https://api.slack.com/apps +# Bot token (OAuth & Permissions -> Bot User OAuth Token) +SLACK_BOT_TOKEN=xoxb-your-bot-token +# Signing secret (Basic Information -> App Credentials) +SLACK_SIGNING_SECRET=your_signing_secret +# App-level token with connections:write scope (Basic Information -> App-Level Tokens) +# Required for Socket Mode real-time listening; REST API works without it. +SLACK_APP_TOKEN=xapp-your-app-token + +# REST API port for the Expo client integration +PORT=3001 + +# Supabase persistence — approved events are inserted into the same `events` +# table the mobile app and Discord bot use. Omit to keep events in memory only. +# Project Settings -> API: Project URL and service_role key (keep secret). +SUPABASE_URL=https://YOUR_PROJECT.supabase.co +SUPABASE_SERVICE_ROLE_KEY= +# auth.users UUID of a dedicated bot user, used as organizer_id for imports +SUPABASE_ORGANIZER_ID= + +# Optional: channel ID for a human-in-the-loop review flow. When set, parsed +# events are posted here with Approve/Reject buttons instead of being +# persisted automatically. +# SLACK_REVIEW_CHANNEL=C0123456789 + +# Optional: LLM tie-break for borderline duplicate detection via OpenRouter. +# Without a key, deduplication is heuristic-only (title similarity + time). +# OPENROUTER_API_KEY=sk-or-... +# OPENROUTER_MODEL=anthropic/claude-haiku-4.5 diff --git a/apps/slack-bot/README.md b/apps/slack-bot/README.md new file mode 100644 index 0000000..7ef6b1a --- /dev/null +++ b/apps/slack-bot/README.md @@ -0,0 +1,50 @@ +# Universify Slack Bot + +Ingests event announcements from Slack channels into Universify. + +Two services run from one process: + +1. **Bolt.js Socket Mode listener** — receives messages in real time from + every channel the bot is in (or only the monitored ones), parses them into + structured events, and persists them to the shared Supabase `events` table. +2. **Express REST API** (default port 3001) — lets the Expo client list + channels and import parsed events on demand + (`/api/slack/health`, `/channels`, `/events?channel=…`, `/cached`). + +## Pipeline + +``` +Slack message ─► regex parser (dates, time ranges, locations, categories) + ─► in-memory store (REST API cache) + ─► duplicate check (title similarity + ±2h window, + optional LLM tie-break via OpenRouter) + ─► Supabase `events` insert (service-role key) +``` + +With `SLACK_REVIEW_CHANNEL` set, events are first posted to that channel +with **Approve / Reject** buttons and only persisted on approval — the +human-in-the-loop equivalent of the Discord bot's review flow. + +## Setup + +```bash +# From the repo root (pnpm workspace) +pnpm install + +cd apps/slack-bot +cp .env.example .env # fill in Slack + Supabase credentials +pnpm dev +``` + +See `.env.example` for every variable and what it enables. Degradation is +graceful: without `SLACK_APP_TOKEN` only the REST API runs; without Supabase +credentials events stay in memory; without `OPENROUTER_API_KEY` dedupe is +heuristic-only. + +## Scripts + +| Command | What it does | +|---|---| +| `pnpm dev` / `pnpm start` | Run via ts-node | +| `pnpm typecheck` | TypeScript check | +| `pnpm build` && `pnpm serve` | Compile to `dist/` and run compiled JS | diff --git a/apps/slack-bot/package.json b/apps/slack-bot/package.json index 5c01b5f..569bbd4 100644 --- a/apps/slack-bot/package.json +++ b/apps/slack-bot/package.json @@ -8,11 +8,13 @@ "dev": "ts-node src/index.ts", "start": "ts-node src/index.ts", "build": "tsc", + "typecheck": "tsc --noEmit", "serve": "node dist/index.js" }, "dependencies": { "@slack/bolt": "^4.1.0", "@slack/web-api": "^7.8.0", + "@supabase/supabase-js": "^2.112.3", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2" diff --git a/apps/slack-bot/src/dedupe.ts b/apps/slack-bot/src/dedupe.ts new file mode 100644 index 0000000..59d7a29 --- /dev/null +++ b/apps/slack-bot/src/dedupe.ts @@ -0,0 +1,128 @@ +/** + * Cross-source event deduplication for the Slack bot. + * + * Heuristic pass: two events are likely duplicates when their titles are + * similar (Jaccard over tokens) and their start times fall within a 2-hour + * window. Optional LLM pass: when OPENROUTER_API_KEY is set, borderline + * pairs (similar-ish titles) are double-checked by a small LLM call. + * + * Mirrors apps/client/utils/dedupe.ts and + * apps/discord-event-bot/src/dedupe.js — keep the three in sync. + */ + +export interface DedupeCandidate { + id?: string; + title: string; + startTime: string; // ISO 8601 +} + +/** Lowercase, strip punctuation/emoji, collapse whitespace. */ +export function normalizeTitle(title: string): string { + return (title || '') + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Jaccard similarity over title token sets, in [0, 1]. */ +export function titleSimilarity(a: string, b: string): number { + const tokensA = new Set(normalizeTitle(a).split(' ').filter(Boolean)); + const tokensB = new Set(normalizeTitle(b).split(' ').filter(Boolean)); + if (tokensA.size === 0 || tokensB.size === 0) return 0; + + let intersection = 0; + for (const t of tokensA) if (tokensB.has(t)) intersection++; + const union = tokensA.size + tokensB.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +const SIMILARITY_THRESHOLD = 0.6; +// Below this the pair is clearly distinct; between the two the LLM (if +// configured) breaks the tie. +const LLM_GRAY_ZONE_FLOOR = 0.35; +export const TIME_WINDOW_MS = 2 * 60 * 60 * 1000; // 2 hours + +export function isWithinTimeWindow(a: DedupeCandidate, b: DedupeCandidate): boolean { + const startA = Date.parse(a.startTime); + const startB = Date.parse(b.startTime); + if (!Number.isFinite(startA) || !Number.isFinite(startB)) return false; + return Math.abs(startA - startB) <= TIME_WINDOW_MS; +} + +/** Heuristic-only check (no network). */ +export function isLikelyDuplicate(a: DedupeCandidate, b: DedupeCandidate): boolean { + if (a.id && b.id && a.id === b.id) return true; + if (!isWithinTimeWindow(a, b)) return false; + return titleSimilarity(a.title, b.title) >= SIMILARITY_THRESHOLD; +} + +/** + * LLM tie-break for borderline pairs via OpenRouter. Returns null when no + * API key is configured or the call fails, so callers can fall back to the + * heuristic verdict. + */ +async function llmSaysDuplicate( + a: DedupeCandidate, + b: DedupeCandidate +): Promise { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) return null; + + try { + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: process.env.OPENROUTER_MODEL || 'anthropic/claude-haiku-4.5', + max_tokens: 5, + messages: [ + { + role: 'user', + content: + 'Two event announcements from different chat platforms may describe the same real-world event. ' + + `Event A: "${a.title}" starting ${a.startTime}. ` + + `Event B: "${b.title}" starting ${b.startTime}. ` + + 'Answer with exactly one word: "yes" if they are the same event, "no" otherwise.', + }, + ], + }), + }); + + if (!response.ok) return null; + const json = (await response.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const answer = json.choices?.[0]?.message?.content?.trim().toLowerCase(); + if (answer?.startsWith('yes')) return true; + if (answer?.startsWith('no')) return false; + return null; + } catch { + return null; + } +} + +/** + * Full duplicate check: heuristic first, LLM tie-break for the gray zone + * when OPENROUTER_API_KEY is configured. + */ +export async function isDuplicate( + candidate: DedupeCandidate, + existing: DedupeCandidate[] +): Promise { + for (const other of existing) { + if (candidate.id && other.id && candidate.id === other.id) continue; // same record, not a cross-source dup + if (!isWithinTimeWindow(candidate, other)) continue; + + const similarity = titleSimilarity(candidate.title, other.title); + if (similarity >= SIMILARITY_THRESHOLD) return true; + if (similarity >= LLM_GRAY_ZONE_FLOOR) { + const verdict = await llmSaysDuplicate(candidate, other); + if (verdict === true) return true; + } + } + return false; +} diff --git a/apps/slack-bot/src/listener.ts b/apps/slack-bot/src/listener.ts index ad1a714..e004c68 100644 --- a/apps/slack-bot/src/listener.ts +++ b/apps/slack-bot/src/listener.ts @@ -1,14 +1,74 @@ /** * Bolt.js real-time event listener. * - * Subscribes to message events in Slack channels. - * When a new message arrives in a monitored channel, it parses the message - * and stores the result in the in-memory store. + * Subscribes to message events in Slack channels. When a new message arrives + * in a monitored channel, it parses the message, stores the result in the + * in-memory store, and persists it to Supabase. + * + * When SLACK_REVIEW_CHANNEL is set, parsed events are posted there with + * Approve/Reject buttons instead of being persisted immediately — a + * human-in-the-loop flow matching the Discord bot's review workflow. */ import { App } from '@slack/bolt'; -import { parseSlackMessage } from './parser'; -import { addEvent, isChannelMonitored, getMonitoredChannels } from './store'; +import { parseSlackMessage, UniversifyEvent } from './parser'; +import { addEvent, getEvent, removeEvent, isChannelMonitored, getMonitoredChannels } from './store'; +import { submitSlackEvent, isPersistenceConfigured } from './supabaseSubmit'; + +const REVIEW_CHANNEL = process.env.SLACK_REVIEW_CHANNEL; + +function reviewBlocks(event: UniversifyEvent) { + return [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: + `*Event parsed from #${event.tags.find((t) => t.startsWith('channel:'))?.slice(8) || 'a channel'}*\n` + + `*${event.title}*\n` + + `🕒 ${event.startTime} → ${event.endTime}\n` + + `📍 ${event.location || '—'}`, + }, + }, + { + type: 'actions', + elements: [ + { + type: 'button', + style: 'primary', + text: { type: 'plain_text', text: 'Approve' }, + action_id: 'event_approve', + value: event.id, + }, + { + type: 'button', + style: 'danger', + text: { type: 'plain_text', text: 'Reject' }, + action_id: 'event_reject', + value: event.id, + }, + ], + }, + ]; +} + +/** Persist an event, logging the outcome. */ +async function persistEvent(event: UniversifyEvent): Promise { + const result = await submitSlackEvent(event); + switch (result.status) { + case 'inserted': + console.log(`[Persist] Stored "${event.title}" in Supabase`); + return 'stored'; + case 'duplicate': + console.log(`[Persist] Skipped "${event.title}" — duplicate of an existing event`); + return 'duplicate'; + case 'skipped': + return 'not configured'; + case 'error': + console.error(`[Persist] Failed to store "${event.title}": ${result.error}`); + return 'error'; + } +} /** * Register Slack event listeners on the Bolt app. @@ -81,6 +141,21 @@ export function registerListeners(app: App): void { console.log( `[Listener] New event from #${channelName}: "${event.title}" (${event.startTime})` ); + + if (REVIEW_CHANNEL) { + // Human-in-the-loop: post for approval instead of auto-persisting + try { + await client.chat.postMessage({ + channel: REVIEW_CHANNEL, + text: `Event parsed: ${event.title}`, + blocks: reviewBlocks(event), + }); + } catch (err) { + console.error('[Listener] Failed to post review message:', err); + } + } else if (isPersistenceConfigured()) { + await persistEvent(event); + } } } } catch (error) { @@ -88,5 +163,61 @@ export function registerListeners(app: App): void { } }); + // ── Review-channel button handlers ── + app.action('event_approve', async ({ ack, body, client }) => { + await ack(); + const action = (body as any).actions?.[0]; + const eventId = action?.value as string | undefined; + const event = eventId ? getEvent(eventId) : undefined; + + let outcome = 'not found'; + if (event) outcome = await persistEvent(event); + + const channel = (body as any).channel?.id; + const ts = (body as any).message?.ts; + if (channel && ts) { + const label = + outcome === 'stored' + ? '✅ Approved and stored' + : outcome === 'duplicate' + ? '☑️ Approved — duplicate of an existing event, not re-added' + : outcome === 'not configured' + ? '✅ Approved (Supabase not configured; kept in memory only)' + : `⚠️ Approval failed (${outcome})`; + await client.chat.update({ + channel, + ts, + text: `${label}: ${event?.title ?? eventId}`, + blocks: [], + }); + } + }); + + app.action('event_reject', async ({ ack, body, client }) => { + await ack(); + const action = (body as any).actions?.[0]; + const eventId = action?.value as string | undefined; + const event = eventId ? getEvent(eventId) : undefined; + if (eventId) removeEvent(eventId); + + const channel = (body as any).channel?.id; + const ts = (body as any).message?.ts; + if (channel && ts) { + await client.chat.update({ + channel, + ts, + text: `🗑️ Rejected: ${event?.title ?? eventId}`, + blocks: [], + }); + } + }); + console.log('[Listener] Slack message listeners registered'); + if (REVIEW_CHANNEL) { + console.log(`[Listener] Review flow enabled — approvals go to ${REVIEW_CHANNEL}`); + } else if (isPersistenceConfigured()) { + console.log('[Listener] Auto-persisting parsed events to Supabase'); + } else { + console.log('[Listener] Supabase not configured — events kept in memory only'); + } } diff --git a/apps/slack-bot/src/supabaseSubmit.ts b/apps/slack-bot/src/supabaseSubmit.ts new file mode 100644 index 0000000..16a58e2 --- /dev/null +++ b/apps/slack-bot/src/supabaseSubmit.ts @@ -0,0 +1,120 @@ +/** + * Persist parsed Slack events into the shared Supabase `events` table (the + * same table the Expo client and the Discord bot use). + * + * Uses the service-role key so RLS does not block inserts. When Supabase env + * vars are missing the module degrades gracefully: events stay in the + * in-memory store only, exactly like the pre-persistence behavior. + */ + +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { UniversifyEvent } from './parser'; +import { isDuplicate, TIME_WINDOW_MS } from './dedupe'; + +const SUPABASE_URL = process.env.SUPABASE_URL; +const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; +// A dedicated bot user's auth.users UUID; ownership of Slack-imported events +const SUPABASE_ORGANIZER_ID = process.env.SUPABASE_ORGANIZER_ID; + +let client: SupabaseClient | null = null; + +export function isPersistenceConfigured(): boolean { + return Boolean(SUPABASE_URL && SUPABASE_SERVICE_ROLE_KEY); +} + +function getClient(): SupabaseClient | null { + if (!isPersistenceConfigured()) return null; + if (!client) { + client = createClient(SUPABASE_URL as string, SUPABASE_SERVICE_ROLE_KEY as string); + } + return client; +} + +function eventToRow(event: UniversifyEvent) { + const tags = [...(event.tags || [])]; + if (!tags.includes('slack')) tags.push('slack'); + + return { + id: event.id, + title: event.title, + description: event.description || null, + start_time: event.startTime, + end_time: event.endTime, + location: event.location || null, + categories: event.categories || [], + organizer_id: SUPABASE_ORGANIZER_ID || null, + organizer_name: event.organizer?.name || 'Slack', + organizer_type: event.organizer?.type || 'club', + color: event.color, + rsvp_enabled: event.rsvpEnabled ?? true, + rsvp_counts: event.rsvpCounts || { going: 0, maybe: 0, notGoing: 0 }, + attendees: [], + attendee_visibility: event.attendeeVisibility || 'public', + is_club_event: event.isClubEvent ?? true, + is_social_event: event.isSocialEvent ?? false, + capacity: event.capacity ?? null, + recurring: null, + tags, + image_url: event.imageUrl ?? null, + }; +} + +export type SubmitResult = + | { status: 'inserted' } + | { status: 'duplicate' } + | { status: 'skipped'; reason: string } + | { status: 'error'; error: string }; + +/** + * Insert a parsed Slack event unless an existing event (from any source) + * within ±2h already looks like the same announcement. + */ +export async function submitSlackEvent(event: UniversifyEvent): Promise { + const supabase = getClient(); + if (!supabase) { + return { status: 'skipped', reason: 'Supabase env vars not configured' }; + } + + try { + // Pull potential duplicates: events starting within the dedupe window + const start = Date.parse(event.startTime); + const windowStart = new Date(start - TIME_WINDOW_MS).toISOString(); + const windowEnd = new Date(start + TIME_WINDOW_MS).toISOString(); + + const { data: nearby, error: queryError } = await supabase + .from('events') + .select('id, title, start_time') + .gte('start_time', windowStart) + .lte('start_time', windowEnd); + + if (queryError) { + return { status: 'error', error: queryError.message }; + } + + const candidates = (nearby || []).map((row) => ({ + id: row.id as string, + title: (row.title as string) || '', + startTime: row.start_time as string, + })); + + // Same Slack message re-imported -> treat as already stored + if (candidates.some((c) => c.id === event.id)) { + return { status: 'duplicate' }; + } + + if (await isDuplicate({ id: event.id, title: event.title, startTime: event.startTime }, candidates)) { + return { status: 'duplicate' }; + } + + const { error: insertError } = await supabase.from('events').insert([eventToRow(event)]); + if (insertError) { + // Unique-violation race (same message processed twice) is a duplicate + if (insertError.code === '23505') return { status: 'duplicate' }; + return { status: 'error', error: insertError.message }; + } + + return { status: 'inserted' }; + } catch (err) { + return { status: 'error', error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/package.json b/package.json index d1a84c7..5887540 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,16 @@ "private": true, "version": "1.0.0", "description": "Universify monorepo", - "main": "index.js", "scripts": { - "install": "pnpm install", "build": "pnpm --filter client build", "dev": "pnpm --filter client start", + "dev:slack": "pnpm --filter slack-bot dev", "dev:discord": "pnpm --filter discord-event-bot dev", "start:discord": "pnpm --filter discord-event-bot start", "lint": "pnpm --filter client lint", - "test": "pnpm --filter client test" + "typecheck": "pnpm --filter client typecheck && pnpm --filter slack-bot typecheck", + "test": "pnpm --filter client test:unit", + "test:e2e": "pnpm --filter client test:e2e" }, "keywords": [], "author": "", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb29a59..93cd435 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 + '@types/node-fetch': + specifier: ^2.6.13 + version: 2.6.13 '@types/react': specifier: ~19.1.0 version: 19.1.17 @@ -140,9 +143,6 @@ importers: luxon: specifier: ^3.7.2 version: 3.7.2 - moment-timezone: - specifier: ^0.5.43 - version: 0.5.48 apps/slack-bot: dependencies: @@ -152,6 +152,9 @@ importers: '@slack/web-api': specifier: ^7.8.0 version: 7.15.0 + '@supabase/supabase-js': + specifier: ^2.112.3 + version: 2.112.3 cors: specifier: ^2.8.5 version: 2.8.6 @@ -1570,6 +1573,10 @@ packages: resolution: {integrity: sha512-Kd0Wey+RkFHgyVep7adS6UOE2pN6MJ3mZ32PAXSvfw6IjUkFRC7IQpdZZjUOcUe5pXr1ejufCRgF6lsGINe4Tw==} engines: {node: '>=20.0.0'} + '@supabase/auth-js@2.112.3': + resolution: {integrity: sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==} + engines: {node: '>=22.0.0'} + '@supabase/auth-js@2.84.0': resolution: {integrity: sha512-J6XKbqqg1HQPMfYkAT9BrC8anPpAiifl7qoVLsYhQq5B/dnu/lxab1pabnxtJEsvYG5rwI5HEVEGXMjoQ6Wz2Q==} engines: {node: '>=20.0.0'} @@ -1578,6 +1585,10 @@ packages: resolution: {integrity: sha512-OZWU7YtaG+NNNFZK8p/FuJ6gpq7pFyrG2fLOopP73HAIDHDGpOttPJapvO8ADu3RkqfQfkwrB354vPkSBbZ20A==} engines: {node: '>=20.0.0'} + '@supabase/functions-js@2.112.3': + resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==} + engines: {node: '>=22.0.0'} + '@supabase/functions-js@2.84.0': resolution: {integrity: sha512-2oY5QBV4py/s64zMlhPEz+4RTdlwxzmfhM1k2xftD2v1DruRZKfoe7Yn9DCz1VondxX8evcvpc2udEIGzHI+VA==} engines: {node: '>=20.0.0'} @@ -1585,10 +1596,17 @@ packages: '@supabase/phoenix@0.4.0': resolution: {integrity: sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==} + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} + '@supabase/postgrest-js@2.101.1': resolution: {integrity: sha512-UW1RajH5jbZoK+ldAJ1I6VZ+HWwZ2oaKjEQ6Gn+AQ67CHQVxGl8wNQoLYyumbyaExm41I+wn7arulcY1eHeZJw==} engines: {node: '>=20.0.0'} + '@supabase/postgrest-js@2.112.3': + resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} + engines: {node: '>=22.0.0'} + '@supabase/postgrest-js@2.84.0': resolution: {integrity: sha512-oplc/3jfJeVW4F0J8wqywHkjIZvOVHtqzF0RESijepDAv5Dn/LThlGW1ftysoP4+PXVIrnghAbzPHo88fNomPQ==} engines: {node: '>=20.0.0'} @@ -1597,6 +1615,10 @@ packages: resolution: {integrity: sha512-Oa6dno0OB9I+hv5do5zsZHbFu41ViZnE9IWjmkeeF/8fPmB5fWoHGqeTYEC3/0DAgtpUoFJa4FpvzFH0SBHo1Q==} engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.112.3': + resolution: {integrity: sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==} + engines: {node: '>=22.0.0'} + '@supabase/realtime-js@2.84.0': resolution: {integrity: sha512-ThqjxiCwWiZAroHnYPmnNl6tZk6jxGcG2a7Hp/3kcolPcMj89kWjUTA3cHmhdIWYsP84fHp8MAQjYWMLf7HEUg==} engines: {node: '>=20.0.0'} @@ -1605,6 +1627,10 @@ packages: resolution: {integrity: sha512-WhTaUOBgeEvnKLy95Cdlp6+D5igSF/65yC727w1olxbet5nzUvMlajKUWyzNtQu2efrz2cQ7FcdVBdQqgT9YKQ==} engines: {node: '>=20.0.0'} + '@supabase/storage-js@2.112.3': + resolution: {integrity: sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==} + engines: {node: '>=22.0.0'} + '@supabase/storage-js@2.84.0': resolution: {integrity: sha512-vXvAJ1euCuhryOhC6j60dG8ky+lk0V06ubNo+CbhuoUv+sl39PyY0lc+k+qpQhTk/VcI6SiM0OECLN83+nyJ5A==} engines: {node: '>=20.0.0'} @@ -1613,6 +1639,15 @@ packages: resolution: {integrity: sha512-Jnhm3LfuACwjIzvk2pfUbGQn7pa7hi6MFzfSyPrRYWVCCu69RPLCFyHSBl7HSBwadbQ3UZOznnD3gPca3ePrRA==} engines: {node: '>=20.0.0'} + '@supabase/supabase-js@2.112.3': + resolution: {integrity: sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@supabase/supabase-js@2.84.0': resolution: {integrity: sha512-byMqYBvb91sx2jcZsdp0qLpmd4Dioe80e4OU/UexXftCkpTcgrkoENXHf5dO8FCSai8SgNeq16BKg10QiDI6xg==} engines: {node: '>=20.0.0'} @@ -1692,6 +1727,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} @@ -1789,6 +1827,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1900,6 +1939,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} @@ -3087,11 +3127,12 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-dirs@0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} @@ -3872,12 +3913,6 @@ packages: engines: {node: '>=10'} hasBin: true - moment-timezone@0.5.48: - resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==} - - moment@2.30.1: - resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} - ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -4778,6 +4813,7 @@ packages: tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} @@ -5007,6 +5043,7 @@ packages: uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -6934,6 +6971,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/auth-js@2.112.3': + dependencies: + tslib: 2.8.1 + '@supabase/auth-js@2.84.0': dependencies: tslib: 2.8.1 @@ -6942,16 +6983,26 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/functions-js@2.112.3': + dependencies: + tslib: 2.8.1 + '@supabase/functions-js@2.84.0': dependencies: tslib: 2.8.1 '@supabase/phoenix@0.4.0': {} + '@supabase/phoenix@0.4.5': {} + '@supabase/postgrest-js@2.101.1': dependencies: tslib: 2.8.1 + '@supabase/postgrest-js@2.112.3': + dependencies: + tslib: 2.8.1 + '@supabase/postgrest-js@2.84.0': dependencies: tslib: 2.8.1 @@ -6966,6 +7017,11 @@ snapshots: - bufferutil - utf-8-validate + '@supabase/realtime-js@2.112.3': + dependencies: + '@supabase/phoenix': 0.4.5 + tslib: 2.8.1 + '@supabase/realtime-js@2.84.0': dependencies: '@types/phoenix': 1.6.6 @@ -6981,6 +7037,11 @@ snapshots: iceberg-js: 0.8.1 tslib: 2.8.1 + '@supabase/storage-js@2.112.3': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + '@supabase/storage-js@2.84.0': dependencies: tslib: 2.8.1 @@ -6996,6 +7057,14 @@ snapshots: - bufferutil - utf-8-validate + '@supabase/supabase-js@2.112.3': + dependencies: + '@supabase/auth-js': 2.112.3 + '@supabase/functions-js': 2.112.3 + '@supabase/postgrest-js': 2.112.3 + '@supabase/realtime-js': 2.112.3 + '@supabase/storage-js': 2.112.3 + '@supabase/supabase-js@2.84.0': dependencies: '@supabase/auth-js': 2.84.0 @@ -7098,6 +7167,11 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.19.17 + form-data: 4.0.5 + '@types/node@22.19.17': dependencies: undici-types: 6.21.0 @@ -9836,12 +9910,6 @@ snapshots: mkdirp@1.0.4: {} - moment-timezone@0.5.48: - dependencies: - moment: 2.30.1 - - moment@2.30.1: {} - ms@2.0.0: {} ms@2.1.3: {} diff --git a/supabase/migrations/003_event_rsvps.sql b/supabase/migrations/003_event_rsvps.sql new file mode 100644 index 0000000..c9af657 --- /dev/null +++ b/supabase/migrations/003_event_rsvps.sql @@ -0,0 +1,103 @@ +-- Universify: per-user RSVP storage +-- Run this in Supabase SQL Editor after 001 and 002. +-- +-- Why: RSVPs used to be written by updating events.rsvp_counts/attendees +-- directly from the client. The events UPDATE policy only allows the +-- organizer, so every other user's RSVP silently matched zero rows and was +-- lost on reload. This migration gives each user their own RSVP row (which +-- they are allowed to write) and keeps the denormalized JSONB on events in +-- sync via a SECURITY DEFINER trigger. + +-- ============================================ +-- EVENT RSVPS TABLE +-- ============================================ +CREATE TABLE IF NOT EXISTS event_rsvps ( + event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('going', 'maybe', 'not-going')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (event_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_event_rsvps_user ON event_rsvps(user_id); + +ALTER TABLE event_rsvps ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "RSVPs are viewable by everyone" + ON event_rsvps FOR SELECT USING (true); + +CREATE POLICY "Users can create their own RSVPs" + ON event_rsvps FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own RSVPs" + ON event_rsvps FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own RSVPs" + ON event_rsvps FOR DELETE + USING (auth.uid() = user_id); + +CREATE TRIGGER update_event_rsvps_updated_at + BEFORE UPDATE ON event_rsvps + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- SYNC DENORMALIZED AGGREGATES ON EVENTS +-- ============================================ +-- SECURITY DEFINER so the recount can update the events row regardless of +-- which user triggered it (the events UPDATE policy only allows organizers). +CREATE OR REPLACE FUNCTION sync_event_rsvp_aggregates() +RETURNS TRIGGER +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + target_event_id TEXT; +BEGIN + target_event_id := COALESCE(NEW.event_id, OLD.event_id); + + UPDATE events + SET + rsvp_counts = ( + SELECT jsonb_build_object( + 'going', COUNT(*) FILTER (WHERE status = 'going'), + 'maybe', COUNT(*) FILTER (WHERE status = 'maybe'), + 'notGoing', COUNT(*) FILTER (WHERE status = 'not-going') + ) + FROM event_rsvps + WHERE event_id = target_event_id + ), + attendees = COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'userId', user_id, + 'status', status, + 'timestamp', updated_at + ) + ORDER BY updated_at + ) + FROM event_rsvps + WHERE event_id = target_event_id + ), + '[]'::jsonb + ) + WHERE id = target_event_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS on_event_rsvp_change ON event_rsvps; +CREATE TRIGGER on_event_rsvp_change + AFTER INSERT OR UPDATE OR DELETE ON event_rsvps + FOR EACH ROW + EXECUTE FUNCTION sync_event_rsvp_aggregates(); + +-- No backfill: pre-migration attendees JSONB only ever persisted for event +-- organizers RSVPing to their own events (all other writes were blocked by +-- RLS), so existing data is not a meaningful source of truth.