From 5a26098bac3ade01e3cdbe9da5b6c837444a7c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 07:17:24 +0000 Subject: [PATCH 1/6] Fix broken install/build tooling - Remove the root 'install' lifecycle script that recursively re-invoked pnpm install (fork bomb on every fresh setup) - Replace removed 'expo export:web' with 'expo export --platform web' in build/vercel-build (SDK 54 dropped the webpack command) - Add outputDirectory and frozen lockfile to vercel.json - Guard the Supabase client so a missing .env no longer crashes bundling; export isSupabaseConfigured for offline demo mode - Fix duplicate 'private' key in discord bot package.json; add typecheck scripts and @types/node-fetch Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011nrgpkJbvZ9JCf2VA2kKvJ --- apps/client/.gitignore | 4 + apps/client/app/(tabs)/explore.tsx | 112 ----------- apps/client/app/demo-recs.tsx | 181 ------------------ apps/client/app/modal.tsx | 29 --- .../components/calendar/CalendarWithRecs.tsx | 148 -------------- apps/client/components/external-link.tsx | 25 --- apps/client/components/hello-wave.tsx | 19 -- .../components/parallax-scroll-view.tsx | 79 -------- .../recommendations/RecommendationRail.tsx | 114 ----------- apps/client/components/themed-text.tsx | 60 ------ apps/client/components/themed-view.tsx | 14 -- apps/client/components/ui/collapsible.tsx | 45 ----- apps/client/hooks/use-theme-color.ts | 21 -- apps/client/lib/supabase.ts | 21 +- apps/client/package.json | 7 +- apps/client/vercel.json | 3 +- apps/discord-event-bot/package.json | 5 +- apps/discord-event-bot/src/parser.js | 73 ------- apps/discord-event-bot/src/storage.js | 53 ----- apps/slack-bot/package.json | 2 + package.json | 7 +- pnpm-lock.yaml | 100 ++++++++-- 22 files changed, 119 insertions(+), 1003 deletions(-) delete mode 100644 apps/client/app/(tabs)/explore.tsx delete mode 100644 apps/client/app/demo-recs.tsx delete mode 100644 apps/client/app/modal.tsx delete mode 100644 apps/client/components/calendar/CalendarWithRecs.tsx delete mode 100644 apps/client/components/external-link.tsx delete mode 100644 apps/client/components/hello-wave.tsx delete mode 100644 apps/client/components/parallax-scroll-view.tsx delete mode 100644 apps/client/components/recommendations/RecommendationRail.tsx delete mode 100644 apps/client/components/themed-text.tsx delete mode 100644 apps/client/components/themed-view.tsx delete mode 100644 apps/client/components/ui/collapsible.tsx delete mode 100644 apps/client/hooks/use-theme-color.ts delete mode 100644 apps/discord-event-bot/src/parser.js delete mode 100644 apps/discord-event-bot/src/storage.js 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/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/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/modal.tsx b/apps/client/app/modal.tsx deleted file mode 100644 index 6dfbc1a..0000000 --- a/apps/client/app/modal.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Link } from 'expo-router'; -import { StyleSheet } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; - -export default function ModalScreen() { - return ( - - This is a modal - - Go to home screen - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - link: { - marginTop: 15, - paddingVertical: 15, - }, -}); diff --git a/apps/client/components/calendar/CalendarWithRecs.tsx b/apps/client/components/calendar/CalendarWithRecs.tsx deleted file mode 100644 index a3cbb4a..0000000 --- a/apps/client/components/calendar/CalendarWithRecs.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState } from 'react' -import RecommendationRail from '../recommendations/RecommendationRail' -import type { DbEvent } from '../../hooks/useRecommendations' -import { useRecommendations } from '../../hooks/useRecommendations' - -// Minimal calendar demo that lets you pick a start/end ISO and shows recommendations -export default function CalendarWithRecs({ eventsJson }: { eventsJson: DbEvent[] }) { - const [selStartISO, setSelStartISO] = useState() - const [selEndISO, setSelEndISO] = useState() - - const { suggestions } = useRecommendations({ aStartISO: selStartISO, aEndISO: selEndISO, events: eventsJson, k: 8 }) - - const setNowPlus = (mins: number) => { - const now = new Date() - const start = now - const end = new Date(now.getTime() + mins * 60 * 1000) - setSelStartISO(start.toISOString()) - setSelEndISO(end.toISOString()) - } - - // Find a free slot in eventsJson of at least `minMinutes` starting from `from`. - const findFreeSlot = (minMinutes = 60, from = new Date()) => { - // Build an array of intervals from eventsJson - const intervals = (eventsJson || []) - .map(e => { - const s = new Date(e.start_ts || e.startTime) - const t = new Date(e.end_ts || e.endTime) - return { start: s, end: t } - }) - .filter(i => i.start instanceof Date && !isNaN(i.start.getTime()) && i.end instanceof Date && !isNaN(i.end.getTime())) - .sort((a, b) => a.start.getTime() - b.start.getTime()) - - // merge overlapping intervals - const merged = [] as { start: Date; end: Date }[] - for (const it of intervals) { - if (!merged.length) { merged.push({ ...it }); continue } - const last = merged[merged.length - 1] - if (it.start <= last.end) { - // overlap - if (it.end > last.end) last.end = it.end - } else { - merged.push({ ...it }) - } - } - - const minMs = minMinutes * 60 * 1000 - // consider gap before first event - if (!merged.length) { - const s = new Date(from) - const e = new Date(s.getTime() + minMs) - return { startISO: s.toISOString(), endISO: e.toISOString() } - } - - // search between `from` and merged intervals - let cursor = new Date(from) - for (const it of merged) { - // if event ends before cursor, skip - if (it.end.getTime() <= cursor.getTime()) continue - // if there's a gap between cursor and next event start - if (it.start.getTime() - cursor.getTime() >= minMs) { - const s = new Date(cursor) - const e = new Date(s.getTime() + minMs) - return { startISO: s.toISOString(), endISO: e.toISOString() } - } - // move cursor forward to the end of this event - cursor = new Date(Math.max(cursor.getTime(), it.end.getTime())) - } - - // no gap found between events; schedule after last event - const last = merged[merged.length - 1] - const s = new Date(Math.max(last.end.getTime(), from.getTime())) - const e = new Date(s.getTime() + minMs) - return { startISO: s.toISOString(), endISO: e.toISOString() } - } - - return ( -
-
-

