diff --git a/apps/client/DATABASE_SETUP.md b/apps/client/DATABASE_SETUP.md index fb89176..0c15bbf 100644 --- a/apps/client/DATABASE_SETUP.md +++ b/apps/client/DATABASE_SETUP.md @@ -80,6 +80,16 @@ The app currently uses **localStorage** (web) for data persistence. Events are l EXPO_PUBLIC_API_URL=https://your-api-url.com ``` +## Migrations + +Run these in the Supabase SQL editor, in order: + +1. `supabase/migrations/001_initial_schema.sql` — events, profiles, RLS +2. `supabase/migrations/002_handle_new_user.sql` — profile row on signup +3. `supabase/migrations/003_event_rsvps.sql` — per-user RSVPs + aggregate trigger +4. `supabase/migrations/004_event_messages.sql` — event chat and host + announcements, readable and writable only by people going (or the host) + ## Testing Without Database With no Supabase credentials the app runs in offline demo mode against diff --git a/apps/client/README.md b/apps/client/README.md index 77dbef7..0c2b548 100644 --- a/apps/client/README.md +++ b/apps/client/README.md @@ -37,6 +37,16 @@ A comprehensive cross-platform event aggregation and discovery application built - Capacity limits - Real-time validation +- **My Events & ratings** + - Every event you RSVP'd to, pinned, or host, in one timeline + - Opens on past events so you can rate them 1–5 stars with an optional note + - "Not yet rated" filter, plus search across past events + +- **Event chat & announcements** + - Per-event thread for the people going, gated to attendees and the host + - Hosts can post announcements, which pin above the conversation + - Backed by Supabase (`event_messages` + RLS) with a device-local fallback + - **Recommendations Feed** - Personalized based on user interests - Random selection from upcoming events @@ -180,24 +190,45 @@ bundled mock events and keeps all changes in local state. ## 🎨 Design System +Tokens live in `constants/design.ts` and reach components through +`useAppTheme()`. Screens compose from the scale rather than inventing values — +that consistency is what makes unrelated screens read as one product. + ### Colors -- **Primary**: `#FF6B6B` (Coral Red) -- **Secondary**: `#8B7FFF` (Purple) -- **Accent**: `#FF6BA8` (Pink) -- **Background**: `#F8F9FA` (Light Gray) -- **Text**: `#1F2937` (Dark Gray) +Semantic roles, not raw hex: `background`, `surface`, `surfaceAlt`, `border`, +`textPrimary/Secondary/Tertiary`, `primary`, `onPrimary`, plus status colours. +Every role is defined for light, dark, and both high-contrast variants in +`constants/theme.ts`. Emphasis comes from the three text roles, so no screen +needs a bespoke grey. ### Typography -- **Headers**: Bold, 24-32px -- **Body**: Regular, 14-16px -- **Small**: Regular, 12-14px - -### Spacing - -- Base unit: 8px -- Small: 8px, Medium: 16px, Large: 24px, XLarge: 32px +A fixed scale modelled on Apple's HIG text styles, each step carrying its own +weight, line height and tracking, multiplied by the user's font-size setting: + +| Token | Size / line height | Weight | Used for | +| --- | --- | --- | --- | +| `display` | 34 / 40 | 800 | Landing hero | +| `title1` | 28 / 34 | 800 | Screen titles | +| `title2` | 22 / 28 | 700 | Section titles | +| `title3` | 20 / 26 | 700 | Card titles, empty states | +| `headline` | 17 / 23 | 700 | List item titles | +| `body` | 16 / 24 | 400 | Long-form text | +| `callout` | 15 / 21 | 400 | Supporting copy | +| `subhead` | 14 / 20 | 600 | Labels, buttons | +| `footnote` | 13 / 18 | 400 | Metadata | +| `caption` | 12 / 16 | 600 | Counts, timestamps | +| `overline` | 11 / 14 | 700, uppercase | Eyebrows, chips | + +### Spacing, radius, elevation + +- 8pt grid with 4pt half-steps: `xs 4, sm 8, md 12, lg 16, xl 24, xxl 32, xxxl 48` +- Radii: `sm 8, md 12, lg 16, xl 24, pill` +- Elevation: a three-step shadow ramp in light mode; dark mode returns flat + styles and separates surfaces with stepped backgrounds and hairlines, because + shadows read as dirt on dark backgrounds +- Minimum tap target: 44pt ## 📱 Responsive Breakpoints diff --git a/apps/client/app/(tabs)/_layout.tsx b/apps/client/app/(tabs)/_layout.tsx index 8ccbc3e..8498d79 100644 --- a/apps/client/app/(tabs)/_layout.tsx +++ b/apps/client/app/(tabs)/_layout.tsx @@ -5,7 +5,9 @@ import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { useAppTheme } from '@/hooks/useAppTheme'; import { useAuth } from '@/contexts/AuthContext'; -import { ActivityIndicator, View } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Spacing } from '@/constants/design'; import { useResponsive } from '@/hooks/useResponsive'; import { DesktopNav } from '@/components/layout/DesktopNav'; @@ -13,6 +15,7 @@ export default function TabLayout() { const { colors } = useAppTheme(); const { isAuthenticated, isLoading } = useAuth(); const { isDesktop } = useResponsive(); + const insets = useSafeAreaInsets(); useEffect(() => { if (!isLoading && !isAuthenticated) { @@ -41,41 +44,57 @@ export default function TabLayout() { tabBarInactiveTintColor: colors.textTertiary, headerShown: false, tabBarButton: HapticTab, - tabBarStyle: isDesktop ? { display: 'none' } : { backgroundColor: colors.surface }, + tabBarStyle: isDesktop + ? { display: 'none' } + : { + backgroundColor: colors.surface, + borderTopColor: colors.border, + borderTopWidth: StyleSheet.hairlineWidth, + height: 64 + insets.bottom, + paddingTop: Spacing.sm, + paddingHorizontal: Spacing.sm, + paddingBottom: Math.max(insets.bottom, Spacing.sm), + }, + tabBarLabelStyle: { + fontSize: 11, + fontWeight: '600', + letterSpacing: 0.1, + }, + tabBarItemStyle: { paddingVertical: 0 }, }}> , + tabBarIcon: ({ color }) => , }} /> , + tabBarIcon: ({ color }) => , }} /> , + tabBarIcon: ({ color }) => , }} /> , + tabBarIcon: ({ color }) => , }} /> , + tabBarIcon: ({ color }) => , }} /> diff --git a/apps/client/app/(tabs)/find.tsx b/apps/client/app/(tabs)/find.tsx index 72ba97e..3778c55 100644 --- a/apps/client/app/(tabs)/find.tsx +++ b/apps/client/app/(tabs)/find.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { View, StyleSheet, FlatList, TouchableOpacity, Text } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; import { useEvents } from '@/contexts/EventsContext'; @@ -7,6 +7,7 @@ import { useSettings } from '@/contexts/SettingsContext'; import { FilterProvider, useFilters } from '@/contexts/FilterContext'; import { SearchBar } from '@/components/ui/SearchBar'; import { CategoryPill } from '@/components/ui/CategoryPill'; +import { Ionicons } from '@expo/vector-icons'; import { EventCard } from '@/components/events/EventCard'; import { EventDetailSidebar } from '@/components/events/EventDetailSidebar'; import { FilterDrawer } from '@/components/layout/FilterDrawer'; @@ -49,10 +50,24 @@ function FindScreenContent() { }, [params.filterMyEvents]); // Filter for my events - const displayEvents = showMyEventsOnly + const visibleEvents = showMyEventsOnly ? filteredEvents.filter((event) => event.organizer.id === currentUser?.id) : filteredEvents; + // Browsing leads with what you can still go to; events that already happened + // stay findable, just after the upcoming ones. + const displayEvents = useMemo(() => { + const now = Date.now(); + const upcoming: Event[] = []; + const past: Event[] = []; + for (const event of visibleEvents) { + (new Date(event.endTime).getTime() >= now ? upcoming : past).push(event); + } + const byStart = (a: Event, b: Event) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(); + return [...upcoming.sort(byStart), ...past.sort((a, b) => byStart(b, a))]; + }, [visibleEvents]); + const numColumns = isMobile ? 1 : viewMode === 'grid' ? 3 : 1; return ( @@ -75,13 +90,13 @@ function FindScreenContent() { style={[styles.viewButton, viewMode === 'grid' && styles.viewButtonActive]} onPress={() => setViewMode('grid')} > - + setViewMode('list')} > - + )} @@ -93,7 +108,7 @@ function FindScreenContent() { style={styles.filterButton} onPress={() => setShowFilters(true)} > - + Filters {activeFilterCount > 0 && ( @@ -152,7 +167,9 @@ function FindScreenContent() { {/* Events List */} {displayEvents.length === 0 ? ( - 🔍 + + + No events found Try adjusting your filters or search query @@ -338,8 +355,13 @@ const createStyles = (colors: AppPalette, fontScale: number) => alignItems: 'center', padding: 32, }, - emptyIcon: { - fontSize: 64, + emptyIconWrap: { + width: 56, + height: 56, + borderRadius: 16, + backgroundColor: colors.surfaceAlt, + alignItems: 'center', + justifyContent: 'center', marginBottom: 16, }, emptyTitle: { diff --git a/apps/client/app/_layout.tsx b/apps/client/app/_layout.tsx index e1432ab..bb461c4 100644 --- a/apps/client/app/_layout.tsx +++ b/apps/client/app/_layout.tsx @@ -9,6 +9,7 @@ import { DevModeProvider } from '@/contexts/DevModeContext'; import { GoogleAuthProvider } from '@/contexts/GoogleAuthContext'; import { GoogleCalendarProvider } from '@/contexts/GoogleCalendarContext'; import { EventsProvider } from '@/contexts/EventsContext'; +import { RatingsProvider } from '@/contexts/RatingsContext'; import { SettingsProvider, useSettings } from '@/contexts/SettingsContext'; import { SlackProvider } from '@/contexts/SlackContext'; @@ -48,9 +49,11 @@ export default function RootLayout() { - - - + + + + + diff --git a/apps/client/app/event/[id].tsx b/apps/client/app/event/[id].tsx index 7d622e2..77f0dfb 100644 --- a/apps/client/app/event/[id].tsx +++ b/apps/client/app/event/[id].tsx @@ -20,6 +20,10 @@ import { Button } from '@/components/ui/Button'; import { Event, RSVPStatus } from '@/types/event'; import { fetchEventAPI } from '@/lib/api'; import { formatDate, formatFullDate, formatTimeRange } from '@/utils/dateHelpers'; +import { getAvailableSpots } from '@/utils/eventHelpers'; +import { EventThread } from '@/components/events/EventThread'; +import { RateEventRow } from '@/components/events/RateEventRow'; +import { useRatings } from '@/contexts/RatingsContext'; import { googleCalendarUrl, downloadIcs, shareEvent } from '@/utils/calendarLinks'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; @@ -67,6 +71,7 @@ function InfoRow({ export default function EventDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const { getEventById, updateRSVP, getRSVPStatus } = useEvents(); + const { ratingFor, rateEvent } = useRatings(); const { currentUser } = useAuth(); const { colors, fontScale, reduceMotion } = useAppTheme(); const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); @@ -203,9 +208,8 @@ export default function EventDetailScreen() { }; const recurrence = recurrenceLabel(event); - const spotsLeft = event.capacity - ? Math.max(event.capacity - (event.rsvpCounts.going + event.rsvpCounts.maybe), 0) - : null; + const spotsLeft = getAvailableSpots(event); + const hasEnded = new Date(event.endTime).getTime() < Date.now(); const barAnimStyle = { opacity: barAnim, @@ -281,9 +285,11 @@ export default function EventDetailScreen() { icon="people-outline" primary={`${event.rsvpCounts.going} going · ${event.rsvpCounts.maybe} maybe`} secondary={ - spotsLeft !== null - ? `${spotsLeft} of ${event.capacity} spots left` - : undefined + spotsLeft === null + ? undefined + : spotsLeft === 0 + ? `Full · ${event.capacity} spots` + : `${spotsLeft} of ${event.capacity} spots left` } styles={styles} colors={colors} @@ -345,6 +351,24 @@ export default function EventDetailScreen() { )} + + {/* Rate it, once it's over */} + {hasEnded && currentUser ? ( + + Rate this event + + rateEvent(event.id, stars, note)} + onClear={() => rateEvent(event.id, null)} + /> + + + ) : null} + + {/* Attendee chat + host announcements */} + @@ -626,6 +650,15 @@ const createStyles = (colors: AppPalette, fontScale: number) => marginTop: 22, marginBottom: 8, }, + rateSection: { + marginBottom: 4, + }, + rateCard: { + backgroundColor: colors.surfaceAlt, + borderRadius: 12, + padding: 16, + marginBottom: 8, + }, description: { fontSize: 15 * fontScale, lineHeight: 22, diff --git a/apps/client/app/my-events.tsx b/apps/client/app/my-events.tsx index d9d437a..eb830bb 100644 --- a/apps/client/app/my-events.tsx +++ b/apps/client/app/my-events.tsx @@ -5,48 +5,72 @@ import { StyleSheet, TouchableOpacity, TextInput, + Pressable, } from 'react-native'; import { router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; +import { ContentWidth, Radii, Spacing, TouchTarget, Typography } from '@/constants/design'; import { useAuth } from '@/contexts/AuthContext'; +import { useRatings } from '@/contexts/RatingsContext'; import { useMyEvents } from '@/hooks/useMyEvents'; import { AgendaList } from '@/components/events/AgendaList'; import { SegmentedControl } from '@/components/ui/SegmentedControl'; +import { RateEventRow } from '@/components/events/RateEventRow'; +import { Event } from '@/types/event'; /** - * My Events — the Luma pattern: every event you have a relationship with - * (RSVP'd going/maybe, pinned to your calendar, or hosting) in one - * date-grouped timeline, split into Upcoming and Past. + * My Events — every event you have a relationship with, in one timeline. + * + * Opens on what already happened, because that is the part with something to + * do: rate it. Upcoming events are one tap away behind the same control, and + * they're never mixed into the list you are rating. */ -type MyEventsTab = 'upcoming' | 'past'; +type MyEventsTab = 'past' | 'upcoming'; export default function MyEventsScreen() { - const { colors, fontScale } = useAppTheme(); - const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const { colors, type } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, type), [colors, type]); const { currentUser } = useAuth(); // Same "my events" set the calendar and agenda read from const { myEvents, relationFor } = useMyEvents(); - const [tab, setTab] = useState('upcoming'); + const { ratingFor, rateEvent, ratedCount } = useRatings(); + const [tab, setTab] = useState('past'); + const [onlyUnrated, setOnlyUnrated] = useState(false); const [query, setQuery] = useState(''); - const visibleEvents = useMemo(() => { + const isPast = tab === 'past'; + + const { past, upcoming } = useMemo(() => { const now = Date.now(); - const inTab = myEvents.filter((event) => { - const end = new Date(event.endTime).getTime(); - return tab === 'upcoming' ? end >= now : end < now; - }); - if (tab !== 'past') return inTab; + const split: { past: Event[]; upcoming: Event[] } = { past: [], upcoming: [] }; + for (const event of myEvents) { + const ended = new Date(event.endTime).getTime() < now; + (ended ? split.past : split.upcoming).push(event); + } + return split; + }, [myEvents]); + + const unratedCount = useMemo( + () => past.filter((event) => !ratingFor(event.id)).length, + [past, ratingFor] + ); + + const visibleEvents = useMemo(() => { + const inTab = isPast ? past : upcoming; + const filtered = isPast && onlyUnrated + ? inTab.filter((event) => !ratingFor(event.id)) + : inTab; const q = query.trim().toLowerCase(); - if (!q) return inTab; - return inTab.filter( + if (!isPast || !q) return filtered; + return filtered.filter( (event) => event.title.toLowerCase().includes(q) || event.location.toLowerCase().includes(q) ); - }, [myEvents, tab, query]); + }, [isPast, past, upcoming, onlyUnrated, query, ratingFor]); const topBar = ( @@ -70,8 +94,8 @@ export default function MyEventsScreen() { Sign in to see your events - Your RSVPs, pinned events, and everything you host live here once - you're signed in. + Your RSVPs, pinned events, ratings and everything you host live here + once you're signed in. 0; + const searchActive = isPast && query.trim().length > 0; return ( @@ -93,26 +117,48 @@ export default function MyEventsScreen() { My Events - Everything you're going to, hosting, or saved + {ratedCount > 0 + ? `You've rated ${ratedCount} ${ratedCount === 1 ? 'event' : 'events'}` + : 'Look back on where you’ve been, and rate it'} options={[ - { value: 'upcoming', label: 'Upcoming' }, { value: 'past', label: 'Past' }, + { value: 'upcoming', label: `Upcoming${upcoming.length ? ` (${upcoming.length})` : ''}` }, ]} value={tab} onChange={setTab} /> - {tab === 'past' && ( + {isPast && ( + + setOnlyUnrated((prev) => !prev)} + style={[styles.filterChip, onlyUnrated && styles.filterChipActive]} + accessibilityRole="switch" + accessibilityState={{ checked: onlyUnrated }} + > + + + Not yet rated{unratedCount ? ` (${unratedCount})` : ''} + + + + )} + + {isPast && ( router.push(`/event/${event.id}`)} badgeFor={relationFor} - descending={tab === 'past'} + descending={isPast} + renderFooter={ + isPast + ? (event) => ( + rateEvent(event.id, stars, note)} + onClear={() => rateEvent(event.id, null)} + /> + ) + : undefined + } emptyTitle={ - tab === 'upcoming' - ? 'No upcoming events' - : searchActive + isPast + ? searchActive ? `Nothing matched “${query.trim()}”` - : 'No past events yet' + : onlyUnrated + ? 'Everything is rated' + : 'No past events yet' + : 'No upcoming events' } emptyBody={ - tab === 'upcoming' - ? "RSVP to something or pin it to your calendar and it'll live here." - : searchActive + isPast + ? searchActive ? 'Try a different word — titles and locations are searchable.' - : 'Once events you join wrap up, they move here.' + : onlyUnrated + ? 'You have rated every event you went to. Nice.' + : 'Once events you join wrap up, they move here to rate.' + : "RSVP to something or pin it to your calendar and it'll live here." } emptyAction={ - tab === 'upcoming' - ? { label: 'Find events', onPress: () => router.push('/(tabs)/find') } - : undefined + isPast && !searchActive && onlyUnrated + ? { label: 'Show all past events', onPress: () => setOnlyUnrated(false) } + : !isPast + ? { label: 'Find events', onPress: () => router.push('/(tabs)/find') } + : undefined } /> @@ -157,7 +221,7 @@ export default function MyEventsScreen() { ); } -const createStyles = (colors: AppPalette, fontScale: number) => +const createStyles = (colors: AppPalette, type: Typography) => StyleSheet.create({ container: { flex: 1, @@ -167,69 +231,92 @@ const createStyles = (colors: AppPalette, fontScale: number) => flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - maxWidth: 960, + paddingHorizontal: Spacing.xl, + paddingVertical: Spacing.lg, + maxWidth: ContentWidth.wide, width: '100%', alignSelf: 'center', }, backButton: { - width: 36, - height: 36, - borderRadius: 10, + width: TouchTarget, + height: TouchTarget, + borderRadius: Radii.md, justifyContent: 'center', alignItems: 'center', }, wordmark: { - fontSize: 18 * fontScale, - fontWeight: '800', - letterSpacing: -0.5, + ...type.headline, color: colors.textPrimary, }, wordmarkAccent: { color: colors.primary, }, header: { - paddingHorizontal: 20, - paddingTop: 8, - paddingBottom: 16, - gap: 14, - maxWidth: 720, + paddingHorizontal: Spacing.xl, + paddingTop: Spacing.sm, + paddingBottom: Spacing.lg, + gap: Spacing.md, + maxWidth: ContentWidth.regular, width: '100%', alignSelf: 'center', }, title: { - fontSize: 28 * fontScale, - lineHeight: 33 * fontScale, - fontWeight: '800', - letterSpacing: -0.8, + ...type.title1, color: colors.textPrimary, }, subtitle: { - fontSize: 14 * fontScale, - lineHeight: 20 * fontScale, + ...type.callout, color: colors.textSecondary, - marginTop: -10, + marginTop: -Spacing.sm, + marginBottom: Spacing.xs, + }, + filterRow: { + flexDirection: 'row', + gap: Spacing.sm, + }, + filterChip: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.sm, + paddingHorizontal: Spacing.md, + paddingVertical: Spacing.sm, + borderRadius: Radii.pill, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + minHeight: TouchTarget - 8, + }, + filterChipActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + filterChipText: { + ...type.footnote, + fontWeight: '600', + color: colors.textSecondary, + }, + filterChipTextActive: { + color: colors.onPrimary, }, searchWrap: { flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: Spacing.sm, backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 8, + borderRadius: Radii.md, + paddingHorizontal: Spacing.md, + minHeight: TouchTarget, }, searchInput: { flex: 1, - fontSize: 14 * fontScale, + ...type.callout, color: colors.textPrimary, }, listWrap: { flex: 1, - maxWidth: 720, + maxWidth: ContentWidth.regular, width: '100%', alignSelf: 'center', }, @@ -237,40 +324,38 @@ const createStyles = (colors: AppPalette, fontScale: number) => flex: 1, alignItems: 'center', justifyContent: 'center', - padding: 32, + padding: Spacing.xxl, }, signedOutIconWrap: { - width: 52, - height: 52, - borderRadius: 16, + width: 56, + height: 56, + borderRadius: Radii.lg, backgroundColor: colors.surfaceAlt, alignItems: 'center', justifyContent: 'center', - marginBottom: 14, + marginBottom: Spacing.lg, }, signedOutTitle: { - fontSize: 17 * fontScale, - fontWeight: '700', + ...type.title3, color: colors.textPrimary, - marginBottom: 6, + marginBottom: Spacing.sm, }, signedOutBody: { - fontSize: 14 * fontScale, - lineHeight: 20 * fontScale, + ...type.callout, color: colors.textSecondary, textAlign: 'center', - maxWidth: 320, - marginBottom: 18, + maxWidth: 340, + marginBottom: Spacing.xl, }, signInButton: { backgroundColor: colors.primary, - borderRadius: 10, - paddingHorizontal: 18, - paddingVertical: 10, + borderRadius: Radii.md, + paddingHorizontal: Spacing.xl, + minHeight: TouchTarget, + justifyContent: 'center', }, signInButtonText: { - fontSize: 14 * fontScale, - fontWeight: '600', + ...type.subhead, color: colors.onPrimary, }, }); diff --git a/apps/client/components/events/AgendaList.tsx b/apps/client/components/events/AgendaList.tsx index 43d3884..3383537 100644 --- a/apps/client/components/events/AgendaList.tsx +++ b/apps/client/components/events/AgendaList.tsx @@ -12,6 +12,13 @@ import { Event } from '@/types/event'; import { formatTimeRange } from '@/utils/dateHelpers'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; +import { + Elevation, + Radii, + Spacing, + TouchTarget, + Typography, +} from '@/constants/design'; import { MyEventRelation } from '@/utils/myEvents'; /** @@ -35,6 +42,8 @@ interface AgendaListProps { emptyAction?: { label: string; onPress: () => void }; /** Sort descending (for "Past" lists) */ descending?: boolean; + /** Extra row rendered inside each card — used for rating past events */ + renderFooter?: (event: Event) => React.ReactNode; } function dayKey(date: Date): string { @@ -76,9 +85,13 @@ export const AgendaList: React.FC = ({ emptyBody = 'Events you add will show up in this timeline.', emptyAction, descending = false, + renderFooter, }) => { - const { colors, fontScale, reduceMotion } = useAppTheme(); - const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const { colors, type, elevation, reduceMotion } = useAppTheme(); + const styles = React.useMemo( + () => createStyles(colors, type, elevation), + [colors, type, elevation] + ); const groups = useMemo(() => { const sorted = [...events].sort((a, b) => { @@ -139,6 +152,7 @@ export const AgendaList: React.FC = ({ index={itemIndex++} onPress={() => onEventPress(event)} badge={badgeFor?.(event) ?? null} + footer={renderFooter?.(event) ?? null} styles={styles} colors={colors} reduceMotion={reduceMotion} @@ -157,6 +171,7 @@ function AgendaCard({ index, onPress, badge, + footer, styles, colors, reduceMotion, @@ -165,6 +180,7 @@ function AgendaCard({ index: number; onPress: () => void; badge: AgendaBadge | null; + footer: React.ReactNode; styles: ReturnType; colors: AppPalette; reduceMotion: boolean; @@ -243,7 +259,7 @@ function AgendaCard({ ) : null} {event.rsvpEnabled && going > 0 && ( - + {going} going @@ -252,28 +268,29 @@ function AgendaCard({ + {footer ? {footer} : null} ); } -const createStyles = (colors: AppPalette, fontScale: number) => +const createStyles = (colors: AppPalette, type: Typography, elevation: Elevation) => StyleSheet.create({ container: { flex: 1, }, content: { - paddingHorizontal: 16, - paddingTop: 8, - paddingBottom: 32, + paddingHorizontal: Spacing.lg, + paddingTop: Spacing.sm, + paddingBottom: Spacing.xxl, }, group: { - marginBottom: 4, + marginBottom: Spacing.xs, }, dateRow: { flexDirection: 'row', alignItems: 'center', - gap: 10, - marginBottom: 10, + gap: Spacing.sm, + marginBottom: Spacing.md, }, spineDot: { width: 8, @@ -282,8 +299,7 @@ const createStyles = (colors: AppPalette, fontScale: number) => backgroundColor: colors.primary, }, dateLabel: { - fontSize: 14 * fontScale, - fontWeight: '700', + ...type.subhead, color: colors.textPrimary, }, dateRule: { @@ -293,29 +309,30 @@ const createStyles = (colors: AppPalette, fontScale: number) => }, groupBody: { position: 'relative', - paddingLeft: 18, - paddingBottom: 16, + paddingLeft: Spacing.lg + Spacing.xs, + paddingBottom: Spacing.lg, }, spineLine: { position: 'absolute', left: 3.5, top: 0, - bottom: -10, + bottom: -Spacing.md, width: 1, backgroundColor: colors.border, }, cards: { - gap: 10, + gap: Spacing.md, }, card: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.surface, - borderRadius: 14, + borderRadius: Radii.lg, borderWidth: 1, borderColor: colors.border, - paddingRight: 12, + paddingRight: Spacing.md, overflow: 'hidden', + ...elevation.low, }, cardAccent: { width: 4, @@ -323,47 +340,60 @@ const createStyles = (colors: AppPalette, fontScale: number) => }, cardBody: { flex: 1, - paddingVertical: 12, - paddingHorizontal: 12, + paddingVertical: Spacing.md, + paddingHorizontal: Spacing.md, + gap: Spacing.xs, }, cardTopRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 3, - gap: 8, + gap: Spacing.sm, }, cardTime: { - fontSize: 12 * fontScale, - fontWeight: '600', + ...type.caption, color: colors.textSecondary, }, cardTitle: { - fontSize: 16 * fontScale, - fontWeight: '700', + ...type.headline, color: colors.textPrimary, - marginBottom: 5, }, cardMetaRow: { flexDirection: 'row', alignItems: 'center', - gap: 12, + gap: Spacing.md, }, metaItem: { flexDirection: 'row', alignItems: 'center', - gap: 4, + gap: Spacing.xs, flexShrink: 1, }, + metaItemFixed: { + flexShrink: 0, + }, metaText: { - fontSize: 12 * fontScale, + ...type.caption, + fontWeight: '500', color: colors.textTertiary, flexShrink: 1, }, + cardFooter: { + marginTop: -Spacing.md, + paddingTop: Spacing.lg, + paddingBottom: Spacing.md, + paddingHorizontal: Spacing.md, + backgroundColor: colors.surfaceAlt, + borderBottomLeftRadius: Radii.lg, + borderBottomRightRadius: Radii.lg, + borderWidth: 1, + borderTopWidth: 0, + borderColor: colors.border, + }, badge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 999, + paddingHorizontal: Spacing.sm, + paddingVertical: Spacing.xxs, + borderRadius: Radii.pill, backgroundColor: colors.surfaceAlt, }, badgeGoing: { @@ -373,8 +403,7 @@ const createStyles = (colors: AppPalette, fontScale: number) => backgroundColor: 'rgba(139, 92, 246, 0.12)', }, badgeText: { - fontSize: 11 * fontScale, - fontWeight: '700', + ...type.overline, color: colors.textSecondary, }, badgeTextGoing: { @@ -387,40 +416,39 @@ const createStyles = (colors: AppPalette, fontScale: number) => flex: 1, alignItems: 'center', justifyContent: 'center', - padding: 32, + padding: Spacing.xxl, }, emptyIconWrap: { - width: 52, - height: 52, - borderRadius: 16, + width: 56, + height: 56, + borderRadius: Radii.lg, backgroundColor: colors.surfaceAlt, alignItems: 'center', justifyContent: 'center', - marginBottom: 14, + marginBottom: Spacing.lg, }, emptyTitle: { - fontSize: 17 * fontScale, - fontWeight: '700', + ...type.title3, color: colors.textPrimary, - marginBottom: 6, + marginBottom: Spacing.sm, }, emptyBody: { - fontSize: 14 * fontScale, - lineHeight: 20 * fontScale, + ...type.callout, color: colors.textSecondary, textAlign: 'center', - maxWidth: 300, - marginBottom: 18, + maxWidth: 320, + marginBottom: Spacing.xl, }, emptyButton: { backgroundColor: colors.primary, - borderRadius: 10, - paddingHorizontal: 18, - paddingVertical: 10, + borderRadius: Radii.md, + paddingHorizontal: Spacing.xl, + paddingVertical: Spacing.md, + minHeight: TouchTarget, + justifyContent: 'center', }, emptyButtonText: { - fontSize: 14 * fontScale, - fontWeight: '600', + ...type.subhead, color: colors.onPrimary, }, }); diff --git a/apps/client/components/events/EventCard.tsx b/apps/client/components/events/EventCard.tsx index 53323db..21e1bd8 100644 --- a/apps/client/components/events/EventCard.tsx +++ b/apps/client/components/events/EventCard.tsx @@ -1,10 +1,13 @@ import React, { useEffect, useRef } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, Animated, Image } from 'react-native'; +import { View, Text, Pressable, StyleSheet, Animated, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { Event } from '@/types/event'; import { formatDate, formatTimeRange } from '@/utils/dateHelpers'; +import { getAvailableSpots, getClaimedSpots } from '@/utils/eventHelpers'; import { CategoryPill } from '@/components/ui/CategoryPill'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; +import { Elevation, Motion, Radii, Spacing, Typography } from '@/constants/design'; interface EventCardProps { event: Event; @@ -12,63 +15,63 @@ interface EventCardProps { index?: number; } +/** + * Browse card. Same anatomy as the feed card — date tile, title, quiet meta — + * with room for the organiser, a cover image when there is one, and the + * capacity state. One card language across the app is most of what makes it + * feel designed rather than assembled. + */ export const EventCard: React.FC = ({ event, onPress, index = 0 }) => { - const { colors, fontScale, reduceMotion } = useAppTheme(); - const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const { colors, type, elevation, reduceMotion } = useAppTheme(); + const styles = React.useMemo( + () => createStyles(colors, type, elevation), + [colors, type, elevation] + ); - const totalRSVPs = event.rsvpCounts.going + event.rsvpCounts.maybe; - const fadeAnim = useRef(new Animated.Value(0)).current; - const slideAnim = useRef(new Animated.Value(20)).current; - const scaleAnim = useRef(new Animated.Value(0.95)).current; + const totalRSVPs = getClaimedSpots(event); + const spotsLeft = getAvailableSpots(event); + const fadeAnim = useRef(new Animated.Value(reduceMotion ? 1 : 0)).current; + const slideAnim = useRef(new Animated.Value(reduceMotion ? 0 : 12)).current; + const scaleAnim = useRef(new Animated.Value(1)).current; useEffect(() => { if (reduceMotion) { - // Skip the entrance animation: jump straight to the final values fadeAnim.setValue(1); slideAnim.setValue(0); scaleAnim.setValue(1); return; } + const delay = Math.min(index, 8) * Motion.stagger; Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, - duration: 400, - delay: index * 50, + duration: Motion.slow, + delay, useNativeDriver: true, }), Animated.spring(slideAnim, { toValue: 0, - delay: index * 50, - tension: 50, - friction: 7, - useNativeDriver: true, - }), - Animated.spring(scaleAnim, { - toValue: 1, - delay: index * 50, - tension: 50, - friction: 7, + delay, + tension: 90, + friction: 14, useNativeDriver: true, }), ]).start(); - }, [event.id, reduceMotion, index, fadeAnim, slideAnim, scaleAnim]); + }, [fadeAnim, slideAnim, scaleAnim, index, reduceMotion]); const handlePressIn = () => { if (reduceMotion) return; - Animated.spring(scaleAnim, { - toValue: 0.97, - useNativeDriver: true, - }).start(); + Animated.spring(scaleAnim, { toValue: 0.985, useNativeDriver: true }).start(); }; const handlePressOut = () => { if (reduceMotion) return; - Animated.spring(scaleAnim, { - toValue: 1, - useNativeDriver: true, - }).start(); + Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true }).start(); }; + const start = new Date(event.startTime); + const weekday = start.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase(); + return ( = ({ event, onPress, index = 0 transform: [{ translateY: slideAnim }, { scale: scaleAnim }], }} > - - {/* Color Bar */} - + {event.imageUrl ? ( + + ) : null} - {/* Flyer Image */} - {event.imageUrl ? ( - - ) : null} + + + {weekday} + {start.getDate()} + - {/* Content */} - - {/* Header */} - - - {event.title} - - {event.isClubEvent && ( - - Club - - )} - {event.isSocialEvent && ( - - Social + + + + {event.title} + + {event.isClubEvent ? ( + + Club + + ) : event.isSocialEvent ? ( + + Social + + ) : null} - )} - - - {/* Time & Location */} - - 🕒 - - {formatDate(event.startTime)} • {formatTimeRange(event.startTime, event.endTime)} - {event.recurring ? ' 🔁' : ''} - - - - 📍 - - {event.location} - - + + + + {formatDate(event.startTime)} · {formatTimeRange(event.startTime, event.endTime)} + + {event.recurring ? ( + + ) : null} + - {/* Organizer */} - - 👤 - - {event.organizer.name} - - + {event.location ? ( + + + + {event.location} · {event.organizer.name} + + + ) : null} - {/* Categories */} - - {event.categories.slice(0, 3).map((category) => ( - - ))} - {event.categories.length > 3 && ( - - +{event.categories.length - 3} - - )} + + {event.categories.slice(0, 3).map((category) => ( + + ))} + {event.categories.length > 3 && ( + +{event.categories.length - 3} + )} + + - {/* Footer */} {event.rsvpEnabled && ( - - - - {totalRSVPs} {totalRSVPs === 1 ? 'person' : 'people'} interested - - + + {totalRSVPs} {totalRSVPs === 1 ? 'person' : 'people'} interested + )} - {event.capacity && ( - - {event.capacity - totalRSVPs} spots left + {spotsLeft !== null && ( + + {spotsLeft === 0 + ? 'Full' + : `${spotsLeft} ${spotsLeft === 1 ? 'spot' : 'spots'} left`} )} - - + ); }; -const createStyles = (colors: AppPalette, fontScale: number) => +const createStyles = (colors: AppPalette, type: Typography, elevation: Elevation) => StyleSheet.create({ card: { backgroundColor: colors.surface, - borderRadius: 14, + borderRadius: Radii.lg, borderWidth: 1, borderColor: colors.border, overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.04, - shadowRadius: 8, - elevation: 1, - marginBottom: 16, - }, - colorBar: { - height: 4, + marginBottom: Spacing.md, + ...elevation.low, }, - image: { + cover: { width: '100%', - height: 140, + height: 132, }, - content: { - padding: 16, + row: { + flexDirection: 'row', + gap: Spacing.lg, + padding: Spacing.lg, + }, + tile: { + width: 52, + height: 52, + borderRadius: Radii.md, + alignItems: 'center', + justifyContent: 'center', + }, + tileWeekday: { + ...type.overline, + color: '#FFFFFF', + }, + tileDay: { + ...type.title3, + color: '#FFFFFF', + }, + body: { + flex: 1, + gap: Spacing.xs, }, header: { flexDirection: 'row', alignItems: 'flex-start', - marginBottom: 12, - gap: 8, + gap: Spacing.sm, }, title: { flex: 1, - fontSize: 18 * fontScale, - fontWeight: 'bold', + ...type.headline, color: colors.textPrimary, }, badge: { - backgroundColor: 'rgba(139, 127, 255, 0.14)', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 6, + paddingHorizontal: Spacing.sm, + paddingVertical: Spacing.xxs, + borderRadius: Radii.pill, + backgroundColor: colors.surfaceAlt, }, socialBadge: { - backgroundColor: 'rgba(255, 107, 168, 0.14)', - }, - socialBadgeText: { - color: '#E24E8C', + backgroundColor: colors.infoSoft, }, badgeText: { - fontSize: 10 * fontScale, - fontWeight: '700', - color: '#8B7FFF', - textTransform: 'uppercase', - letterSpacing: 0.4, + ...type.overline, + color: colors.textSecondary, + }, + socialBadgeText: { + color: colors.infoText, }, infoRow: { flexDirection: 'row', alignItems: 'center', - marginBottom: 8, - gap: 8, - }, - infoIcon: { - fontSize: 14, + gap: Spacing.sm, }, infoText: { flex: 1, - fontSize: 14 * fontScale, + ...type.footnote, color: colors.textSecondary, }, categories: { flexDirection: 'row', flexWrap: 'wrap', - gap: 6, - marginTop: 12, - marginBottom: 12, + alignItems: 'center', + gap: Spacing.sm, + marginTop: Spacing.xs, }, moreCategories: { - fontSize: 12 * fontScale, + ...type.caption, color: colors.textTertiary, - alignSelf: 'center', }, footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingTop: 12, + gap: Spacing.sm, + paddingHorizontal: Spacing.lg, + paddingVertical: Spacing.md, borderTopWidth: 1, borderTopColor: colors.border, - }, - rsvpInfo: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - rsvpIcon: { - fontSize: 14, - color: colors.success, + backgroundColor: colors.surfaceAlt, }, rsvpText: { - fontSize: 13 * fontScale, + ...type.caption, + fontWeight: '500', color: colors.textSecondary, }, capacity: { - fontSize: 13 * fontScale, + ...type.caption, color: colors.primary, - fontWeight: '500', + }, + capacityFull: { + color: colors.textTertiary, }, }); diff --git a/apps/client/components/events/EventDetailSidebar.tsx b/apps/client/components/events/EventDetailSidebar.tsx index 714d2ac..58078c9 100644 --- a/apps/client/components/events/EventDetailSidebar.tsx +++ b/apps/client/components/events/EventDetailSidebar.tsx @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/Button'; import { CategoryPill } from '@/components/ui/CategoryPill'; import { Event, RSVPStatus } from '@/types/event'; import { formatFullDate, formatTimeRange } from '@/utils/dateHelpers'; +import { getAvailableSpots, getClaimedSpots } from '@/utils/eventHelpers'; import { useResponsive } from '@/hooks/useResponsive'; import { useAuth } from '@/contexts/AuthContext'; import { useEvents } from '@/contexts/EventsContext'; @@ -44,6 +45,8 @@ export const EventDetailSidebar: React.FC = ({ if (!event) return null; const userRSVP = currentUser ? getRSVPStatus(event.id, currentUser.id) : null; + const claimedSpots = getClaimedSpots(event); + const spotsLeft = getAvailableSpots(event); const handleRSVP = async (status: RSVPStatus) => { if (!currentUser) return; @@ -107,7 +110,7 @@ export const EventDetailSidebar: React.FC = ({ {/* Date & Time */} - 🕒 + Date & Time @@ -130,7 +133,7 @@ export const EventDetailSidebar: React.FC = ({ {/* Location */} - 📍 + Location {event.location} @@ -141,7 +144,7 @@ export const EventDetailSidebar: React.FC = ({ {/* Organizer */} - 👤 + Organized by {event.organizer.name} @@ -209,12 +212,14 @@ export const EventDetailSidebar: React.FC = ({ {/* Capacity */} - {event.capacity && ( + {event.capacity ? ( Capacity - {event.rsvpCounts.going + event.rsvpCounts.maybe} / {event.capacity} + {spotsLeft === 0 + ? 'Full' + : `${spotsLeft} of ${event.capacity} spots left`} @@ -222,19 +227,14 @@ export const EventDetailSidebar: React.FC = ({ style={[ styles.capacityFill, { - width: `${Math.min( - 100, - ((event.rsvpCounts.going + event.rsvpCounts.maybe) / - event.capacity) * - 100 - )}%`, + width: `${Math.min(100, (claimedSpots / event.capacity) * 100)}%`, backgroundColor: event.color, }, ]} /> - )} + ) : null} {/* RSVP Stats */} {event.rsvpEnabled && ( @@ -354,7 +354,8 @@ const createStyles = (colors: AppPalette, fontScale: number) => gap: 12, }, infoIcon: { - fontSize: 20, + width: 20, + textAlign: 'center', }, infoContent: { flex: 1, diff --git a/apps/client/components/events/EventThread.tsx b/apps/client/components/events/EventThread.tsx new file mode 100644 index 0000000..6142270 --- /dev/null +++ b/apps/client/components/events/EventThread.tsx @@ -0,0 +1,406 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TextInput, + Pressable, + ActivityIndicator, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; +import { Radii, Spacing, TouchTarget, Typography } from '@/constants/design'; +import { useAuth } from '@/contexts/AuthContext'; +import { useEventMessages } from '@/hooks/useEventMessages'; +import { EventMessage, canParticipate } from '@/utils/eventMessages'; +import { Event, RSVPStatus } from '@/types/event'; + +interface EventThreadProps { + event: Event; + /** The signed-in user's RSVP, used to gate the thread. */ + rsvpStatus: RSVPStatus; +} + +function initials(name: string): string { + return name + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? '') + .join(''); +} + +function timeAgo(iso: string): string { + const then = new Date(iso).getTime(); + if (isNaN(then)) return ''; + const minutes = Math.floor((Date.now() - then) / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** + * The conversation attached to an event: host announcements pinned at the top, + * then everyone else's messages in order. + * + * Only people going (or maybe) and the host can read or post — the same rule + * the database enforces — so the thread stays a room for people who are + * actually coming rather than a public comment section. + */ +export const EventThread: React.FC = ({ event, rsvpStatus }) => { + const { colors, type } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, type), [colors, type]); + const { currentUser } = useAuth(); + + const isHost = Boolean(currentUser && event.organizer.id === currentUser.id); + const allowed = Boolean(currentUser) && canParticipate(rsvpStatus, isHost); + + const { messages, isLoading, error, post, remove, isDeviceOnly } = useEventMessages({ + eventId: event.id, + userId: currentUser?.id, + authorName: currentUser?.name ?? 'Someone', + enabled: allowed, + }); + + const [draft, setDraft] = useState(''); + const [asAnnouncement, setAsAnnouncement] = useState(false); + const [isSending, setIsSending] = useState(false); + + const send = async () => { + if (!draft.trim() || isSending) return; + setIsSending(true); + const ok = await post(draft, asAnnouncement ? 'announcement' : 'message'); + setIsSending(false); + if (ok) setDraft(''); + }; + + const announcements = messages.filter((m) => m.kind === 'announcement'); + const chat = messages.filter((m) => m.kind === 'message'); + + if (!allowed) { + return ( + + Attendee chat + + + + {currentUser + ? 'Register for this event to see announcements and talk to the people going.' + : 'Sign in and register to join the conversation for this event.'} + + + + ); + } + + return ( + + + + {isHost ? 'Announcements & chat' : 'Announcements & chat'} + + + {messages.length} {messages.length === 1 ? 'post' : 'posts'} + + + + {announcements.length > 0 && ( + + {announcements.map((message) => ( + remove(message.id)} + styles={styles} + colors={colors} + /> + ))} + + )} + + {isLoading ? ( + + + + ) : chat.length === 0 && announcements.length === 0 ? ( + + {isHost + ? 'Nothing posted yet — send an announcement so everyone going hears it.' + : 'No messages yet. Say hi to the people going.'} + + ) : ( + + {chat.map((message) => ( + remove(message.id)} + styles={styles} + colors={colors} + /> + ))} + + )} + + {error ? {error} : null} + + + + + + + + + {isHost && ( + setAsAnnouncement((prev) => !prev)} + style={styles.announceToggle} + accessibilityRole="switch" + accessibilityState={{ checked: asAnnouncement }} + > + + + Post as announcement + + + )} + + {isDeviceOnly && ( + + Saved on this device — connect a Supabase project to sync the thread + between everyone going. + + )} + + ); +}; + +function MessageBubble({ + message, + isMine, + onDelete, + styles, + colors, +}: { + message: EventMessage; + isMine: boolean; + onDelete: () => void; + styles: ReturnType; + colors: AppPalette; +}) { + const isAnnouncement = message.kind === 'announcement'; + return ( + + + {isAnnouncement ? ( + + ) : ( + {initials(message.authorName)} + )} + + + + + {message.authorName} + {isAnnouncement ? ' · Host' : ''} + + {timeAgo(message.createdAt)} + + {message.body} + {isMine && ( + + Delete + + )} + + + ); +} + +const createStyles = (colors: AppPalette, type: Typography) => + StyleSheet.create({ + section: { + gap: Spacing.md, + paddingTop: Spacing.xl, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'baseline', + justifyContent: 'space-between', + }, + sectionTitle: { + ...type.title3, + color: colors.textPrimary, + }, + count: { + ...type.footnote, + color: colors.textTertiary, + }, + lockedCard: { + flexDirection: 'row', + gap: Spacing.md, + alignItems: 'flex-start', + backgroundColor: colors.surfaceAlt, + borderRadius: Radii.md, + padding: Spacing.lg, + }, + lockedText: { + ...type.callout, + color: colors.textSecondary, + flex: 1, + }, + announcements: { + gap: Spacing.md, + }, + messages: { + gap: Spacing.md, + }, + loading: { + paddingVertical: Spacing.xl, + }, + emptyText: { + ...type.callout, + color: colors.textTertiary, + }, + error: { + ...type.footnote, + color: colors.danger, + }, + bubble: { + flexDirection: 'row', + gap: Spacing.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: Radii.md, + padding: Spacing.md, + }, + bubbleAnnouncement: { + backgroundColor: colors.infoSoft, + borderColor: 'transparent', + }, + avatar: { + width: 32, + height: 32, + borderRadius: Radii.pill, + backgroundColor: colors.surfaceAlt, + alignItems: 'center', + justifyContent: 'center', + }, + avatarAnnouncement: { + backgroundColor: colors.primary, + }, + avatarText: { + ...type.caption, + color: colors.textSecondary, + }, + bubbleBody: { + flex: 1, + gap: Spacing.xxs, + }, + bubbleTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'baseline', + gap: Spacing.sm, + }, + author: { + ...type.subhead, + color: colors.textPrimary, + flexShrink: 1, + }, + time: { + ...type.caption, + fontWeight: '400', + color: colors.textTertiary, + }, + body: { + ...type.callout, + color: colors.textPrimary, + }, + deleteButton: { + alignSelf: 'flex-start', + paddingVertical: Spacing.xs, + }, + deleteText: { + ...type.caption, + color: colors.textTertiary, + }, + composer: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: Spacing.sm, + }, + input: { + flex: 1, + ...type.callout, + color: colors.textPrimary, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: Radii.lg, + paddingHorizontal: Spacing.lg, + paddingVertical: Spacing.md, + minHeight: TouchTarget, + maxHeight: 140, + }, + sendButton: { + width: TouchTarget, + height: TouchTarget, + borderRadius: Radii.pill, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + }, + sendButtonDisabled: { + opacity: 0.4, + }, + announceToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.sm, + alignSelf: 'flex-start', + minHeight: TouchTarget - 12, + }, + announceText: { + ...type.footnote, + fontWeight: '600', + color: colors.textSecondary, + }, + announceTextActive: { + color: colors.primary, + }, + deviceNote: { + ...type.caption, + fontWeight: '400', + color: colors.textTertiary, + }, + }); diff --git a/apps/client/components/events/RateEventRow.tsx b/apps/client/components/events/RateEventRow.tsx new file mode 100644 index 0000000..2db0919 --- /dev/null +++ b/apps/client/components/events/RateEventRow.tsx @@ -0,0 +1,175 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TextInput, Pressable } from 'react-native'; +import { StarRating } from '@/components/events/StarRating'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; +import { Radii, Spacing, TouchTarget, Typography } from '@/constants/design'; +import { EventRating, MAX_NOTE_LENGTH } from '@/utils/eventRatings'; + +interface RateEventRowProps { + eventTitle: string; + rating: EventRating | null; + onRate: (stars: number, note?: string) => void; + onClear: () => void; +} + +/** + * Rate an event you attended. Tapping a star saves immediately — the note is + * optional and opens only after the first tap, so leaving a rating is one + * gesture and elaborating is a choice. + */ +export const RateEventRow: React.FC = ({ + eventTitle, + rating, + onRate, + onClear, +}) => { + const { colors, type } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, type), [colors, type]); + const [isEditingNote, setIsEditingNote] = useState(false); + const [draft, setDraft] = useState(rating?.note ?? ''); + + const stars = rating?.stars ?? 0; + + const handleStars = (next: number) => { + onRate(next, rating?.note); + }; + + const saveNote = () => { + if (!stars) return; + onRate(stars, draft); + setIsEditingNote(false); + }; + + return ( + + + + {stars ? 'Your rating' : 'How was it?'} + + + + + {stars > 0 && !isEditingNote && ( + + {rating?.note ? ( + + “{rating.note}” + + ) : null} + + { + setDraft(rating?.note ?? ''); + setIsEditingNote(true); + }} + style={styles.linkButton} + > + + {rating?.note ? 'Edit note' : 'Add a note'} + + + + Clear + + + + )} + + {isEditingNote && ( + + + + setIsEditingNote(false)} style={styles.linkButton}> + Cancel + + + Save note + + + + )} + + ); +}; + +const createStyles = (colors: AppPalette, type: Typography) => + StyleSheet.create({ + container: { + gap: Spacing.sm, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: Spacing.sm, + }, + label: { + ...type.subhead, + color: colors.textSecondary, + }, + noteRow: { + gap: Spacing.sm, + }, + note: { + ...type.footnote, + color: colors.textSecondary, + fontStyle: 'italic', + }, + noteActions: { + flexDirection: 'row', + gap: Spacing.lg, + }, + linkButton: { + minHeight: TouchTarget - 12, + justifyContent: 'center', + }, + linkText: { + ...type.footnote, + fontWeight: '600', + color: colors.primary, + }, + clearText: { + color: colors.textTertiary, + }, + editor: { + gap: Spacing.sm, + }, + input: { + ...type.callout, + color: colors.textPrimary, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: Radii.md, + padding: Spacing.md, + minHeight: 72, + textAlignVertical: 'top', + }, + editorActions: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: Spacing.lg, + }, + saveButton: { + backgroundColor: colors.primary, + borderRadius: Radii.md, + paddingHorizontal: Spacing.lg, + minHeight: TouchTarget - 8, + justifyContent: 'center', + }, + saveText: { + ...type.subhead, + color: colors.onPrimary, + }, + }); diff --git a/apps/client/components/events/StarRating.tsx b/apps/client/components/events/StarRating.tsx new file mode 100644 index 0000000..517a7c3 --- /dev/null +++ b/apps/client/components/events/StarRating.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { View, Pressable, StyleSheet, Animated } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { MAX_STARS } from '@/utils/eventRatings'; +import { Spacing, TouchTarget } from '@/constants/design'; + +interface StarRatingProps { + /** Current rating, 0 when unrated. */ + value: number; + /** Omit to render a read-only rating. */ + onChange?: (stars: number) => void; + size?: number; + /** Accessible name for the whole control, e.g. the event title. */ + label?: string; +} + +/** + * Five-star rating. Read-only when `onChange` is omitted; interactive stars + * get full-size tap targets and a small press bounce so a rating feels + * deliberate rather than accidental. + */ +export const StarRating: React.FC = ({ + value, + onChange, + size = 22, + label, +}) => { + const { colors, reduceMotion } = useAppTheme(); + const styles = React.useMemo(() => createStyles(), []); + const scales = React.useRef( + Array.from({ length: MAX_STARS }, () => new Animated.Value(1)) + ).current; + + const bounce = (index: number) => { + if (reduceMotion) return; + const scale = scales[index]; + scale.setValue(0.8); + Animated.spring(scale, { + toValue: 1, + tension: 220, + friction: 8, + useNativeDriver: true, + }).start(); + }; + + return ( + + {Array.from({ length: MAX_STARS }, (_, index) => { + const filled = index < value; + const star = ( + + ); + + if (!onChange) { + return ( + + {star} + + ); + } + + return ( + { + bounce(index); + onChange(index + 1); + }} + style={styles.tapTarget} + hitSlop={4} + accessibilityRole="button" + accessibilityLabel={`${index + 1} ${index === 0 ? 'star' : 'stars'}`} + > + + {star} + + + ); + })} + + ); +}; + +const createStyles = () => + StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + }, + tapTarget: { + minWidth: TouchTarget - 8, + height: TouchTarget - 8, + alignItems: 'center', + justifyContent: 'center', + }, + readOnlyStar: { + paddingRight: Spacing.xxs, + }, + }); diff --git a/apps/client/components/recommendations/RecommendationCard.tsx b/apps/client/components/recommendations/RecommendationCard.tsx index d386a6e..b294a53 100644 --- a/apps/client/components/recommendations/RecommendationCard.tsx +++ b/apps/client/components/recommendations/RecommendationCard.tsx @@ -1,125 +1,220 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import React, { useEffect, useRef } from 'react'; +import { View, Text, Pressable, StyleSheet, Animated, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { Event } from '@/types/event'; -import { formatDate } from '@/utils/dateHelpers'; +import { formatTimeRange } from '@/utils/dateHelpers'; +import { getAvailableSpots } from '@/utils/eventHelpers'; import { CategoryPill } from '@/components/ui/CategoryPill'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; +import { Elevation, Motion, Radii, Spacing, Typography } from '@/constants/design'; interface RecommendationCardProps { event: Event; onPress: () => void; reason?: string; + index?: number; } +/** + * Feed card: a date tile anchors the row, the title carries the weight, and + * everything else is quiet metadata. One accent colour per card (the event's), + * used on the tile only — colour is information here, not decoration. + */ export const RecommendationCard: React.FC = ({ event, onPress, reason, + index = 0, }) => { - const { colors, fontScale } = useAppTheme(); - const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const { colors, type, elevation, reduceMotion } = useAppTheme(); + const styles = React.useMemo( + () => createStyles(colors, type, elevation), + [colors, type, elevation] + ); - return ( - - + const fade = useRef(new Animated.Value(reduceMotion ? 1 : 0)).current; + const slide = useRef(new Animated.Value(reduceMotion ? 0 : 12)).current; + const scale = useRef(new Animated.Value(1)).current; - - {reason && ( - - ✨ {reason} - - )} + useEffect(() => { + if (reduceMotion) { + fade.setValue(1); + slide.setValue(0); + return; + } + const delay = Math.min(index, 8) * Motion.stagger; + Animated.parallel([ + Animated.timing(fade, { + toValue: 1, + duration: Motion.slow, + delay, + useNativeDriver: true, + }), + Animated.spring(slide, { + toValue: 0, + delay, + tension: 90, + friction: 14, + useNativeDriver: true, + }), + ]).start(); + }, [fade, slide, index, reduceMotion]); - - {event.title} - + const start = new Date(event.startTime); + const weekday = start.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase(); + const day = start.getDate(); + const spotsLeft = getAvailableSpots(event); + const going = event.rsvpCounts.going; - - 🕒 - - {formatDate(event.startTime)} - + return ( + + { + if (!reduceMotion) { + Animated.spring(scale, { toValue: 0.985, useNativeDriver: true }).start(); + } + }} + onPressOut={() => { + if (!reduceMotion) { + Animated.spring(scale, { toValue: 1, useNativeDriver: true }).start(); + } + }} + style={styles.card} + accessibilityRole="button" + accessibilityLabel={event.title} + > + {/* Date tile — image when the event has one, otherwise its colour */} + + {event.imageUrl ? ( + + ) : null} + + {weekday} + {day} + - - 📍 - - {event.location} + + {reason ? ( + + {reason} + + ) : null} + + + {event.title} - - - {event.categories.slice(0, 2).map((category) => ( - - ))} + + + + {formatTimeRange(event.startTime, event.endTime)} + {event.location ? ` · ${event.location}` : ''} + + + + + + {event.categories.slice(0, 2).map((category) => ( + + ))} + + {going > 0 || spotsLeft === 0 ? ( + + {spotsLeft === 0 ? 'Full' : `${going} going`} + + ) : null} + - - + + ); }; -const createStyles = (colors: AppPalette, fontScale: number) => +const createStyles = (colors: AppPalette, type: Typography, elevation: Elevation) => StyleSheet.create({ card: { + flexDirection: 'row', + gap: Spacing.lg, backgroundColor: colors.surface, - borderRadius: 12, + borderRadius: Radii.lg, + borderWidth: 1, + borderColor: colors.border, + padding: Spacing.lg, + marginBottom: Spacing.md, + ...elevation.low, + }, + tile: { + width: 56, + height: 56, + borderRadius: Radii.md, overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.08, - shadowRadius: 4, - elevation: 2, - marginBottom: 12, + justifyContent: 'center', + alignItems: 'center', + }, + tileImage: { + ...StyleSheet.absoluteFillObject, }, - colorBar: { - height: 3, + tileOverlay: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: Spacing.sm, + paddingVertical: Spacing.xs, + borderRadius: Radii.sm, + backgroundColor: 'rgba(0, 0, 0, 0.28)', + }, + tileWeekday: { + ...type.overline, + color: '#FFFFFF', }, - content: { - padding: 14, + tileDay: { + ...type.title3, + color: '#FFFFFF', }, - reasonBadge: { - backgroundColor: '#FEF3C7', - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 12, - alignSelf: 'flex-start', - marginBottom: 8, + body: { + flex: 1, + gap: Spacing.xs, }, - reasonText: { - fontSize: 11 * fontScale, - fontWeight: '600', - color: '#92400E', + reason: { + ...type.overline, + color: colors.textTertiary, }, title: { - fontSize: 16 * fontScale, - fontWeight: 'bold', + ...type.headline, color: colors.textPrimary, - marginBottom: 8, }, - infoRow: { + metaRow: { flexDirection: 'row', alignItems: 'center', - marginBottom: 6, - gap: 6, + gap: Spacing.sm, }, - infoIcon: { - fontSize: 12, - }, - infoText: { + metaText: { flex: 1, - fontSize: 13 * fontScale, + ...type.footnote, color: colors.textSecondary, }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: Spacing.sm, + marginTop: Spacing.xs, + }, categories: { flexDirection: 'row', flexWrap: 'wrap', - gap: 6, - marginTop: 8, + gap: Spacing.sm, + flexShrink: 1, + }, + attendance: { + ...type.caption, + color: colors.textTertiary, }, }); diff --git a/apps/client/components/recommendations/RecommendationsList.tsx b/apps/client/components/recommendations/RecommendationsList.tsx index 83a1e12..2d59cb5 100644 --- a/apps/client/components/recommendations/RecommendationsList.tsx +++ b/apps/client/components/recommendations/RecommendationsList.tsx @@ -1,9 +1,12 @@ import React from 'react'; -import { View, Text, FlatList, StyleSheet, TouchableOpacity } from 'react-native'; +import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; import { Event } from '@/types/event'; import { RecommendationCard } from './RecommendationCard'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; +import { ContentWidth, Radii, Spacing, TouchTarget, Typography } from '@/constants/design'; interface RecommendationsListProps { events: Event[]; @@ -14,22 +17,19 @@ interface RecommendationsListProps { } const getRecommendationReason = (event: Event, index: number): string => { - const reasons = [ - 'Popular in your area', - 'Based on your interests', - 'Trending now', - 'New event', - 'Similar to events you liked', - 'Recommended for you', - ]; - - if (event.rsvpCounts.going > 50) return 'Popular event'; - if (event.isSocialEvent) return 'Social event nearby'; + if (event.rsvpCounts.going > 50) return 'Popular right now'; + if (event.isSocialEvent) return 'Social'; if (event.isClubEvent) return 'Club event'; - - return reasons[index % reasons.length]; + return ['Based on your interests', 'Worth a look', 'New this week'][index % 3]; }; +function greeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} + export const RecommendationsList: React.FC = ({ events, onEventPress, @@ -37,44 +37,77 @@ export const RecommendationsList: React.FC = ({ showFilters = false, onFilterPress, }) => { - const { colors, fontScale } = useAppTheme(); - const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const { colors, type } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, type), [colors, type]); + + const header = showFilters ? ( + + + + {greeting()} + What's on + + + router.push('/my-events')} + accessibilityRole="button" + accessibilityLabel="My events" + > + + + + + + + + + {events.length} {events.length === 1 ? 'event' : 'events'} picked for you + + + ) : null; if (events.length === 0) { return ( - - 🎉 - No recommendations yet - - Check back later for personalized event suggestions - + + {header} + + + + + Nothing to show yet + + Loosen your filters, or browse everything happening on campus. + + router.push('/(tabs)/find')}> + Browse all events + + ); } return ( - {showFilters && ( - - Recommended for you - - - - - )} - item.id} + ListHeaderComponent={header} renderItem={({ item, index }) => ( onEventPress(item)} reason={getRecommendationReason(item, index)} /> )} contentContainerStyle={styles.listContent} - showsVerticalScrollIndicator={true} + showsVerticalScrollIndicator={false} onRefresh={onRefresh} refreshing={false} /> @@ -82,58 +115,95 @@ export const RecommendationsList: React.FC = ({ ); }; -const createStyles = (colors: AppPalette, fontScale: number) => +const createStyles = (colors: AppPalette, type: Typography) => StyleSheet.create({ container: { flex: 1, }, header: { + paddingTop: Spacing.md, + paddingBottom: Spacing.lg, + gap: Spacing.xs, + }, + headerTop: { flexDirection: 'row', + alignItems: 'flex-start', justifyContent: 'space-between', - alignItems: 'center', - padding: 16, - backgroundColor: colors.surface, - borderBottomWidth: 1, - borderBottomColor: colors.border, + gap: Spacing.md, + }, + headerText: { + flex: 1, + gap: Spacing.xxs, + }, + eyebrow: { + ...type.overline, + color: colors.textTertiary, }, headerTitle: { - fontSize: 18 * fontScale, - fontWeight: 'bold', + ...type.title1, color: colors.textPrimary, }, - filterButton: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: colors.surfaceAlt, + headerSubtitle: { + ...type.footnote, + color: colors.textSecondary, + }, + headerActions: { + flexDirection: 'row', + gap: Spacing.sm, + }, + iconButton: { + width: TouchTarget, + height: TouchTarget, + borderRadius: Radii.md, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, justifyContent: 'center', alignItems: 'center', }, - filterIcon: { - fontSize: 18, - }, listContent: { - padding: 16, + paddingHorizontal: Spacing.lg, + paddingBottom: Spacing.xxl, + maxWidth: ContentWidth.regular, + width: '100%', + alignSelf: 'center', }, emptyState: { flex: 1, justifyContent: 'center', alignItems: 'center', - padding: 32, + padding: Spacing.xxl, }, - emptyIcon: { - fontSize: 64, - marginBottom: 16, + emptyIconWrap: { + width: 56, + height: 56, + borderRadius: Radii.lg, + backgroundColor: colors.surfaceAlt, + alignItems: 'center', + justifyContent: 'center', + marginBottom: Spacing.lg, }, emptyTitle: { - fontSize: 20 * fontScale, - fontWeight: 'bold', + ...type.title3, color: colors.textPrimary, - marginBottom: 8, + marginBottom: Spacing.sm, }, emptyText: { - fontSize: 16 * fontScale, + ...type.callout, color: colors.textSecondary, textAlign: 'center', + maxWidth: 320, + marginBottom: Spacing.xl, + }, + emptyButton: { + backgroundColor: colors.primary, + borderRadius: Radii.md, + paddingHorizontal: Spacing.xl, + minHeight: TouchTarget, + justifyContent: 'center', + }, + emptyButtonText: { + ...type.subhead, + color: colors.onPrimary, }, }); diff --git a/apps/client/components/ui/SearchBar.tsx b/apps/client/components/ui/SearchBar.tsx index 7f02148..d89021d 100644 --- a/apps/client/components/ui/SearchBar.tsx +++ b/apps/client/components/ui/SearchBar.tsx @@ -9,6 +9,7 @@ import { ViewStyle, } from 'react-native'; import { SearchMode } from '@/types/settings'; +import { Ionicons } from '@expo/vector-icons'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; @@ -42,7 +43,7 @@ export const SearchBar: React.FC = ({ return ( - 🔍 + height: 44, }, searchIcon: { - fontSize: 18, marginRight: 8, }, input: { diff --git a/apps/client/constants/design.ts b/apps/client/constants/design.ts new file mode 100644 index 0000000..91d03ce --- /dev/null +++ b/apps/client/constants/design.ts @@ -0,0 +1,154 @@ +import { Platform, TextStyle, ViewStyle } from 'react-native'; + +/** + * Design tokens — the vocabulary every screen builds from. + * + * The rules behind the numbers, from current mobile design guidance: + * - Spacing is an 8pt grid with 4pt half-steps. Arbitrary values (13, 18, 22) + * are what make an interface feel hand-assembled; a fixed set is what makes + * unrelated screens look like one product. + * - Type is a small, fixed scale modelled on Apple's HIG styles, with each + * step carrying its own weight, line height and tracking. Headings run + * ~1.5–2× body size so hierarchy is obvious at a glance. + * - Emphasis comes from the three text colour roles in the palette, not from + * inventing new colours per screen. + * - Depth is a single shadow ramp in light mode; in dark mode shadows are + * invisible, so separation comes from stepped surfaces and hairlines. + * - Anything tappable is at least 44pt. + */ + +/** 8pt grid with 4pt half-steps. */ +export const Spacing = { + xxs: 2, + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, + xxxl: 48, +} as const; + +export const Radii = { + sm: 8, + md: 12, + lg: 16, + xl: 24, + pill: 999, +} as const; + +/** Minimum tap target, per Apple HIG. */ +export const TouchTarget = 44; + +/** Content column width — text lines stay readable on wide screens. */ +export const ContentWidth = { + narrow: 560, + regular: 720, + wide: 1120, +} as const; + +type TypeToken = + | 'display' + | 'title1' + | 'title2' + | 'title3' + | 'headline' + | 'body' + | 'callout' + | 'subhead' + | 'footnote' + | 'caption' + | 'overline'; + +interface TypeSpec { + size: number; + lineHeight: number; + weight: TextStyle['fontWeight']; + tracking?: number; + uppercase?: boolean; +} + +/** + * Sizes are unscaled; `createType(fontScale)` applies the user's font-size + * preference. Tight tracking on the big sizes keeps large headings from + * looking loose — the standard fix for oversized system type. + */ +const TYPE: Record = { + display: { size: 34, lineHeight: 40, weight: '800', tracking: -0.8 }, + title1: { size: 28, lineHeight: 34, weight: '800', tracking: -0.6 }, + title2: { size: 22, lineHeight: 28, weight: '700', tracking: -0.4 }, + title3: { size: 20, lineHeight: 26, weight: '700', tracking: -0.3 }, + headline: { size: 17, lineHeight: 23, weight: '700', tracking: -0.2 }, + body: { size: 16, lineHeight: 24, weight: '400' }, + callout: { size: 15, lineHeight: 21, weight: '400' }, + subhead: { size: 14, lineHeight: 20, weight: '600' }, + footnote: { size: 13, lineHeight: 18, weight: '400' }, + caption: { size: 12, lineHeight: 16, weight: '600' }, + overline: { size: 11, lineHeight: 14, weight: '700', tracking: 0.8, uppercase: true }, +}; + +export type Typography = Record; + +/** Build the type scale for a given font-size preference. */ +export function createType(fontScale: number): Typography { + const built = {} as Typography; + (Object.keys(TYPE) as TypeToken[]).forEach((token) => { + const spec = TYPE[token]; + built[token] = { + fontSize: spec.size * fontScale, + lineHeight: spec.lineHeight * fontScale, + fontWeight: spec.weight, + ...(spec.tracking ? { letterSpacing: spec.tracking } : {}), + ...(spec.uppercase ? { textTransform: 'uppercase' as const } : {}), + }; + }); + return built; +} + +export interface Elevation { + /** Flat — separated by a hairline border instead. */ + flat: ViewStyle; + /** Resting cards. */ + low: ViewStyle; + /** Raised surfaces: sticky bars, popovers. */ + medium: ViewStyle; + /** Modals and sheets. */ + high: ViewStyle; +} + +const shadow = (y: number, blur: number, opacity: number): ViewStyle => + Platform.select({ + web: { boxShadow: `0 ${y}px ${blur}px rgba(15, 15, 20, ${opacity})` } as ViewStyle, + default: { + shadowColor: '#0F0F14', + shadowOffset: { width: 0, height: y }, + shadowOpacity: opacity, + shadowRadius: blur / 2, + elevation: y + 1, + }, + }) as ViewStyle; + +/** + * Shadows read as dirt on dark backgrounds, so dark mode returns flat styles + * and leans on the palette's stepped surfaces and borders for separation. + */ +export function createElevation(isDark: boolean): Elevation { + if (isDark) { + return { flat: {}, low: {}, medium: {}, high: {} }; + } + return { + flat: {}, + low: shadow(1, 2, 0.06), + medium: shadow(4, 12, 0.08), + high: shadow(12, 32, 0.14), + }; +} + +/** Standard entrance/exit timings. Long enough to read, short enough to feel instant. */ +export const Motion = { + fast: 140, + base: 220, + slow: 320, + /** Per-item stagger in a list entrance. */ + stagger: 45, +} as const; diff --git a/apps/client/contexts/RatingsContext.tsx b/apps/client/contexts/RatingsContext.tsx new file mode 100644 index 0000000..bc165f9 --- /dev/null +++ b/apps/client/contexts/RatingsContext.tsx @@ -0,0 +1,87 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { storage } from '@/lib/storage'; +import { + EventRating, + RATING_STORAGE_KEY, + RatingStore, + averageStars, + getRating, + parseRatingStore, + setRating as setRatingInStore, +} from '@/utils/eventRatings'; + +interface RatingsContextType { + /** The signed-in user's rating for an event, if they left one. */ + ratingFor: (eventId: string) => EventRating | null; + /** Save (or clear, by passing null stars) the user's rating. */ + rateEvent: (eventId: string, stars: number | null, note?: string) => void; + /** How many events the user has rated. */ + ratedCount: number; + /** Average of the user's own ratings, or null if they've rated nothing. */ + averageRating: number | null; + isLoading: boolean; +} + +const RatingsContext = createContext(undefined); + +/** + * Ratings people leave on events they attended. Kept on the device — there is + * no ratings table on the server yet, and in demo mode there is no server at + * all — so this is deliberately a personal record, not a public score. + */ +export const RatingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { currentUser } = useAuth(); + const [store, setStore] = useState({}); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + storage + .getItem(RATING_STORAGE_KEY) + .then((raw) => setStore(parseRatingStore(raw))) + .catch((error) => console.error('Failed to load ratings:', error)) + .finally(() => setIsLoading(false)); + }, []); + + const userId = currentUser?.id; + + const ratingFor = useCallback( + (eventId: string) => getRating(store, userId, eventId), + [store, userId] + ); + + const rateEvent = useCallback( + (eventId: string, stars: number | null, note?: string) => { + if (!userId) return; + setStore((prev) => { + const next = setRatingInStore(prev, userId, eventId, stars, note); + storage + .setItem(RATING_STORAGE_KEY, JSON.stringify(next)) + .catch((error) => console.error('Failed to save rating:', error)); + return next; + }); + }, + [userId] + ); + + const value = useMemo( + () => ({ + ratingFor, + rateEvent, + ratedCount: userId ? Object.keys(store[userId] ?? {}).length : 0, + averageRating: averageStars(store, userId), + isLoading, + }), + [ratingFor, rateEvent, store, userId, isLoading] + ); + + return {children}; +}; + +export const useRatings = (): RatingsContextType => { + const context = useContext(RatingsContext); + if (context === undefined) { + throw new Error('useRatings must be used within a RatingsProvider'); + } + return context; +}; diff --git a/apps/client/data/allEvents.json b/apps/client/data/allEvents.json index e392288..e3671f8 100644 --- a/apps/client/data/allEvents.json +++ b/apps/client/data/allEvents.json @@ -62,7 +62,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 30, + "capacity": 48, "tags": [ "arduino", "electronics", @@ -98,7 +98,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": true, - "capacity": 12, + "capacity": 15, "tags": [ "poker", "games", @@ -146,8 +146,8 @@ "id": "evt-005", "title": "Basketball Pickup Game", "description": "Looking for 4 more players for a casual basketball game. All levels welcome!", - "startTime": "2026-08-01T20:00:00.000Z", - "endTime": "2026-08-01T22:00:00.000Z", + "startTime": "2026-08-02T20:00:00.000Z", + "endTime": "2026-08-02T22:00:00.000Z", "location": "Highmark Center Court 2", "categories": [ "Sports", @@ -175,8 +175,8 @@ "sports", "pickup" ], - "createdAt": "2026-07-11T20:00:00.000Z", - "updatedAt": "2026-07-25T20:00:00.000Z" + "createdAt": "2026-07-12T20:00:00.000Z", + "updatedAt": "2026-07-26T20:00:00.000Z" }, { "id": "evt-006", @@ -241,7 +241,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 400, + "capacity": 465, "tags": [ "hackathon", "coding", @@ -289,8 +289,8 @@ "id": "evt-009", "title": "Coffee Chat: Alumni in Tech", "description": "Informal networking with CMU alumni working at FAANG companies.", - "startTime": "2026-08-02T19:00:00.000Z", - "endTime": "2026-08-02T20:30:00.000Z", + "startTime": "2026-08-03T19:00:00.000Z", + "endTime": "2026-08-03T20:30:00.000Z", "location": "Rothberg's Roasters", "categories": [ "Career", @@ -312,14 +312,14 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 50, + "capacity": 88, "tags": [ "networking", "alumni", "career" ], - "createdAt": "2026-07-12T19:00:00.000Z", - "updatedAt": "2026-07-26T19:00:00.000Z" + "createdAt": "2026-07-13T19:00:00.000Z", + "updatedAt": "2026-07-27T19:00:00.000Z" }, { "id": "evt-010", @@ -348,7 +348,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 600, + "capacity": 648, "tags": [ "fashion", "show", @@ -384,7 +384,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 120, + "capacity": 126, "tags": [ "trivia", "games", @@ -420,7 +420,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 100, + "capacity": 150, "tags": [ "ml", "ai", @@ -433,8 +433,8 @@ "id": "evt-013", "title": "Bubble Tea Run", "description": "Going to get bubble tea in Oakland, anyone want to join? Meeting at UC.", - "startTime": "2026-08-03T18:30:00.000Z", - "endTime": "2026-08-03T20:00:00.000Z", + "startTime": "2026-08-04T18:30:00.000Z", + "endTime": "2026-08-04T20:00:00.000Z", "location": "University Center Entrance", "categories": [ "Food", @@ -462,8 +462,8 @@ "boba", "social" ], - "createdAt": "2026-07-13T18:30:00.000Z", - "updatedAt": "2026-07-27T18:30:00.000Z" + "createdAt": "2026-07-14T18:30:00.000Z", + "updatedAt": "2026-07-28T18:30:00.000Z" }, { "id": "evt-014", @@ -492,7 +492,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 200, + "capacity": 221, "tags": [ "startup", "pitch", @@ -528,7 +528,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 80, + "capacity": 84, "tags": [ "dance", "salsa", @@ -564,7 +564,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 60, + "capacity": 107, "tags": [ "study-abroad", "international", @@ -577,8 +577,8 @@ "id": "evt-017", "title": "Rock Climbing @ The Climbing Wall", "description": "Casual climbing session. Equipment provided, all levels welcome!", - "startTime": "2026-08-04T19:00:00.000Z", - "endTime": "2026-08-04T21:00:00.000Z", + "startTime": "2026-08-05T19:00:00.000Z", + "endTime": "2026-08-05T21:00:00.000Z", "location": "Cohon Center Climbing Wall", "categories": [ "Sports", @@ -606,8 +606,8 @@ "sports", "fitness" ], - "createdAt": "2026-07-14T19:00:00.000Z", - "updatedAt": "2026-07-28T19:00:00.000Z" + "createdAt": "2026-07-15T19:00:00.000Z", + "updatedAt": "2026-07-29T19:00:00.000Z" }, { "id": "evt-018", @@ -721,8 +721,8 @@ "id": "evt-021", "title": "Cybersecurity CTF Competition", "description": "Capture the flag competition testing your hacking skills. Form teams of 3-4.", - "startTime": "2026-08-05T22:00:00.000Z", - "endTime": "2026-08-06T10:00:00.000Z", + "startTime": "2026-08-06T22:00:00.000Z", + "endTime": "2026-08-07T10:00:00.000Z", "location": "Gates Hillman Center", "categories": [ "Tech", @@ -744,14 +744,14 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 100, + "capacity": 113, "tags": [ "ctf", "security", "hacking" ], - "createdAt": "2026-07-15T22:00:00.000Z", - "updatedAt": "2026-07-29T22:00:00.000Z" + "createdAt": "2026-07-16T22:00:00.000Z", + "updatedAt": "2026-07-30T22:00:00.000Z" }, { "id": "evt-022", @@ -851,7 +851,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 30, + "capacity": 47, "tags": [ "meditation", "wellness", @@ -864,8 +864,8 @@ "id": "evt-025", "title": "Late Night Breakfast", "description": "Free pancakes, waffles, and breakfast foods served at midnight.", - "startTime": "2026-08-07T02:00:00.000Z", - "endTime": "2026-08-07T05:00:00.000Z", + "startTime": "2026-08-08T02:00:00.000Z", + "endTime": "2026-08-08T05:00:00.000Z", "location": "Resnik Dining Hall", "categories": [ "Food", @@ -892,8 +892,8 @@ "breakfast", "free" ], - "createdAt": "2026-07-17T02:00:00.000Z", - "updatedAt": "2026-07-31T02:00:00.000Z" + "createdAt": "2026-07-18T02:00:00.000Z", + "updatedAt": "2026-08-01T02:00:00.000Z" }, { "id": "evt-026", @@ -922,7 +922,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": true, - "capacity": 20, + "capacity": 21, "tags": [ "photography", "nature", @@ -958,7 +958,7 @@ "attendeeVisibility": "private", "isClubEvent": true, "isSocialEvent": false, - "capacity": 60, + "capacity": 97, "tags": [ "interview", "career", @@ -1012,8 +1012,8 @@ "id": "evt-029", "title": "Open Mic Night", "description": "Share your music, poetry, or comedy. Sign up at the door or just watch!", - "startTime": "2026-08-07T23:00:00.000Z", - "endTime": "2026-08-08T02:00:00.000Z", + "startTime": "2026-08-08T23:00:00.000Z", + "endTime": "2026-08-09T02:00:00.000Z", "location": "The Underground", "categories": [ "Arts", @@ -1035,14 +1035,14 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 100, + "capacity": 109, "tags": [ "open-mic", "music", "performance" ], - "createdAt": "2026-07-17T23:00:00.000Z", - "updatedAt": "2026-07-31T23:00:00.000Z" + "createdAt": "2026-07-18T23:00:00.000Z", + "updatedAt": "2026-08-01T23:00:00.000Z" }, { "id": "evt-030", @@ -1071,7 +1071,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 50, + "capacity": 70, "tags": [ "sustainability", "environment", @@ -1161,8 +1161,8 @@ "id": "evt-033", "title": "3D Printing Workshop", "description": "Learn to design and print 3D models. Beginners welcome!", - "startTime": "2026-08-08T19:00:00.000Z", - "endTime": "2026-08-08T21:00:00.000Z", + "startTime": "2026-08-09T19:00:00.000Z", + "endTime": "2026-08-09T21:00:00.000Z", "location": "Hunt Library Maker Space", "categories": [ "Tech", @@ -1184,14 +1184,14 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 30, + "capacity": 35, "tags": [ "3d-printing", "maker", "workshop" ], - "createdAt": "2026-07-18T19:00:00.000Z", - "updatedAt": "2026-08-01T19:00:00.000Z" + "createdAt": "2026-07-19T19:00:00.000Z", + "updatedAt": "2026-08-02T19:00:00.000Z" }, { "id": "evt-034", @@ -1220,7 +1220,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": true, - "capacity": 22, + "capacity": 59, "tags": [ "soccer", "sports", @@ -1293,7 +1293,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 100, + "capacity": 150, "tags": [ "data-science", "networking", @@ -1306,8 +1306,8 @@ "id": "evt-037", "title": "Acapella Concert", "description": "CMU's acapella groups perform. Free admission!", - "startTime": "2026-08-10T00:00:00.000Z", - "endTime": "2026-08-10T02:00:00.000Z", + "startTime": "2026-08-11T00:00:00.000Z", + "endTime": "2026-08-11T02:00:00.000Z", "location": "Kresge Theatre", "categories": [ "Arts", @@ -1329,14 +1329,14 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 200, + "capacity": 239, "tags": [ "acapella", "music", "concert" ], - "createdAt": "2026-07-20T00:00:00.000Z", - "updatedAt": "2026-08-03T00:00:00.000Z" + "createdAt": "2026-07-21T00:00:00.000Z", + "updatedAt": "2026-08-04T00:00:00.000Z" }, { "id": "evt-038", @@ -1365,7 +1365,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 20, + "capacity": 46, "tags": [ "cooking", "food", @@ -1401,7 +1401,7 @@ "attendeeVisibility": "private", "isClubEvent": true, "isSocialEvent": false, - "capacity": 60, + "capacity": 88, "tags": [ "mental-health", "wellness", @@ -1451,8 +1451,8 @@ "id": "evt-041", "title": "Activities Fair on the Cut", "description": "Every student org on campus in one place. Free swag, sign-up sheets, and a lot of free food.", - "startTime": "2026-08-10T19:00:00.000Z", - "endTime": "2026-08-10T22:00:00.000Z", + "startTime": "2026-08-11T19:00:00.000Z", + "endTime": "2026-08-11T22:00:00.000Z", "location": "The Cut", "categories": [ "Social", @@ -1480,8 +1480,8 @@ "orgs", "freshman" ], - "createdAt": "2026-07-20T19:00:00.000Z", - "updatedAt": "2026-08-03T19:00:00.000Z" + "createdAt": "2026-07-21T19:00:00.000Z", + "updatedAt": "2026-08-04T19:00:00.000Z" }, { "id": "evt-042", @@ -1593,8 +1593,8 @@ "id": "evt-045", "title": "Student Org Open House", "description": "Drop in and meet officers from 40+ clubs. Bring questions, leave with a calendar full of meetings.", - "startTime": "2026-08-11T22:00:00.000Z", - "endTime": "2026-08-12T00:00:00.000Z", + "startTime": "2026-08-12T22:00:00.000Z", + "endTime": "2026-08-13T00:00:00.000Z", "location": "Rangos Ballroom", "categories": [ "Networking", @@ -1621,8 +1621,8 @@ "clubs", "networking" ], - "createdAt": "2026-07-21T22:00:00.000Z", - "updatedAt": "2026-08-04T22:00:00.000Z" + "createdAt": "2026-07-22T22:00:00.000Z", + "updatedAt": "2026-08-05T22:00:00.000Z" }, { "id": "evt-046", @@ -1698,8 +1698,8 @@ "id": "evt-048", "title": "Career Center Kickoff: Resume Lab", "description": "Bring a draft, leave with a resume that survives a recruiter skim. Walk-in reviews all afternoon.", - "startTime": "2026-08-12T17:00:00.000Z", - "endTime": "2026-08-12T21:00:00.000Z", + "startTime": "2026-08-13T17:00:00.000Z", + "endTime": "2026-08-13T21:00:00.000Z", "location": "Career & Professional Development Center", "categories": [ "Career" @@ -1726,15 +1726,15 @@ "resume", "jobs" ], - "createdAt": "2026-07-22T17:00:00.000Z", - "updatedAt": "2026-08-05T17:00:00.000Z" + "createdAt": "2026-07-23T17:00:00.000Z", + "updatedAt": "2026-08-06T17:00:00.000Z" }, { "id": "evt-current-001", "title": "Morning Yoga Session", "description": "Start your day with a relaxing yoga session", - "startTime": "2026-08-12T12:00:00.000Z", - "endTime": "2026-08-12T13:00:00.000Z", + "startTime": "2026-08-13T12:00:00.000Z", + "endTime": "2026-08-13T13:00:00.000Z", "location": "UC Gym", "categories": [ "Wellness", @@ -1762,8 +1762,8 @@ "wellness", "morning" ], - "createdAt": "2026-07-22T12:00:00.000Z", - "updatedAt": "2026-08-05T12:00:00.000Z" + "createdAt": "2026-07-23T12:00:00.000Z", + "updatedAt": "2026-08-06T12:00:00.000Z" }, { "id": "evt-current-002", @@ -1841,8 +1841,8 @@ "id": "evt-current-004", "title": "Tech Talk: AI in Healthcare", "description": "Guest speaker from Google discussing AI applications in healthcare", - "startTime": "2026-08-13T21:00:00.000Z", - "endTime": "2026-08-13T22:30:00.000Z", + "startTime": "2026-08-14T21:00:00.000Z", + "endTime": "2026-08-14T22:30:00.000Z", "location": "Gates Hillman Center", "categories": [ "Tech", @@ -1871,15 +1871,15 @@ "healthcare", "talk" ], - "createdAt": "2026-07-23T21:00:00.000Z", - "updatedAt": "2026-08-06T21:00:00.000Z" + "createdAt": "2026-07-24T21:00:00.000Z", + "updatedAt": "2026-08-07T21:00:00.000Z" }, { "id": "evt-current-005", "title": "Coffee & Code", "description": "Casual coding session at Entropy. Work on projects or just hang out!", - "startTime": "2026-08-13T14:00:00.000Z", - "endTime": "2026-08-13T16:00:00.000Z", + "startTime": "2026-08-14T14:00:00.000Z", + "endTime": "2026-08-14T16:00:00.000Z", "location": "Entropy Coffee", "categories": [ "Social", @@ -1907,8 +1907,8 @@ "coding", "social" ], - "createdAt": "2026-07-23T14:00:00.000Z", - "updatedAt": "2026-08-06T14:00:00.000Z" + "createdAt": "2026-07-24T14:00:00.000Z", + "updatedAt": "2026-08-07T14:00:00.000Z" }, { "id": "evt-current-006", @@ -1937,7 +1937,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": true, - "capacity": 10, + "capacity": 13, "tags": [ "poker", "games", @@ -1986,8 +1986,8 @@ "id": "evt-current-008", "title": "Lunch & Learn: Entrepreneurship", "description": "Free lunch and discussion about starting your own company", - "startTime": "2026-08-14T16:00:00.000Z", - "endTime": "2026-08-14T17:30:00.000Z", + "startTime": "2026-08-15T16:00:00.000Z", + "endTime": "2026-08-15T17:30:00.000Z", "location": "Tepper Quad", "categories": [ "Career", @@ -2009,21 +2009,21 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 60, + "capacity": 98, "tags": [ "entrepreneurship", "lunch", "career" ], - "createdAt": "2026-07-24T16:00:00.000Z", - "updatedAt": "2026-08-07T16:00:00.000Z" + "createdAt": "2026-07-25T16:00:00.000Z", + "updatedAt": "2026-08-08T16:00:00.000Z" }, { "id": "evt-current-009", "title": "Movie Night: The Social Network", "description": "Watch The Social Network with free popcorn!", - "startTime": "2026-08-14T23:00:00.000Z", - "endTime": "2026-08-15T01:30:00.000Z", + "startTime": "2026-08-15T23:00:00.000Z", + "endTime": "2026-08-16T01:30:00.000Z", "location": "McConomy Auditorium", "categories": [ "Entertainment", @@ -2051,8 +2051,8 @@ "social", "entertainment" ], - "createdAt": "2026-07-24T23:00:00.000Z", - "updatedAt": "2026-08-07T23:00:00.000Z" + "createdAt": "2026-07-25T23:00:00.000Z", + "updatedAt": "2026-08-08T23:00:00.000Z" }, { "id": "evt-current-010", @@ -2117,7 +2117,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 60, + "capacity": 68, "tags": [ "study", "exams", @@ -2130,8 +2130,8 @@ "id": "evt-dec-002", "title": "Cookie Decorating Night", "description": "Decorate cookies and take a box home. No baking skills required.", - "startTime": "2026-08-15T19:00:00.000Z", - "endTime": "2026-08-15T21:00:00.000Z", + "startTime": "2026-08-16T19:00:00.000Z", + "endTime": "2026-08-16T21:00:00.000Z", "location": "UC Activities Room", "categories": [ "Social", @@ -2153,20 +2153,20 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 40, + "capacity": 52, "tags": [ "cookies", "social" ], - "createdAt": "2026-07-25T19:00:00.000Z", - "updatedAt": "2026-08-08T19:00:00.000Z" + "createdAt": "2026-07-26T19:00:00.000Z", + "updatedAt": "2026-08-09T19:00:00.000Z" }, { "id": "evt-dec-003", "title": "Alumni Career Networking Mixer", "description": "Network with alumni and recruiters over appetizers.", - "startTime": "2026-08-15T21:00:00.000Z", - "endTime": "2026-08-15T23:30:00.000Z", + "startTime": "2026-08-16T21:00:00.000Z", + "endTime": "2026-08-16T23:30:00.000Z", "location": "Tepper Quad", "categories": [ "Career", @@ -2188,14 +2188,14 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 120, + "capacity": 128, "tags": [ "networking", "career", "alumni" ], - "createdAt": "2026-07-25T21:00:00.000Z", - "updatedAt": "2026-08-08T21:00:00.000Z" + "createdAt": "2026-07-26T21:00:00.000Z", + "updatedAt": "2026-08-09T21:00:00.000Z" }, { "id": "evt-dec-004", @@ -2273,8 +2273,8 @@ "id": "evt-dec-006", "title": "Basketball Tournament", "description": "Intramural basketball tournament - sign up your team!", - "startTime": "2026-08-16T22:00:00.000Z", - "endTime": "2026-08-17T01:00:00.000Z", + "startTime": "2026-08-17T22:00:00.000Z", + "endTime": "2026-08-18T01:00:00.000Z", "location": "Highmark Center", "categories": [ "Sports", @@ -2302,15 +2302,15 @@ "tournament", "sports" ], - "createdAt": "2026-07-26T22:00:00.000Z", - "updatedAt": "2026-08-09T22:00:00.000Z" + "createdAt": "2026-07-27T22:00:00.000Z", + "updatedAt": "2026-08-10T22:00:00.000Z" }, { "id": "evt-dec-007", "title": "Movie Marathon Night", "description": "Back-to-back classics with hot chocolate and popcorn.", - "startTime": "2026-08-16T23:00:00.000Z", - "endTime": "2026-08-17T03:00:00.000Z", + "startTime": "2026-08-17T23:00:00.000Z", + "endTime": "2026-08-18T03:00:00.000Z", "location": "McConomy Auditorium", "categories": [ "Entertainment", @@ -2337,8 +2337,8 @@ "movies", "social" ], - "createdAt": "2026-07-26T23:00:00.000Z", - "updatedAt": "2026-08-09T23:00:00.000Z" + "createdAt": "2026-07-27T23:00:00.000Z", + "updatedAt": "2026-08-10T23:00:00.000Z" }, { "id": "evt-dec-008", @@ -2367,7 +2367,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": false, - "capacity": 35, + "capacity": 70, "tags": [ "projects", "help", @@ -2415,8 +2415,8 @@ "id": "evt-dec-010", "title": "Paper Craft Workshop", "description": "Learn folding and wrapping techniques. Materials provided.", - "startTime": "2026-08-17T18:00:00.000Z", - "endTime": "2026-08-17T20:00:00.000Z", + "startTime": "2026-08-18T18:00:00.000Z", + "endTime": "2026-08-18T20:00:00.000Z", "location": "Hunt Library Study Room", "categories": [ "Social", @@ -2443,15 +2443,15 @@ "gifts", "workshop" ], - "createdAt": "2026-07-27T18:00:00.000Z", - "updatedAt": "2026-08-10T18:00:00.000Z" + "createdAt": "2026-07-28T18:00:00.000Z", + "updatedAt": "2026-08-11T18:00:00.000Z" }, { "id": "evt-dec-011", "title": "Break & Travel Planning Session", "description": "Compare travel plans, internships, and break activities.", - "startTime": "2026-08-17T15:00:00.000Z", - "endTime": "2026-08-17T16:30:00.000Z", + "startTime": "2026-08-18T15:00:00.000Z", + "endTime": "2026-08-18T16:30:00.000Z", "location": "Tepper Quad", "categories": [ "Social", @@ -2473,14 +2473,14 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 50, + "capacity": 58, "tags": [ "planning", "break", "social" ], - "createdAt": "2026-07-27T15:00:00.000Z", - "updatedAt": "2026-08-10T15:00:00.000Z" + "createdAt": "2026-07-28T15:00:00.000Z", + "updatedAt": "2026-08-11T15:00:00.000Z" }, { "id": "evt-dec-012", @@ -2509,7 +2509,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": true, - "capacity": 40, + "capacity": 62, "tags": [ "study", "late-night" @@ -2556,8 +2556,8 @@ "id": "evt-dec-014", "title": "End of Semester Celebration", "description": "Celebrate finishing the semester! Food, music, and fun", - "startTime": "2026-08-18T21:00:00.000Z", - "endTime": "2026-08-19T00:00:00.000Z", + "startTime": "2026-08-19T21:00:00.000Z", + "endTime": "2026-08-20T00:00:00.000Z", "location": "Wiegand Gymnasium", "categories": [ "Social", @@ -2585,15 +2585,15 @@ "semester", "social" ], - "createdAt": "2026-07-28T21:00:00.000Z", - "updatedAt": "2026-08-11T21:00:00.000Z" + "createdAt": "2026-07-29T21:00:00.000Z", + "updatedAt": "2026-08-12T21:00:00.000Z" }, { "id": "evt-dec-015", "title": "AI & Machine Learning Workshop", "description": "Hands-on workshop on building neural networks and ML models", - "startTime": "2026-08-18T17:00:00.000Z", - "endTime": "2026-08-18T20:00:00.000Z", + "startTime": "2026-08-19T17:00:00.000Z", + "endTime": "2026-08-19T20:00:00.000Z", "location": "Gates Hillman Center 4401", "categories": [ "Tech", @@ -2615,7 +2615,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 100, + "capacity": 131, "tags": [ "ai", "machine-learning", @@ -2623,8 +2623,8 @@ "tech", "workshop" ], - "createdAt": "2026-07-28T17:00:00.000Z", - "updatedAt": "2026-08-11T17:00:00.000Z" + "createdAt": "2026-07-29T17:00:00.000Z", + "updatedAt": "2026-08-12T17:00:00.000Z" }, { "id": "evt-dec-016", @@ -2653,7 +2653,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 50, + "capacity": 71, "tags": [ "yoga", "meditation", @@ -2691,7 +2691,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": true, - "capacity": 40, + "capacity": 50, "tags": [ "photography", "arts", @@ -2706,8 +2706,8 @@ "id": "evt-dec-018", "title": "Entrepreneurship Pitch Night", "description": "Students pitch startup ideas to judges and investors", - "startTime": "2026-08-19T23:00:00.000Z", - "endTime": "2026-08-20T02:00:00.000Z", + "startTime": "2026-08-20T23:00:00.000Z", + "endTime": "2026-08-21T02:00:00.000Z", "location": "Tepper School of Business", "categories": [ "Career", @@ -2729,7 +2729,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 150, + "capacity": 157, "tags": [ "entrepreneurship", "startup", @@ -2737,15 +2737,15 @@ "business", "networking" ], - "createdAt": "2026-07-29T23:00:00.000Z", - "updatedAt": "2026-08-12T23:00:00.000Z" + "createdAt": "2026-07-30T23:00:00.000Z", + "updatedAt": "2026-08-13T23:00:00.000Z" }, { "id": "evt-dec-019", "title": "Board Game Night", "description": "Play board games, card games, and tabletop RPGs", - "startTime": "2026-08-19T23:00:00.000Z", - "endTime": "2026-08-20T03:00:00.000Z", + "startTime": "2026-08-20T23:00:00.000Z", + "endTime": "2026-08-21T03:00:00.000Z", "location": "UC Game Room", "categories": [ "Social", @@ -2767,7 +2767,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 80, + "capacity": 103, "tags": [ "gaming", "board-games", @@ -2775,8 +2775,8 @@ "fun", "tabletop" ], - "createdAt": "2026-07-29T23:00:00.000Z", - "updatedAt": "2026-08-12T23:00:00.000Z" + "createdAt": "2026-07-30T23:00:00.000Z", + "updatedAt": "2026-08-13T23:00:00.000Z" }, { "id": "evt-dec-020", @@ -2805,7 +2805,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 50, + "capacity": 60, "tags": [ "cooking", "baking", @@ -2857,8 +2857,8 @@ "id": "evt-dec-022", "title": "Robotics Demo Day", "description": "See student-built robots in action and learn about robotics", - "startTime": "2026-08-20T19:00:00.000Z", - "endTime": "2026-08-20T22:00:00.000Z", + "startTime": "2026-08-21T19:00:00.000Z", + "endTime": "2026-08-21T22:00:00.000Z", "location": "Newell-Simon Hall", "categories": [ "Tech", @@ -2888,15 +2888,15 @@ "demos", "hands-on" ], - "createdAt": "2026-07-30T19:00:00.000Z", - "updatedAt": "2026-08-13T19:00:00.000Z" + "createdAt": "2026-07-31T19:00:00.000Z", + "updatedAt": "2026-08-14T19:00:00.000Z" }, { "id": "evt-dec-023", "title": "Language Exchange Meetup", "description": "Practice languages with native speakers - Spanish, French, Chinese, Japanese, and more", - "startTime": "2026-08-20T20:00:00.000Z", - "endTime": "2026-08-20T22:00:00.000Z", + "startTime": "2026-08-21T20:00:00.000Z", + "endTime": "2026-08-21T22:00:00.000Z", "location": "Hunt Library Study Room 3", "categories": [ "Social", @@ -2918,7 +2918,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 60, + "capacity": 81, "tags": [ "language", "exchange", @@ -2926,8 +2926,8 @@ "social", "learning" ], - "createdAt": "2026-07-30T20:00:00.000Z", - "updatedAt": "2026-08-13T20:00:00.000Z" + "createdAt": "2026-07-31T20:00:00.000Z", + "updatedAt": "2026-08-14T20:00:00.000Z" }, { "id": "evt-dec-024", @@ -2994,7 +2994,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 100, + "capacity": 105, "tags": [ "volunteering", "community", @@ -3009,8 +3009,8 @@ "id": "evt-dec-026", "title": "Hackathon Kickoff", "description": "24-hour coding competition - build something awesome!", - "startTime": "2026-08-21T14:00:00.000Z", - "endTime": "2026-08-22T14:00:00.000Z", + "startTime": "2026-08-22T14:00:00.000Z", + "endTime": "2026-08-23T14:00:00.000Z", "location": "Gates Hillman Center", "categories": [ "Tech", @@ -3032,7 +3032,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 200, + "capacity": 223, "tags": [ "hackathon", "coding", @@ -3040,15 +3040,15 @@ "competition", "programming" ], - "createdAt": "2026-07-31T14:00:00.000Z", - "updatedAt": "2026-08-14T14:00:00.000Z" + "createdAt": "2026-08-01T14:00:00.000Z", + "updatedAt": "2026-08-15T14:00:00.000Z" }, { "id": "evt-dec-027", "title": "Poetry Slam", "description": "Share original poetry or just listen to amazing performances", - "startTime": "2026-08-21T23:00:00.000Z", - "endTime": "2026-08-22T01:30:00.000Z", + "startTime": "2026-08-22T23:00:00.000Z", + "endTime": "2026-08-23T01:30:00.000Z", "location": "UC Coffeehouse", "categories": [ "Arts", @@ -3070,7 +3070,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 50, + "capacity": 62, "tags": [ "poetry", "arts", @@ -3078,8 +3078,8 @@ "creative", "spoken-word" ], - "createdAt": "2026-07-31T23:00:00.000Z", - "updatedAt": "2026-08-14T23:00:00.000Z" + "createdAt": "2026-08-01T23:00:00.000Z", + "updatedAt": "2026-08-15T23:00:00.000Z" }, { "id": "evt-dec-028", @@ -3108,7 +3108,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 80, + "capacity": 95, "tags": [ "sustainability", "environment", @@ -3146,7 +3146,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 60, + "capacity": 92, "tags": [ "dance", "hip-hop", @@ -3161,8 +3161,8 @@ "id": "evt-dec-030", "title": "Music Jam Session", "description": "Bring your instrument and jam with other musicians", - "startTime": "2026-08-23T00:00:00.000Z", - "endTime": "2026-08-23T03:00:00.000Z", + "startTime": "2026-08-24T00:00:00.000Z", + "endTime": "2026-08-24T03:00:00.000Z", "location": "UC Music Room", "categories": [ "Arts", @@ -3184,7 +3184,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 50, + "capacity": 64, "tags": [ "music", "jam", @@ -3192,15 +3192,15 @@ "arts", "collaboration" ], - "createdAt": "2026-08-02T00:00:00.000Z", - "updatedAt": "2026-08-16T00:00:00.000Z" + "createdAt": "2026-08-03T00:00:00.000Z", + "updatedAt": "2026-08-17T00:00:00.000Z" }, { "id": "evt-dec-031", "title": "Cybersecurity Workshop", "description": "Learn about encryption, network security, and ethical hacking", - "startTime": "2026-08-22T18:00:00.000Z", - "endTime": "2026-08-22T21:00:00.000Z", + "startTime": "2026-08-23T18:00:00.000Z", + "endTime": "2026-08-23T21:00:00.000Z", "location": "Gates Hillman Center 4307", "categories": [ "Tech", @@ -3222,7 +3222,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 90, + "capacity": 107, "tags": [ "cybersecurity", "security", @@ -3230,8 +3230,8 @@ "hacking", "encryption" ], - "createdAt": "2026-08-01T18:00:00.000Z", - "updatedAt": "2026-08-15T18:00:00.000Z" + "createdAt": "2026-08-02T18:00:00.000Z", + "updatedAt": "2026-08-16T18:00:00.000Z" }, { "id": "evt-dec-032", @@ -3260,7 +3260,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 70, + "capacity": 117, "tags": [ "fitness", "workout", @@ -3275,8 +3275,8 @@ "id": "evt-dec-033", "title": "Travel Planning Session", "description": "Share travel tips, plan trips, and learn about study abroad programs", - "startTime": "2026-08-23T20:00:00.000Z", - "endTime": "2026-08-23T22:00:00.000Z", + "startTime": "2026-08-24T20:00:00.000Z", + "endTime": "2026-08-24T22:00:00.000Z", "location": "Hunt Library Study Room 2", "categories": [ "Social", @@ -3298,7 +3298,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 60, + "capacity": 68, "tags": [ "travel", "study-abroad", @@ -3306,15 +3306,15 @@ "planning", "networking" ], - "createdAt": "2026-08-02T20:00:00.000Z", - "updatedAt": "2026-08-16T20:00:00.000Z" + "createdAt": "2026-08-03T20:00:00.000Z", + "updatedAt": "2026-08-17T20:00:00.000Z" }, { "id": "evt-dec-034", "title": "Video Game Tournament", "description": "Competitive gaming tournament - multiple games and prizes", - "startTime": "2026-08-23T23:00:00.000Z", - "endTime": "2026-08-24T03:00:00.000Z", + "startTime": "2026-08-24T23:00:00.000Z", + "endTime": "2026-08-25T03:00:00.000Z", "location": "UC Game Room", "categories": [ "Social", @@ -3336,7 +3336,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 120, + "capacity": 138, "tags": [ "gaming", "esports", @@ -3344,15 +3344,15 @@ "competition", "video-games" ], - "createdAt": "2026-08-02T23:00:00.000Z", - "updatedAt": "2026-08-16T23:00:00.000Z" + "createdAt": "2026-08-03T23:00:00.000Z", + "updatedAt": "2026-08-17T23:00:00.000Z" }, { "id": "evt-dec-035", "title": "Web Development Bootcamp", "description": "Learn React, Node.js, and modern web development practices", - "startTime": "2026-08-23T14:00:00.000Z", - "endTime": "2026-08-23T19:00:00.000Z", + "startTime": "2026-08-24T14:00:00.000Z", + "endTime": "2026-08-24T19:00:00.000Z", "location": "Gates Hillman Center 4405", "categories": [ "Tech", @@ -3374,7 +3374,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 110, + "capacity": 167, "tags": [ "web-dev", "react", @@ -3382,8 +3382,8 @@ "coding", "frontend" ], - "createdAt": "2026-08-02T14:00:00.000Z", - "updatedAt": "2026-08-16T14:00:00.000Z" + "createdAt": "2026-08-03T14:00:00.000Z", + "updatedAt": "2026-08-17T14:00:00.000Z" }, { "id": "evt-dec-036", @@ -3412,7 +3412,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 100, + "capacity": 127, "tags": [ "karaoke", "music", @@ -3427,8 +3427,8 @@ "id": "evt-dec-037", "title": "Data Science Workshop", "description": "Introduction to Python, pandas, and data visualization", - "startTime": "2026-08-24T17:00:00.000Z", - "endTime": "2026-08-24T20:00:00.000Z", + "startTime": "2026-08-25T17:00:00.000Z", + "endTime": "2026-08-25T20:00:00.000Z", "location": "Gates Hillman Center 4303", "categories": [ "Tech", @@ -3450,7 +3450,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 90, + "capacity": 141, "tags": [ "data-science", "python", @@ -3458,15 +3458,15 @@ "analytics", "statistics" ], - "createdAt": "2026-08-03T17:00:00.000Z", - "updatedAt": "2026-08-17T17:00:00.000Z" + "createdAt": "2026-08-04T17:00:00.000Z", + "updatedAt": "2026-08-18T17:00:00.000Z" }, { "id": "evt-dec-038", "title": "Rock Climbing Session", "description": "Indoor rock climbing for all experience levels", - "startTime": "2026-08-24T22:00:00.000Z", - "endTime": "2026-08-25T00:00:00.000Z", + "startTime": "2026-08-25T22:00:00.000Z", + "endTime": "2026-08-26T00:00:00.000Z", "location": "UC Fitness Center Climbing Wall", "categories": [ "Sports", @@ -3488,7 +3488,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 60, + "capacity": 79, "tags": [ "rock-climbing", "sports", @@ -3496,15 +3496,15 @@ "adventure", "outdoor" ], - "createdAt": "2026-08-03T22:00:00.000Z", - "updatedAt": "2026-08-17T22:00:00.000Z" + "createdAt": "2026-08-04T22:00:00.000Z", + "updatedAt": "2026-08-18T22:00:00.000Z" }, { "id": "evt-dec-039", "title": "Creative Writing Workshop", "description": "Improve your writing skills with exercises and peer feedback", - "startTime": "2026-08-24T14:00:00.000Z", - "endTime": "2026-08-24T16:30:00.000Z", + "startTime": "2026-08-25T14:00:00.000Z", + "endTime": "2026-08-25T16:30:00.000Z", "location": "Hunt Library Study Room 1", "categories": [ "Arts", @@ -3526,7 +3526,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 45, + "capacity": 69, "tags": [ "writing", "creative", @@ -3534,8 +3534,8 @@ "arts", "storytelling" ], - "createdAt": "2026-08-03T14:00:00.000Z", - "updatedAt": "2026-08-17T14:00:00.000Z" + "createdAt": "2026-08-04T14:00:00.000Z", + "updatedAt": "2026-08-18T14:00:00.000Z" }, { "id": "evt-dec-040", @@ -3564,7 +3564,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 40, + "capacity": 74, "tags": [ "basketball", "sports", @@ -3579,8 +3579,8 @@ "id": "evt-dec-041", "title": "3D Printing Workshop", "description": "Learn to design and print 3D objects", - "startTime": "2026-08-25T15:00:00.000Z", - "endTime": "2026-08-25T18:00:00.000Z", + "startTime": "2026-08-26T15:00:00.000Z", + "endTime": "2026-08-26T18:00:00.000Z", "location": "Hunt Library Maker Space", "categories": [ "Tech", @@ -3602,7 +3602,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 50, + "capacity": 58, "tags": [ "3d-printing", "maker", @@ -3610,15 +3610,15 @@ "tech", "prototyping" ], - "createdAt": "2026-08-04T15:00:00.000Z", - "updatedAt": "2026-08-18T15:00:00.000Z" + "createdAt": "2026-08-05T15:00:00.000Z", + "updatedAt": "2026-08-19T15:00:00.000Z" }, { "id": "evt-dec-042", "title": "Movie Night: Cult Classics", "description": "Watch cult classics with popcorn and hot chocolate.", - "startTime": "2026-08-25T23:00:00.000Z", - "endTime": "2026-08-26T02:30:00.000Z", + "startTime": "2026-08-26T23:00:00.000Z", + "endTime": "2026-08-27T02:30:00.000Z", "location": "UC Theater", "categories": [ "Social", @@ -3640,22 +3640,22 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 150, + "capacity": 157, "tags": [ "movies", "film", "entertainment", "social" ], - "createdAt": "2026-08-04T23:00:00.000Z", - "updatedAt": "2026-08-18T23:00:00.000Z" + "createdAt": "2026-08-05T23:00:00.000Z", + "updatedAt": "2026-08-19T23:00:00.000Z" }, { "id": "evt-dec-043", "title": "Blockchain & Cryptocurrency Talk", "description": "Learn about blockchain technology and cryptocurrency trends", - "startTime": "2026-08-25T14:00:00.000Z", - "endTime": "2026-08-25T16:00:00.000Z", + "startTime": "2026-08-26T14:00:00.000Z", + "endTime": "2026-08-26T16:00:00.000Z", "location": "Tepper School of Business", "categories": [ "Tech", @@ -3677,7 +3677,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 85, + "capacity": 107, "tags": [ "blockchain", "cryptocurrency", @@ -3685,8 +3685,8 @@ "tech", "finance" ], - "createdAt": "2026-08-04T14:00:00.000Z", - "updatedAt": "2026-08-18T14:00:00.000Z" + "createdAt": "2026-08-05T14:00:00.000Z", + "updatedAt": "2026-08-19T14:00:00.000Z" }, { "id": "evt-dec-044", @@ -3715,7 +3715,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": true, - "capacity": 35, + "capacity": 53, "tags": [ "origami", "arts", @@ -3730,8 +3730,8 @@ "id": "evt-dec-045", "title": "Speed Networking Event", "description": "Meet professionals and students in quick networking sessions", - "startTime": "2026-08-26T17:00:00.000Z", - "endTime": "2026-08-26T19:30:00.000Z", + "startTime": "2026-08-27T17:00:00.000Z", + "endTime": "2026-08-27T19:30:00.000Z", "location": "Tepper Quad", "categories": [ "Career", @@ -3753,7 +3753,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 160, + "capacity": 178, "tags": [ "networking", "career", @@ -3761,15 +3761,15 @@ "connections", "business" ], - "createdAt": "2026-08-05T17:00:00.000Z", - "updatedAt": "2026-08-19T17:00:00.000Z" + "createdAt": "2026-08-06T17:00:00.000Z", + "updatedAt": "2026-08-20T17:00:00.000Z" }, { "id": "evt-dec-046", "title": "Chess Tournament", "description": "Competitive chess tournament with prizes for winners", - "startTime": "2026-08-26T18:00:00.000Z", - "endTime": "2026-08-26T22:00:00.000Z", + "startTime": "2026-08-27T18:00:00.000Z", + "endTime": "2026-08-27T22:00:00.000Z", "location": "UC Game Room", "categories": [ "Social", @@ -3791,7 +3791,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 70, + "capacity": 99, "tags": [ "chess", "strategy", @@ -3799,15 +3799,15 @@ "games", "intellectual" ], - "createdAt": "2026-08-05T18:00:00.000Z", - "updatedAt": "2026-08-19T18:00:00.000Z" + "createdAt": "2026-08-06T18:00:00.000Z", + "updatedAt": "2026-08-20T18:00:00.000Z" }, { "id": "evt-dec-047", "title": "Podcast Recording Session", "description": "Record episodes for the student podcast - guests welcome", - "startTime": "2026-08-26T15:00:00.000Z", - "endTime": "2026-08-26T17:00:00.000Z", + "startTime": "2026-08-27T15:00:00.000Z", + "endTime": "2026-08-27T17:00:00.000Z", "location": "Hunt Library Media Lab", "categories": [ "Arts", @@ -3829,7 +3829,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 30, + "capacity": 39, "tags": [ "podcast", "media", @@ -3837,8 +3837,8 @@ "audio", "broadcasting" ], - "createdAt": "2026-08-05T15:00:00.000Z", - "updatedAt": "2026-08-19T15:00:00.000Z" + "createdAt": "2026-08-06T15:00:00.000Z", + "updatedAt": "2026-08-20T15:00:00.000Z" }, { "id": "evt-dec-048", @@ -3867,7 +3867,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 44, + "capacity": 108, "tags": [ "soccer", "football", @@ -3882,8 +3882,8 @@ "id": "evt-dec-049", "title": "Mental Health Support Group", "description": "Safe space to discuss stress, anxiety, and mental wellness", - "startTime": "2026-08-27T23:00:00.000Z", - "endTime": "2026-08-28T00:30:00.000Z", + "startTime": "2026-08-28T23:00:00.000Z", + "endTime": "2026-08-29T00:30:00.000Z", "location": "UC Counseling Center", "categories": [ "Wellness", @@ -3905,7 +3905,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 25, + "capacity": 38, "tags": [ "mental-health", "wellness", @@ -3913,15 +3913,15 @@ "self-care", "therapy" ], - "createdAt": "2026-08-06T23:00:00.000Z", - "updatedAt": "2026-08-20T23:00:00.000Z" + "createdAt": "2026-08-07T23:00:00.000Z", + "updatedAt": "2026-08-21T23:00:00.000Z" }, { "id": "evt-dec-050", "title": "Virtual Reality Demo", "description": "Try VR headsets and experience immersive technology", - "startTime": "2026-08-27T19:00:00.000Z", - "endTime": "2026-08-27T22:00:00.000Z", + "startTime": "2026-08-28T19:00:00.000Z", + "endTime": "2026-08-28T22:00:00.000Z", "location": "Gates Hillman Center VR Lab", "categories": [ "Tech", @@ -3943,7 +3943,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 90, + "capacity": 136, "tags": [ "vr", "virtual-reality", @@ -3951,15 +3951,15 @@ "tech", "immersive" ], - "createdAt": "2026-08-06T19:00:00.000Z", - "updatedAt": "2026-08-20T19:00:00.000Z" + "createdAt": "2026-08-07T19:00:00.000Z", + "updatedAt": "2026-08-21T19:00:00.000Z" }, { "id": "evt-dec-051", "title": "Comedy Night", "description": "Stand-up comedy performances by students", - "startTime": "2026-08-28T00:00:00.000Z", - "endTime": "2026-08-28T02:00:00.000Z", + "startTime": "2026-08-29T00:00:00.000Z", + "endTime": "2026-08-29T02:00:00.000Z", "location": "UC Coffeehouse", "categories": [ "Arts", @@ -3981,7 +3981,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 110, + "capacity": 130, "tags": [ "comedy", "stand-up", @@ -3989,8 +3989,8 @@ "humor", "performance" ], - "createdAt": "2026-08-07T00:00:00.000Z", - "updatedAt": "2026-08-21T00:00:00.000Z" + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-22T00:00:00.000Z" }, { "id": "evt-dec-052", @@ -4019,7 +4019,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 60, + "capacity": 66, "tags": [ "investment", "finance", @@ -4034,8 +4034,8 @@ "id": "evt-dec-053", "title": "Tennis Tournament", "description": "Singles and doubles tennis tournament", - "startTime": "2026-08-28T13:00:00.000Z", - "endTime": "2026-08-28T21:00:00.000Z", + "startTime": "2026-08-29T13:00:00.000Z", + "endTime": "2026-08-29T21:00:00.000Z", "location": "Tennis Courts", "categories": [ "Sports", @@ -4057,7 +4057,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 32, + "capacity": 53, "tags": [ "tennis", "sports", @@ -4065,15 +4065,15 @@ "outdoor", "fitness" ], - "createdAt": "2026-08-07T13:00:00.000Z", - "updatedAt": "2026-08-21T13:00:00.000Z" + "createdAt": "2026-08-08T13:00:00.000Z", + "updatedAt": "2026-08-22T13:00:00.000Z" }, { "id": "evt-dec-054", "title": "Study Abroad Info Session", "description": "Learn about study abroad opportunities and application process", - "startTime": "2026-08-28T18:00:00.000Z", - "endTime": "2026-08-28T20:00:00.000Z", + "startTime": "2026-08-29T18:00:00.000Z", + "endTime": "2026-08-29T20:00:00.000Z", "location": "Baker Hall", "categories": [ "Academic", @@ -4095,7 +4095,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 100, + "capacity": 117, "tags": [ "study-abroad", "travel", @@ -4103,15 +4103,15 @@ "international", "academic" ], - "createdAt": "2026-08-07T18:00:00.000Z", - "updatedAt": "2026-08-21T18:00:00.000Z" + "createdAt": "2026-08-08T18:00:00.000Z", + "updatedAt": "2026-08-22T18:00:00.000Z" }, { "id": "evt-dec-055", "title": "Afternoon Coding Workshop", "description": "Build a web app from scratch - React and Node.js", - "startTime": "2026-08-28T19:00:00.000Z", - "endTime": "2026-08-28T21:00:00.000Z", + "startTime": "2026-08-29T19:00:00.000Z", + "endTime": "2026-08-29T21:00:00.000Z", "location": "Gates Hillman Center 4401", "categories": [ "Tech", @@ -4133,7 +4133,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 80, + "capacity": 110, "tags": [ "coding", "react", @@ -4141,8 +4141,8 @@ "workshop", "tech" ], - "createdAt": "2026-08-07T19:00:00.000Z", - "updatedAt": "2026-08-21T19:00:00.000Z" + "createdAt": "2026-08-08T19:00:00.000Z", + "updatedAt": "2026-08-22T19:00:00.000Z" }, { "id": "evt-dec-056", @@ -4171,7 +4171,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 50, + "capacity": 61, "tags": [ "study", "coffee", @@ -4185,8 +4185,8 @@ "id": "evt-dec-057", "title": "Guitar Lessons", "description": "Learn basic guitar chords and strumming patterns", - "startTime": "2026-08-29T20:00:00.000Z", - "endTime": "2026-08-29T22:00:00.000Z", + "startTime": "2026-08-30T20:00:00.000Z", + "endTime": "2026-08-30T22:00:00.000Z", "location": "UC Music Room", "categories": [ "Arts", @@ -4208,7 +4208,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 30, + "capacity": 31, "tags": [ "guitar", "music", @@ -4216,15 +4216,15 @@ "arts", "learning" ], - "createdAt": "2026-08-08T20:00:00.000Z", - "updatedAt": "2026-08-22T20:00:00.000Z" + "createdAt": "2026-08-09T20:00:00.000Z", + "updatedAt": "2026-08-23T20:00:00.000Z" }, { "id": "evt-dec-058", "title": "Product Design Workshop", "description": "Learn UX/UI design principles and create mockups", - "startTime": "2026-08-29T20:30:00.000Z", - "endTime": "2026-08-29T22:30:00.000Z", + "startTime": "2026-08-30T20:30:00.000Z", + "endTime": "2026-08-30T22:30:00.000Z", "location": "Hunt Library Design Lab", "categories": [ "Tech", @@ -4246,7 +4246,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 55, + "capacity": 71, "tags": [ "design", "ux", @@ -4254,15 +4254,15 @@ "product", "creative" ], - "createdAt": "2026-08-08T20:30:00.000Z", - "updatedAt": "2026-08-22T20:30:00.000Z" + "createdAt": "2026-08-09T20:30:00.000Z", + "updatedAt": "2026-08-23T20:30:00.000Z" }, { "id": "evt-dec-059", "title": "Basketball Practice", "description": "Team practice and drills", - "startTime": "2026-08-29T21:00:00.000Z", - "endTime": "2026-08-29T23:00:00.000Z", + "startTime": "2026-08-30T21:00:00.000Z", + "endTime": "2026-08-30T23:00:00.000Z", "location": "Wiegand Gymnasium", "categories": [ "Sports", @@ -4284,7 +4284,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 20, + "capacity": 47, "tags": [ "basketball", "sports", @@ -4292,8 +4292,8 @@ "team", "fitness" ], - "createdAt": "2026-08-08T21:00:00.000Z", - "updatedAt": "2026-08-22T21:00:00.000Z" + "createdAt": "2026-08-09T21:00:00.000Z", + "updatedAt": "2026-08-23T21:00:00.000Z" }, { "id": "evt-dec-060", @@ -4322,7 +4322,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 40, + "capacity": 79, "tags": [ "books", "reading", @@ -4337,8 +4337,8 @@ "id": "evt-dec-061", "title": "Python Programming Session", "description": "Advanced Python topics and coding practice", - "startTime": "2026-08-30T20:00:00.000Z", - "endTime": "2026-08-30T22:00:00.000Z", + "startTime": "2026-08-31T20:00:00.000Z", + "endTime": "2026-08-31T22:00:00.000Z", "location": "Gates Hillman Center 4305", "categories": [ "Tech", @@ -4360,7 +4360,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 65, + "capacity": 93, "tags": [ "python", "programming", @@ -4368,15 +4368,15 @@ "tech", "academic" ], - "createdAt": "2026-08-09T20:00:00.000Z", - "updatedAt": "2026-08-23T20:00:00.000Z" + "createdAt": "2026-08-10T20:00:00.000Z", + "updatedAt": "2026-08-24T20:00:00.000Z" }, { "id": "evt-dec-062", "title": "Yoga & Stretching", "description": "Relaxing yoga session to unwind after classes", - "startTime": "2026-08-30T21:30:00.000Z", - "endTime": "2026-08-30T23:00:00.000Z", + "startTime": "2026-08-31T21:30:00.000Z", + "endTime": "2026-08-31T23:00:00.000Z", "location": "UC Fitness Center", "categories": [ "Wellness", @@ -4398,7 +4398,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 50, + "capacity": 93, "tags": [ "yoga", "wellness", @@ -4406,15 +4406,15 @@ "relaxation", "health" ], - "createdAt": "2026-08-09T21:30:00.000Z", - "updatedAt": "2026-08-23T21:30:00.000Z" + "createdAt": "2026-08-10T21:30:00.000Z", + "updatedAt": "2026-08-24T21:30:00.000Z" }, { "id": "evt-dec-063", "title": "Resume Review Session", "description": "Get feedback on your resume from career advisors", - "startTime": "2026-08-30T19:00:00.000Z", - "endTime": "2026-08-30T21:00:00.000Z", + "startTime": "2026-08-31T19:00:00.000Z", + "endTime": "2026-08-31T21:00:00.000Z", "location": "Tepper School of Business", "categories": [ "Career", @@ -4436,7 +4436,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 90, + "capacity": 102, "tags": [ "resume", "career", @@ -4444,8 +4444,8 @@ "professional", "advice" ], - "createdAt": "2026-08-09T19:00:00.000Z", - "updatedAt": "2026-08-23T19:00:00.000Z" + "createdAt": "2026-08-10T19:00:00.000Z", + "updatedAt": "2026-08-24T19:00:00.000Z" }, { "id": "evt-dec-064", @@ -4474,7 +4474,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 8, + "capacity": 62, "tags": [ "dnd", "dungeons-dragons", @@ -4489,8 +4489,8 @@ "id": "evt-dec-065", "title": "Machine Learning Study Group", "description": "Review ML concepts and work on projects together", - "startTime": "2026-08-31T19:30:00.000Z", - "endTime": "2026-08-31T21:30:00.000Z", + "startTime": "2026-09-01T19:30:00.000Z", + "endTime": "2026-09-01T21:30:00.000Z", "location": "Gates Hillman Center 4403", "categories": [ "Tech", @@ -4512,7 +4512,7 @@ "attendeeVisibility": "public", "isClubEvent": false, "isSocialEvent": false, - "capacity": 45, + "capacity": 62, "tags": [ "machine-learning", "ml", @@ -4520,15 +4520,15 @@ "study", "academic" ], - "createdAt": "2026-08-10T19:30:00.000Z", - "updatedAt": "2026-08-24T19:30:00.000Z" + "createdAt": "2026-08-11T19:30:00.000Z", + "updatedAt": "2026-08-25T19:30:00.000Z" }, { "id": "evt-dec-066", "title": "Pottery Workshop", "description": "Create ceramic pieces - materials provided", - "startTime": "2026-08-31T21:00:00.000Z", - "endTime": "2026-08-31T23:00:00.000Z", + "startTime": "2026-09-01T21:00:00.000Z", + "endTime": "2026-09-01T23:00:00.000Z", "location": "UC Arts & Crafts Room", "categories": [ "Arts", @@ -4550,7 +4550,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 35, + "capacity": 50, "tags": [ "pottery", "ceramics", @@ -4558,15 +4558,15 @@ "crafts", "creative" ], - "createdAt": "2026-08-10T21:00:00.000Z", - "updatedAt": "2026-08-24T21:00:00.000Z" + "createdAt": "2026-08-11T21:00:00.000Z", + "updatedAt": "2026-08-25T21:00:00.000Z" }, { "id": "evt-dec-067", "title": "Financial Planning Workshop", "description": "Learn about budgeting, investing, and financial literacy", - "startTime": "2026-08-31T20:30:00.000Z", - "endTime": "2026-08-31T22:00:00.000Z", + "startTime": "2026-09-01T20:30:00.000Z", + "endTime": "2026-09-01T22:00:00.000Z", "location": "Tepper School of Business", "categories": [ "Career", @@ -4588,7 +4588,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 70, + "capacity": 81, "tags": [ "finance", "money", @@ -4596,8 +4596,8 @@ "budgeting", "career" ], - "createdAt": "2026-08-10T20:30:00.000Z", - "updatedAt": "2026-08-24T20:30:00.000Z" + "createdAt": "2026-08-11T20:30:00.000Z", + "updatedAt": "2026-08-25T20:30:00.000Z" }, { "id": "evt-dec-068", @@ -4626,7 +4626,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 40, + "capacity": 49, "tags": [ "salsa", "dancing", @@ -4641,8 +4641,8 @@ "id": "evt-dec-069", "title": "Watercolor Painting Workshop", "description": "Learn watercolor techniques and create your own masterpiece", - "startTime": "2026-09-01T18:00:00.000Z", - "endTime": "2026-09-01T20:00:00.000Z", + "startTime": "2026-09-02T18:00:00.000Z", + "endTime": "2026-09-02T20:00:00.000Z", "location": "UC Arts & Crafts Room", "categories": [ "Arts", @@ -4664,7 +4664,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 35, + "capacity": 44, "tags": [ "watercolor", "painting", @@ -4672,15 +4672,15 @@ "creative", "workshop" ], - "createdAt": "2026-08-11T18:00:00.000Z", - "updatedAt": "2026-08-25T18:00:00.000Z" + "createdAt": "2026-08-12T18:00:00.000Z", + "updatedAt": "2026-08-26T18:00:00.000Z" }, { "id": "evt-dec-070", "title": "Digital Art & Illustration Class", "description": "Learn digital art techniques using tablets and software", - "startTime": "2026-09-01T19:00:00.000Z", - "endTime": "2026-09-01T21:00:00.000Z", + "startTime": "2026-09-02T19:00:00.000Z", + "endTime": "2026-09-02T21:00:00.000Z", "location": "Hunt Library Design Lab", "categories": [ "Arts", @@ -4702,7 +4702,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": false, - "capacity": 50, + "capacity": 62, "tags": [ "digital-art", "illustration", @@ -4710,15 +4710,15 @@ "tech", "creative" ], - "createdAt": "2026-08-11T19:00:00.000Z", - "updatedAt": "2026-08-25T19:00:00.000Z" + "createdAt": "2026-08-12T19:00:00.000Z", + "updatedAt": "2026-08-26T19:00:00.000Z" }, { "id": "evt-dec-071", "title": "Sculpture Making Session", "description": "Create sculptures using clay and other materials", - "startTime": "2026-09-01T20:00:00.000Z", - "endTime": "2026-09-01T22:00:00.000Z", + "startTime": "2026-09-02T20:00:00.000Z", + "endTime": "2026-09-02T22:00:00.000Z", "location": "UC Arts & Crafts Room", "categories": [ "Arts", @@ -4740,7 +4740,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 25, + "capacity": 30, "tags": [ "sculpture", "clay", @@ -4748,8 +4748,8 @@ "crafts", "creative" ], - "createdAt": "2026-08-11T20:00:00.000Z", - "updatedAt": "2026-08-25T20:00:00.000Z" + "createdAt": "2026-08-12T20:00:00.000Z", + "updatedAt": "2026-08-26T20:00:00.000Z" }, { "id": "evt-dec-072", @@ -4778,7 +4778,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 40, + "capacity": 44, "tags": [ "calligraphy", "handwriting", @@ -4793,8 +4793,8 @@ "id": "evt-dec-073", "title": "Art Gallery Opening", "description": "View student artwork and meet the artists", - "startTime": "2026-09-02T22:00:00.000Z", - "endTime": "2026-09-03T00:00:00.000Z", + "startTime": "2026-09-03T22:00:00.000Z", + "endTime": "2026-09-04T00:00:00.000Z", "location": "UC Gallery", "categories": [ "Arts", @@ -4824,15 +4824,15 @@ "social", "networking" ], - "createdAt": "2026-08-12T22:00:00.000Z", - "updatedAt": "2026-08-26T22:00:00.000Z" + "createdAt": "2026-08-13T22:00:00.000Z", + "updatedAt": "2026-08-27T22:00:00.000Z" }, { "id": "evt-dec-074", "title": "Sketching & Drawing Session", "description": "Practice drawing skills with live models and still life", - "startTime": "2026-09-02T17:00:00.000Z", - "endTime": "2026-09-02T19:00:00.000Z", + "startTime": "2026-09-03T17:00:00.000Z", + "endTime": "2026-09-03T19:00:00.000Z", "location": "UC Arts Studio", "categories": [ "Arts", @@ -4854,7 +4854,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 45, + "capacity": 69, "tags": [ "sketching", "drawing", @@ -4862,15 +4862,15 @@ "creative", "practice" ], - "createdAt": "2026-08-12T17:00:00.000Z", - "updatedAt": "2026-08-26T17:00:00.000Z" + "createdAt": "2026-08-13T17:00:00.000Z", + "updatedAt": "2026-08-27T17:00:00.000Z" }, { "id": "evt-dec-075", "title": "Collage Making Workshop", "description": "Create collages from magazines, photos, and mixed media", - "startTime": "2026-09-02T19:30:00.000Z", - "endTime": "2026-09-02T21:30:00.000Z", + "startTime": "2026-09-03T19:30:00.000Z", + "endTime": "2026-09-03T21:30:00.000Z", "location": "UC Arts & Crafts Room", "categories": [ "Arts", @@ -4892,7 +4892,7 @@ "attendeeVisibility": "public", "isClubEvent": true, "isSocialEvent": true, - "capacity": 30, + "capacity": 34, "tags": [ "collage", "mixed-media", @@ -4900,7 +4900,7 @@ "creative", "crafts" ], - "createdAt": "2026-08-12T19:30:00.000Z", - "updatedAt": "2026-08-26T19:30:00.000Z" + "createdAt": "2026-08-13T19:30:00.000Z", + "updatedAt": "2026-08-27T19:30:00.000Z" } ] diff --git a/apps/client/hooks/useAppTheme.ts b/apps/client/hooks/useAppTheme.ts index 423d44e..4331138 100644 --- a/apps/client/hooks/useAppTheme.ts +++ b/apps/client/hooks/useAppTheme.ts @@ -1,10 +1,26 @@ import { useMemo } from 'react'; import { useSettings } from '@/contexts/SettingsContext'; import { AppPalette, AppPalettes, FontScales } from '@/constants/theme'; +import { + Elevation, + Radii, + Spacing, + Typography, + createElevation, + createType, +} from '@/constants/design'; export interface AppTheme { /** Semantic color palette for the active theme + contrast setting */ colors: AppPalette; + /** Type scale, already multiplied by the user's font-size preference */ + type: Typography; + /** 8pt spacing scale */ + space: typeof Spacing; + /** Corner radius scale */ + radii: typeof Radii; + /** Shadow ramp (flat in dark mode, where shadows don't read) */ + elevation: Elevation; /** Multiplier for text sizes (Settings -> Font Size) */ fontScale: number; /** True when animations should be skipped (Settings -> Reduce Motion) */ @@ -14,9 +30,9 @@ export interface AppTheme { } /** - * The one hook every screen/component should use for colors, font scaling, - * and motion preferences. Resolves the user's theme (light/dark/system), - * high-contrast setting, and font size from SettingsContext. + * The one hook every screen/component should use for colors, type, spacing, + * elevation, and motion preferences. Resolves the user's theme (light/dark/ + * system), high-contrast setting, and font size from SettingsContext. */ export function useAppTheme(): AppTheme { const { settings, currentTheme } = useSettings(); @@ -24,17 +40,24 @@ export function useAppTheme(): AppTheme { const highContrast = settings.accessibility?.highContrast ?? false; const reduceMotion = settings.accessibility?.reduceMotion ?? false; const fontScale = FontScales[settings.fontSize] ?? 1; + const isDark = currentTheme === 'dark'; const colors = AppPalettes[currentTheme][highContrast ? 'highContrast' : 'default']; + const type = useMemo(() => createType(fontScale), [fontScale]); + const elevation = useMemo(() => createElevation(isDark), [isDark]); return useMemo( () => ({ colors, + type, + space: Spacing, + radii: Radii, + elevation, fontScale, reduceMotion, - isDark: currentTheme === 'dark', + isDark, highContrast, }), - [colors, fontScale, reduceMotion, currentTheme, highContrast] + [colors, type, elevation, fontScale, reduceMotion, isDark, highContrast] ); } diff --git a/apps/client/hooks/useEventMessages.ts b/apps/client/hooks/useEventMessages.ts new file mode 100644 index 0000000..14960fe --- /dev/null +++ b/apps/client/hooks/useEventMessages.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from 'react'; +import { isSupabaseConfigured } from '@/lib/supabase'; +import { storage } from '@/lib/storage'; +import { + deleteEventMessageAPI, + fetchEventMessagesAPI, + postEventMessageAPI, +} from '@/lib/api'; +import { isDevUserId } from '@/constants/devAccounts'; +import { + EventMessage, + MESSAGE_STORAGE_KEY, + MessageKind, + MessageStore, + addMessage, + messagesForEvent, + normalizeBody, + parseMessageStore, + removeMessage, +} from '@/utils/eventMessages'; + +interface UseEventMessagesOptions { + eventId: string | undefined; + userId: string | undefined; + authorName: string; + /** Only participants load a thread — matches the database policy. */ + enabled: boolean; +} + +/** + * The discussion on one event. + * + * Reads and writes Supabase when it is configured (row-level security limits + * the thread to people going and the host); otherwise — offline/demo mode, or + * a dev persona that isn't a real auth user — it keeps the thread on the + * device so the feature is still usable end to end. + */ +export function useEventMessages({ + eventId, + userId, + authorName, + enabled, +}: UseEventMessagesOptions) { + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const isLocal = !isSupabaseConfigured || isDevUserId(userId); + + const readLocalStore = useCallback(async (): Promise => { + const raw = await storage.getItem(MESSAGE_STORAGE_KEY); + return parseMessageStore(raw); + }, []); + + const writeLocalStore = useCallback(async (next: MessageStore) => { + await storage.setItem(MESSAGE_STORAGE_KEY, JSON.stringify(next)); + }, []); + + const load = useCallback(async () => { + if (!eventId || !enabled) { + setMessages([]); + return; + } + setIsLoading(true); + setError(null); + try { + if (isLocal) { + const store = await readLocalStore(); + setMessages(messagesForEvent(store, eventId)); + } else { + setMessages(await fetchEventMessagesAPI(eventId)); + } + } catch (err) { + console.error('Failed to load event messages:', err); + setError('Could not load the conversation.'); + } finally { + setIsLoading(false); + } + }, [eventId, enabled, isLocal, readLocalStore]); + + useEffect(() => { + load(); + }, [load]); + + const post = useCallback( + async (rawBody: string, kind: MessageKind = 'message'): Promise => { + const body = normalizeBody(rawBody); + if (!body || !eventId || !userId) return false; + setError(null); + try { + if (isLocal) { + const message: EventMessage = { + id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + eventId, + userId, + authorName, + kind, + body, + createdAt: new Date().toISOString(), + }; + const store = await readLocalStore(); + const next = addMessage(store, message); + await writeLocalStore(next); + setMessages(messagesForEvent(next, eventId)); + } else { + const created = await postEventMessageAPI(eventId, userId, authorName, body, kind); + setMessages((prev) => [...prev, created]); + } + return true; + } catch (err) { + console.error('Failed to post message:', err); + setError('Could not post that. Try again.'); + return false; + } + }, + [eventId, userId, authorName, isLocal, readLocalStore, writeLocalStore] + ); + + const remove = useCallback( + async (messageId: string) => { + if (!eventId) return; + setError(null); + try { + if (isLocal) { + const store = await readLocalStore(); + const next = removeMessage(store, eventId, messageId); + await writeLocalStore(next); + setMessages(messagesForEvent(next, eventId)); + } else { + await deleteEventMessageAPI(messageId); + setMessages((prev) => prev.filter((m) => m.id !== messageId)); + } + } catch (err) { + console.error('Failed to delete message:', err); + setError('Could not delete that message.'); + } + }, + [eventId, isLocal, readLocalStore, writeLocalStore] + ); + + return { + messages, + announcements: messages.filter((m) => m.kind === 'announcement'), + isLoading, + error, + post, + remove, + refresh: load, + /** True when the thread lives on this device only. */ + isDeviceOnly: isLocal, + }; +} diff --git a/apps/client/lib/api.ts b/apps/client/lib/api.ts index b8ec52f..bb2a0f8 100644 --- a/apps/client/lib/api.ts +++ b/apps/client/lib/api.ts @@ -1,4 +1,5 @@ import { supabase, isSupabaseConfigured } from '@/lib/supabase'; +import { EventMessage, MessageKind } from '@/utils/eventMessages'; import { Event, EventCategory, EventFormData, RSVPStatus } from '@/types/event'; /** Thrown when a network call is attempted without Supabase credentials. */ @@ -196,3 +197,68 @@ export const fetchEventAPI = async (eventId: string): Promise => { if (error) throw error; return data ? transformDbEventToEvent(data as Record) : null; }; + +// ============================================ +// EVENT THREADS (chat + host announcements) +// ============================================ + +interface DbEventMessage { + id: string; + event_id: string; + user_id: string; + author_name: string; + kind: MessageKind; + body: string; + created_at: string; +} + +const transformDbMessage = (row: DbEventMessage): EventMessage => ({ + id: row.id, + eventId: row.event_id, + userId: row.user_id, + authorName: row.author_name, + kind: row.kind, + body: row.body, + createdAt: row.created_at, +}); + +/** Thread for one event, oldest first. RLS limits this to participants. */ +export const fetchEventMessagesAPI = async (eventId: string): Promise => { + requireSupabase(); + const { data, error } = await supabase + .from('event_messages') + .select('*') + .eq('event_id', eventId) + .order('created_at', { ascending: true }); + if (error) throw error; + return (data ?? []).map((row) => transformDbMessage(row as DbEventMessage)); +}; + +export const postEventMessageAPI = async ( + eventId: string, + userId: string, + authorName: string, + body: string, + kind: MessageKind = 'message' +): Promise => { + requireSupabase(); + const { data, error } = await supabase + .from('event_messages') + .insert({ + event_id: eventId, + user_id: userId, + author_name: authorName, + kind, + body, + }) + .select() + .single(); + if (error) throw error; + return transformDbMessage(data as DbEventMessage); +}; + +export const deleteEventMessageAPI = async (messageId: string): Promise => { + requireSupabase(); + const { error } = await supabase.from('event_messages').delete().eq('id', messageId); + if (error) throw error; +}; diff --git a/apps/client/tests/eventCapacity.test.ts b/apps/client/tests/eventCapacity.test.ts new file mode 100644 index 0000000..1091039 --- /dev/null +++ b/apps/client/tests/eventCapacity.test.ts @@ -0,0 +1,78 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getAvailableSpots, getClaimedSpots, isEventFull } from '../utils/eventHelpers'; +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('spots left never goes negative when an event is over-subscribed', () => { + const oversold = makeEvent({ + id: 'e1', + capacity: 50, + rsvpCounts: { going: 60, maybe: 12, notGoing: 3 }, + }); + assert.equal(getAvailableSpots(oversold), 0); + assert.equal(isEventFull(oversold), true); +}); + +test('spots left counts going and maybe against capacity', () => { + const event = makeEvent({ + id: 'e1', + capacity: 20, + rsvpCounts: { going: 12, maybe: 3, notGoing: 40 }, + }); + assert.equal(getClaimedSpots(event), 15); + assert.equal(getAvailableSpots(event), 5); + assert.equal(isEventFull(event), false); +}); + +test('an event exactly at capacity is full with zero spots left', () => { + const event = makeEvent({ + id: 'e1', + capacity: 10, + rsvpCounts: { going: 7, maybe: 3, notGoing: 0 }, + }); + assert.equal(getAvailableSpots(event), 0); + assert.equal(isEventFull(event), true); +}); + +test('events without a capacity have no spot count and are never full', () => { + const event = makeEvent({ id: 'e1', rsvpCounts: { going: 900, maybe: 5, notGoing: 0 } }); + assert.equal(getAvailableSpots(event), null); + assert.equal(isEventFull(event), false); + + const zeroCapacity = makeEvent({ id: 'e2', capacity: 0 }); + assert.equal(getAvailableSpots(zeroCapacity), null); + assert.equal(isEventFull(zeroCapacity), false); +}); + +test('negative stored counts cannot inflate the spots left', () => { + const event = makeEvent({ + id: 'e1', + capacity: 10, + rsvpCounts: { going: -5, maybe: 2, notGoing: 0 }, + }); + assert.equal(getClaimedSpots(event), 2); + assert.equal(getAvailableSpots(event), 8); +}); diff --git a/apps/client/tests/eventMessages.test.ts b/apps/client/tests/eventMessages.test.ts new file mode 100644 index 0000000..2147562 --- /dev/null +++ b/apps/client/tests/eventMessages.test.ts @@ -0,0 +1,85 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + EventMessage, + addMessage, + canParticipate, + messagesForEvent, + normalizeBody, + parseMessageStore, + removeMessage, +} from '../utils/eventMessages'; + +function makeMessage(overrides: Partial & { id: string }): EventMessage { + return { + eventId: 'evt-1', + userId: 'u1', + authorName: 'Petra', + kind: 'message', + body: 'hello', + createdAt: '2026-08-12T10:00:00.000Z', + ...overrides, + }; +} + +test('only people going, maybe, or hosting can take part', () => { + assert.equal(canParticipate('going', false), true); + assert.equal(canParticipate('maybe', false), true); + assert.equal(canParticipate('not-going', false), false); + assert.equal(canParticipate(null, false), false); + assert.equal(canParticipate(undefined, false), false); + // The host is in regardless of their own RSVP + assert.equal(canParticipate(null, true), true); +}); + +test('messages stay in chronological order as they are added', () => { + const later = makeMessage({ id: 'b', createdAt: '2026-08-12T12:00:00.000Z' }); + const earlier = makeMessage({ id: 'a', createdAt: '2026-08-12T09:00:00.000Z' }); + + let store = addMessage({}, later); + store = addMessage(store, earlier); + + assert.deepEqual( + messagesForEvent(store, 'evt-1').map((m) => m.id), + ['a', 'b'] + ); +}); + +test('adding the same message twice is a no-op', () => { + const message = makeMessage({ id: 'a' }); + const store = addMessage({}, message); + assert.equal(addMessage(store, message), store); +}); + +test('messages are kept per event', () => { + let store = addMessage({}, makeMessage({ id: 'a' })); + store = addMessage(store, makeMessage({ id: 'b', eventId: 'evt-2' })); + + assert.equal(messagesForEvent(store, 'evt-1').length, 1); + assert.equal(messagesForEvent(store, 'evt-2').length, 1); + assert.deepEqual(messagesForEvent(store, 'evt-3'), []); +}); + +test('removing the last message drops the event key', () => { + const store = addMessage({}, makeMessage({ id: 'a' })); + const emptied = removeMessage(store, 'evt-1', 'a'); + assert.deepEqual(emptied, {}); + // Removing something that isn't there returns the same store + assert.equal(removeMessage(store, 'evt-1', 'missing'), store); +}); + +test('parseMessageStore rejects malformed posts', () => { + assert.deepEqual(parseMessageStore(null), {}); + assert.deepEqual(parseMessageStore('{"evt-1": "not an array"}'), {}); + assert.deepEqual(parseMessageStore('{"evt-1": [{"id": "a"}]}'), {}); + + const good = JSON.stringify({ 'evt-1': [makeMessage({ id: 'a', kind: 'announcement' })] }); + assert.equal(parseMessageStore(good)['evt-1'][0].kind, 'announcement'); +}); + +test('normalizeBody trims, rejects blanks and caps length', () => { + assert.equal(normalizeBody(' hi '), 'hi'); + assert.equal(normalizeBody(' '), null); + assert.equal(normalizeBody(''), null); + assert.equal(normalizeBody('x'.repeat(3000))?.length, 2000); +}); diff --git a/apps/client/tests/eventRatings.test.ts b/apps/client/tests/eventRatings.test.ts new file mode 100644 index 0000000..e106230 --- /dev/null +++ b/apps/client/tests/eventRatings.test.ts @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + averageStars, + clampStars, + getRating, + parseRatingStore, + setRating, +} from '../utils/eventRatings'; + +test('stars are clamped to whole numbers between 1 and 5', () => { + assert.equal(clampStars(0), 1); + assert.equal(clampStars(-4), 1); + assert.equal(clampStars(3.4), 3); + assert.equal(clampStars(3.6), 4); + assert.equal(clampStars(9), 5); + assert.equal(clampStars(NaN), 1); +}); + +test('parseRatingStore drops malformed entries', () => { + assert.deepEqual(parseRatingStore(null), {}); + assert.deepEqual(parseRatingStore('nonsense'), {}); + assert.deepEqual(parseRatingStore('{"u1":{"e1":{"stars":"five"}}}'), {}); + + const parsed = parseRatingStore( + '{"u1":{"e1":{"stars":7,"note":"great","ratedAt":"2026-08-01T00:00:00.000Z"}}}' + ); + assert.deepEqual(parsed, { + u1: { e1: { stars: 5, note: 'great', ratedAt: '2026-08-01T00:00:00.000Z' } }, + }); +}); + +test('setRating stores, replaces and clears per user', () => { + let store = setRating({}, 'u1', 'e1', 4, 'solid', 'now'); + assert.deepEqual(getRating(store, 'u1', 'e1'), { + stars: 4, + note: 'solid', + ratedAt: 'now', + }); + + // Another user's rating of the same event is independent + store = setRating(store, 'u2', 'e1', 2, undefined, 'now'); + assert.equal(getRating(store, 'u2', 'e1')?.stars, 2); + assert.equal(getRating(store, 'u1', 'e1')?.stars, 4); + + // Re-rating replaces + store = setRating(store, 'u1', 'e1', 5, undefined, 'later'); + assert.deepEqual(getRating(store, 'u1', 'e1'), { stars: 5, ratedAt: 'later' }); + + // Clearing removes the entry, and the user when it was their last one + store = setRating(store, 'u1', 'e1', null); + assert.equal(getRating(store, 'u1', 'e1'), null); + assert.ok(!('u1' in store)); + assert.equal(getRating(store, 'u2', 'e1')?.stars, 2); +}); + +test('blank notes are not stored, long notes are capped', () => { + const blank = setRating({}, 'u1', 'e1', 3, ' ', 'now'); + assert.equal(getRating(blank, 'u1', 'e1')?.note, undefined); + + const long = setRating({}, 'u1', 'e1', 3, 'x'.repeat(400), 'now'); + assert.equal(getRating(long, 'u1', 'e1')?.note?.length, 280); +}); + +test('averageStars reflects only the given user', () => { + let store = setRating({}, 'u1', 'e1', 5, undefined, 'now'); + store = setRating(store, 'u1', 'e2', 4, undefined, 'now'); + store = setRating(store, 'u2', 'e1', 1, undefined, 'now'); + + assert.equal(averageStars(store, 'u1'), 4.5); + assert.equal(averageStars(store, 'u2'), 1); + assert.equal(averageStars(store, 'nobody'), null); + assert.equal(averageStars(store, undefined), null); +}); + +test('signed-out lookups never return a rating', () => { + const store = setRating({}, 'u1', 'e1', 5, undefined, 'now'); + assert.equal(getRating(store, null, 'e1'), null); +}); diff --git a/apps/client/utils/eventHelpers.ts b/apps/client/utils/eventHelpers.ts index ba6f37f..25e7d41 100644 --- a/apps/client/utils/eventHelpers.ts +++ b/apps/client/utils/eventHelpers.ts @@ -55,11 +55,7 @@ export const filterEvents = (events: Event[], filters: FilterState): Event[] => } // Availability filter - if (filters.hasAvailability && event.capacity) { - const totalRSVPs = - event.rsvpCounts.going + event.rsvpCounts.maybe; - if (totalRSVPs >= event.capacity) return false; - } + if (filters.hasAvailability && isEventFull(event)) return false; return true; }); @@ -210,16 +206,24 @@ export const getEventDuration = (event: Event): number => { return (end.getTime() - start.getTime()) / (1000 * 60); // Duration in minutes }; +/** Everyone who has claimed a spot: going plus maybe. */ +export const getClaimedSpots = (event: Event): number => + Math.max(0, event.rsvpCounts.going) + Math.max(0, event.rsvpCounts.maybe); + export const isEventFull = (event: Event): boolean => { - if (!event.capacity) return false; - const totalRSVPs = event.rsvpCounts.going + event.rsvpCounts.maybe; - return totalRSVPs >= event.capacity; + if (!event.capacity || event.capacity <= 0) return false; + return getClaimedSpots(event) >= event.capacity; }; +/** + * Spots still open, or null when the event has no capacity limit. + * + * Never negative: an over-subscribed event (imported counts, concurrent RSVPs, + * a capacity lowered after the fact) is full, not "-6 spots left". + */ export const getAvailableSpots = (event: Event): number | null => { - if (!event.capacity) return null; - const totalRSVPs = event.rsvpCounts.going + event.rsvpCounts.maybe; - return Math.max(0, event.capacity - totalRSVPs); + if (!event.capacity || event.capacity <= 0) return null; + return Math.max(0, event.capacity - getClaimedSpots(event)); }; export const groupEventsByCategory = ( diff --git a/apps/client/utils/eventMessages.ts b/apps/client/utils/eventMessages.ts new file mode 100644 index 0000000..fbc209e --- /dev/null +++ b/apps/client/utils/eventMessages.ts @@ -0,0 +1,112 @@ +/** + * Event threads: attendee chat plus host announcements. + * + * Announcements and messages share one ordered list so the thread reads + * chronologically, with `kind` deciding how a post renders and who may write + * it (announcements are the host's). When Supabase is configured these live in + * the `event_messages` table; otherwise they are kept on the device, which is + * what offline/demo mode and dev personas use. + */ + +export type MessageKind = 'message' | 'announcement'; + +export interface EventMessage { + id: string; + eventId: string; + userId: string; + authorName: string; + kind: MessageKind; + body: string; + createdAt: string; +} + +export type MessageStore = Record; + +export const MESSAGE_STORAGE_KEY = 'universify_event_messages'; +export const MAX_MESSAGE_LENGTH = 2000; + +function isMessage(value: unknown): value is EventMessage { + if (!value || typeof value !== 'object') return false; + const m = value as Partial; + return ( + typeof m.id === 'string' && + typeof m.eventId === 'string' && + typeof m.userId === 'string' && + typeof m.authorName === 'string' && + (m.kind === 'message' || m.kind === 'announcement') && + typeof m.body === 'string' && + typeof m.createdAt === 'string' + ); +} + +const byTime = (a: EventMessage, b: EventMessage) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + +/** Parse a persisted store, dropping anything malformed. */ +export function parseMessageStore(raw: string | null | undefined): MessageStore { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const store: MessageStore = {}; + for (const [eventId, list] of Object.entries(parsed as Record)) { + if (!Array.isArray(list)) continue; + const clean = list.filter(isMessage).sort(byTime); + if (clean.length > 0) store[eventId] = clean; + } + return store; + } catch { + return {}; + } +} + +export function messagesForEvent(store: MessageStore, eventId: string): EventMessage[] { + return store[eventId] ?? []; +} + +/** Return a new store with the post appended (ids are de-duplicated). */ +export function addMessage(store: MessageStore, message: EventMessage): MessageStore { + const existing = store[message.eventId] ?? []; + if (existing.some((m) => m.id === message.id)) return store; + return { + ...store, + [message.eventId]: [...existing, message].sort(byTime), + }; +} + +export function removeMessage( + store: MessageStore, + eventId: string, + messageId: string +): MessageStore { + const existing = store[eventId]; + if (!existing) return store; + const next = existing.filter((m) => m.id !== messageId); + if (next.length === existing.length) return store; + const updated = { ...store }; + if (next.length > 0) { + updated[eventId] = next; + } else { + delete updated[eventId]; + } + return updated; +} + +/** Trim and length-cap a draft; returns null when there's nothing to post. */ +export function normalizeBody(body: string): string | null { + const trimmed = body.trim(); + if (!trimmed) return null; + return trimmed.slice(0, MAX_MESSAGE_LENGTH); +} + +/** + * Who may take part in an event's thread: anyone going or maybe, plus the + * host. Kept next to the storage helpers so the UI and the database policy + * (see supabase/migrations/004_event_messages.sql) state the same rule. + */ +export function canParticipate( + rsvpStatus: string | null | undefined, + isHost: boolean +): boolean { + return isHost || rsvpStatus === 'going' || rsvpStatus === 'maybe'; +} diff --git a/apps/client/utils/eventRatings.ts b/apps/client/utils/eventRatings.ts new file mode 100644 index 0000000..0878c8e --- /dev/null +++ b/apps/client/utils/eventRatings.ts @@ -0,0 +1,109 @@ +/** + * Ratings people leave on events they attended. + * + * Stored per user on the device: there is no ratings table on the server (and + * in offline/demo mode no server at all), so this is the record. Shape: + * { [userId]: { [eventId]: { stars, note, ratedAt } } }. + */ + +export interface EventRating { + /** 1–5 */ + stars: number; + /** Optional free-text note the rater left */ + note?: string; + /** ISO timestamp of the last edit */ + ratedAt: string; +} + +export type RatingStore = Record>; + +export const RATING_STORAGE_KEY = 'universify_event_ratings'; +export const MIN_STARS = 1; +export const MAX_STARS = 5; +export const MAX_NOTE_LENGTH = 280; + +/** Clamp to a whole number of stars inside the allowed range. */ +export function clampStars(stars: number): number { + if (!Number.isFinite(stars)) return MIN_STARS; + return Math.min(MAX_STARS, Math.max(MIN_STARS, Math.round(stars))); +} + +/** Parse a persisted store, dropping anything malformed. */ +export function parseRatingStore(raw: string | null | undefined): RatingStore { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const store: RatingStore = {}; + for (const [userId, byEvent] of Object.entries(parsed as Record)) { + if (!byEvent || typeof byEvent !== 'object' || Array.isArray(byEvent)) continue; + const clean: Record = {}; + for (const [eventId, value] of Object.entries(byEvent as Record)) { + if (!value || typeof value !== 'object') continue; + const { stars, note, ratedAt } = value as Partial; + if (typeof stars !== 'number' || !Number.isFinite(stars)) continue; + clean[eventId] = { + stars: clampStars(stars), + ...(typeof note === 'string' && note.trim() + ? { note: note.slice(0, MAX_NOTE_LENGTH) } + : {}), + ratedAt: typeof ratedAt === 'string' ? ratedAt : new Date(0).toISOString(), + }; + } + if (Object.keys(clean).length > 0) store[userId] = clean; + } + return store; + } catch { + return {}; + } +} + +export function getRating( + store: RatingStore, + userId: string | null | undefined, + eventId: string +): EventRating | null { + if (!userId) return null; + return store[userId]?.[eventId] ?? null; +} + +/** Return a new store with the rating set, or removed when stars is null. */ +export function setRating( + store: RatingStore, + userId: string, + eventId: string, + stars: number | null, + note?: string, + ratedAt: string = new Date().toISOString() +): RatingStore { + const forUser = { ...(store[userId] ?? {}) }; + if (stars === null) { + delete forUser[eventId]; + } else { + const trimmed = note?.trim().slice(0, MAX_NOTE_LENGTH); + forUser[eventId] = { + stars: clampStars(stars), + ...(trimmed ? { note: trimmed } : {}), + ratedAt, + }; + } + const next = { ...store }; + if (Object.keys(forUser).length > 0) { + next[userId] = forUser; + } else { + delete next[userId]; + } + return next; +} + +/** Average of the user's own ratings, or null when they've rated nothing. */ +export function averageStars( + store: RatingStore, + userId: string | null | undefined +): number | null { + if (!userId) return null; + const ratings = Object.values(store[userId] ?? {}); + if (ratings.length === 0) return null; + const total = ratings.reduce((sum, rating) => sum + rating.stars, 0); + return Math.round((total / ratings.length) * 10) / 10; +} diff --git a/supabase/migrations/004_event_messages.sql b/supabase/migrations/004_event_messages.sql new file mode 100644 index 0000000..cf17d4b --- /dev/null +++ b/supabase/migrations/004_event_messages.sql @@ -0,0 +1,75 @@ +-- Universify: per-event discussion and host announcements +-- Run this in Supabase SQL Editor after 001–003. +-- +-- Why: an event page had no way for the people going to talk to each other, +-- and no way for the host to reach them. Both live in one table, separated by +-- `kind`, so a single query renders the thread in order. +-- +-- Access rules enforced in the database, not just the UI: +-- * only people who RSVP'd (going/maybe) or the organizer can read a thread +-- * the same people can post messages +-- * announcements can only be written by the organizer + +CREATE TABLE IF NOT EXISTS event_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'message' CHECK (kind IN ('message', 'announcement')), + body TEXT NOT NULL CHECK (char_length(btrim(body)) BETWEEN 1 AND 2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_event_messages_event ON event_messages(event_id, created_at); + +ALTER TABLE event_messages ENABLE ROW LEVEL SECURITY; + +-- True when the given user is going/maybe to the event, or hosts it. +CREATE OR REPLACE FUNCTION is_event_participant(target_event_id TEXT, target_user_id UUID) +RETURNS BOOLEAN +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM event_rsvps r + WHERE r.event_id = target_event_id + AND r.user_id = target_user_id + AND r.status IN ('going', 'maybe') + ) OR EXISTS ( + SELECT 1 FROM events e + WHERE e.id = target_event_id + AND e.organizer_id = target_user_id::text + ); +$$; + +CREATE OR REPLACE FUNCTION is_event_organizer(target_event_id TEXT, target_user_id UUID) +RETURNS BOOLEAN +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM events e + WHERE e.id = target_event_id + AND e.organizer_id = target_user_id::text + ); +$$; + +CREATE POLICY "Participants can read the thread" + ON event_messages FOR SELECT + USING (is_event_participant(event_id, auth.uid())); + +CREATE POLICY "Participants can post messages" + ON event_messages FOR INSERT + WITH CHECK ( + auth.uid() = user_id + AND is_event_participant(event_id, auth.uid()) + AND (kind = 'message' OR is_event_organizer(event_id, auth.uid())) + ); + +CREATE POLICY "Authors can delete their own posts" + ON event_messages FOR DELETE + USING (auth.uid() = user_id);