Calendar demo

-

Use the controls to set a selected range for recommendations.

- -
- - { - const v = e.target.value - setSelStartISO(v ? new Date(v).toISOString() : undefined) - }} - /> - - - { - const v = e.target.value - setSelEndISO(v ? new Date(v).toISOString() : undefined) - }} - /> -
- -
- - - - - -
- -
-

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/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/parallax-scroll-view.tsx b/apps/client/components/parallax-scroll-view.tsx deleted file mode 100644 index 6f674a7..0000000 --- a/apps/client/components/parallax-scroll-view.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { - interpolate, - useAnimatedRef, - useAnimatedStyle, - useScrollOffset, -} from 'react-native-reanimated'; - -import { ThemedView } from '@/components/themed-view'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useThemeColor } from '@/hooks/use-theme-color'; - -const HEADER_HEIGHT = 250; - -type Props = PropsWithChildren<{ - headerImage: ReactElement; - headerBackgroundColor: { dark: string; light: string }; -}>; - -export default function ParallaxScrollView({ - children, - headerImage, - headerBackgroundColor, -}: Props) { - const backgroundColor = useThemeColor({}, 'background'); - const colorScheme = useColorScheme() ?? 'light'; - const scrollRef = useAnimatedRef(); - const scrollOffset = useScrollOffset(scrollRef); - const headerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] - ), - }, - { - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), - }, - ], - }; - }); - - return ( - - - {headerImage} - - {children} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: HEADER_HEIGHT, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}); diff --git a/apps/client/components/recommendations/RecommendationRail.tsx b/apps/client/components/recommendations/RecommendationRail.tsx deleted file mode 100644 index a24a90d..0000000 --- a/apps/client/components/recommendations/RecommendationRail.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// apps/client/components/recommendations/RecommendationRail.tsx -import { useMemo, useState } from 'react' -import type { DbEvent, Suggestion } from '../../hooks/useRecommendations' -import { useRecommendations } from '../../hooks/useRecommendations' -import useUserInterests from '../../hooks/useUserInterests' - -export type RecommendationRailProps = { - events: DbEvent[] // your JSON input - aStartISO?: string // selected start (ISO) - aEndISO?: string // selected end (ISO) - initialChips?: string[] - defaultActiveChips?: string[] - defaultK?: number - onPick?: (event: Suggestion) => void - style?: React.CSSProperties -} - -export default function RecommendationRail({ - events, - aStartISO, - aEndISO, - initialChips = ['Career','Food','Fun','Afternoon','Events'], - defaultActiveChips = [], - defaultK = 5, - onPick, - style -}: RecommendationRailProps) { - const [activeChips, setActiveChips] = useState(defaultActiveChips) - const [search, setSearch] = useState('') - const [k, setK] = useState(defaultK) - const interests = useUserInterests({ events, startISO: aStartISO, endISO: aEndISO }) - const queryTerms = useMemo(() => { - const sTerms = search.split(/\s+/).map(s => s.trim()).filter(Boolean) - // if user hasn't entered chips or search terms, auto-populate with top tags from interests for the selected window - if (!sTerms.length && activeChips.length === 0 && aStartISO && aEndISO) { - const auto = (interests?.tags || []).slice(0, 5).map(t => t.label) - return [...activeChips, ...sTerms, ...auto] - } - return [...activeChips, ...sTerms] - }, [activeChips, search, interests, aStartISO, aEndISO]) - - const { suggestions } = useRecommendations({ aStartISO, aEndISO, queryTerms, k, events }) - - // suggestions are provided by useRecommendations - - const toggleChip = (label: string) => { - setActiveChips(prev => - prev.includes(label) ? prev.filter(x => x !== label) : [...prev, label] - ) - } - - return ( - - ) -} 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/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/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/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/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/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/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/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/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: {} From 05ac45714fde5fcf13af9c59d573cd6594b41a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 07:17:40 +0000 Subject: [PATCH 2/6] Fix RSVP persistence with per-user event_rsvps table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RSVPs were written by updating events.rsvp_counts/attendees directly, but the events UPDATE policy only allows the organizer β€” every other user's RSVP silently matched zero rows and vanished on reload. - Migration 003: event_rsvps table (own-row RLS) with a SECURITY DEFINER trigger that keeps the denormalized aggregates on events in sync - Client: setRSVPAPI/fetchEventAPI; updateRSVP now writes the user's own row, refreshes authoritative aggregates, reverts on failure, and keeps local-only state in offline demo mode - addExternalEvents drops cross-source duplicates (title similarity + 2h window) via new utils/dedupe.ts Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011nrgpkJbvZ9JCf2VA2kKvJ --- apps/client/contexts/EventsContext.tsx | 42 ++++++++-- apps/client/lib/api.ts | 68 +++++++++++++++- apps/client/utils/dedupe.ts | 75 +++++++++++++++++ supabase/migrations/003_event_rsvps.sql | 103 ++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 apps/client/utils/dedupe.ts create mode 100644 supabase/migrations/003_event_rsvps.sql 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/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/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/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. From 52e573fea61c59efb2f818859cbbb8537dda1e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 07:18:09 +0000 Subject: [PATCH 3/6] Add pure engine layer: recommendations, recurrence, reminders, filters - Extract the recommendation engine into utils/recommendationEngine.ts (React-free, unit-testable); hooks re-export and wrap it - Add rankEventsForUser (interest profile + explicit categories + popularity ranking) and fix title normalization to be per-token so 'Late Night Chess' matches the mined 'chess' interest - Add utils/recurringEvents.ts: expand daily/weekly/monthly patterns into display occurrences with range clipping and month-length clamping - Add useEventReminders: browser notifications ~30min before scheduled events (web, opt-in preference) - FilterContext now exposes all six filter axes (date range, location, time of day, availability were implemented but unreachable) - 21 unit tests over dedupe, recurrence, layout, and the engine Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011nrgpkJbvZ9JCf2VA2kKvJ --- apps/client/contexts/FilterContext.tsx | 28 +- apps/client/hooks/useAuth.ts | 3 +- apps/client/hooks/useEventReminders.ts | 98 +++++ apps/client/hooks/useRecommendations.ts | 352 +-------------- apps/client/tests/dedupe.test.ts | 52 +++ apps/client/tests/eventLayout.test.ts | 66 +++ .../client/tests/recommendationEngine.test.ts | 103 +++++ apps/client/tests/recurringEvents.test.ts | 129 ++++++ apps/client/types/event.ts | 1 + apps/client/types/settings.ts | 10 - apps/client/utils/recommendationEngine.ts | 415 ++++++++++++++++++ apps/client/utils/recurringEvents.ts | 128 ++++++ 12 files changed, 1040 insertions(+), 345 deletions(-) create mode 100644 apps/client/hooks/useEventReminders.ts create mode 100644 apps/client/tests/dedupe.test.ts create mode 100644 apps/client/tests/eventLayout.test.ts create mode 100644 apps/client/tests/recommendationEngine.test.ts create mode 100644 apps/client/tests/recurringEvents.test.ts create mode 100644 apps/client/utils/recommendationEngine.ts create mode 100644 apps/client/utils/recurringEvents.ts 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/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/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/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; +} From 31ec3a420dec74ec8d85997d272a7d38e47cab42 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 07:18:21 +0000 Subject: [PATCH 4/6] Client UI: theming system, ranked feeds, event detail, recurring, images - Real theming via useAppTheme + semantic palettes (light/dark/system, high-contrast variants, font-size scaling, reduced-motion) applied across every screen and component; removes ~70 hardcoded chrome colors - Home tab now shows engine-ranked recommendations (was random 20); calendar drag-selection shows top-5 suggestions for the time window - New /event/[id] detail page with RSVP; fixes dead My Activity links - FilterDrawer implements the Date Range / Time of Day / Location / Availability sections that previously said 'Coming soon' - Create form: recurring pattern (frequency/interval/end date) and flyer image URL; calendar renders recurring occurrences; cards and detail views show images and recurrence - Persist the Universify->Google Calendar event map so unschedule can delete the Google copy after a reload - Remove remaining committed debug telemetry from GoogleAuthContext and the OAuth callback; drop the misleading password-change UI (auth is Google-only); zero tsc errors and zero lint warnings Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011nrgpkJbvZ9JCf2VA2kKvJ --- apps/client/app/(auth)/callback.tsx | 57 +- apps/client/app/(auth)/login.tsx | 222 ++-- apps/client/app/(auth)/signup.tsx | 1 - apps/client/app/(tabs)/_layout.tsx | 20 +- apps/client/app/(tabs)/calendar.tsx | 499 ++++----- apps/client/app/(tabs)/create.tsx | 43 +- apps/client/app/(tabs)/find.tsx | 333 +++--- apps/client/app/(tabs)/index.tsx | 92 +- apps/client/app/(tabs)/profile.tsx | 986 +++++++++--------- apps/client/app/_layout.tsx | 1 - apps/client/app/event/[id].tsx | 364 +++++++ apps/client/app/index.tsx | 129 ++- apps/client/app/settings/account.tsx | 186 ++-- apps/client/app/settings/appearance.tsx | 267 ++--- apps/client/app/settings/preferences.tsx | 541 +++++----- .../components/calendar/CalendarHeader.tsx | 91 +- .../components/calendar/EventDisplayCard.tsx | 282 ++--- .../client/components/calendar/TimeColumn.tsx | 50 +- apps/client/components/calendar/WeekView.tsx | 253 ++--- .../components/events/CreateEventForm.tsx | 402 ++++--- apps/client/components/events/EventCard.tsx | 226 ++-- .../components/events/EventDetailSidebar.tsx | 299 +++--- apps/client/components/layout/DesktopNav.tsx | 320 +++--- .../client/components/layout/FilterDrawer.tsx | 415 +++++--- apps/client/components/layout/Header.tsx | 216 ++-- .../components/layout/ResizableSidebar.tsx | 84 +- .../components/layout/ResponsiveLayout.tsx | 18 +- .../recommendations/RecommendationCard.tsx | 125 +-- .../recommendations/RecommendationsList.tsx | 119 ++- apps/client/components/ui/AnimatedDrawer.tsx | 70 +- apps/client/components/ui/Button.tsx | 155 +-- apps/client/components/ui/CategoryPill.tsx | 81 +- apps/client/components/ui/DateTimePicker.tsx | 313 +++--- apps/client/components/ui/Input.tsx | 123 +-- apps/client/components/ui/Modal.tsx | 138 +-- apps/client/components/ui/SearchBar.tsx | 194 ++-- apps/client/constants/theme.ts | 111 ++ apps/client/contexts/GoogleAuthContext.tsx | 8 - apps/client/hooks/useAppTheme.ts | 40 + 39 files changed, 4436 insertions(+), 3438 deletions(-) create mode 100644 apps/client/app/event/[id].tsx create mode 100644 apps/client/hooks/useAppTheme.ts 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)/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/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. + +