From 186d5712f62230a098c4493e5e2addf031d5c8c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:14:23 +0000 Subject: [PATCH] Fix broken icons on Vercel and rebuild mobile calendar as a Luma-style experience Icon fix: - Vercel's uploader silently prunes any path containing node_modules, and Expo's web export emits icon fonts under assets/__node_modules/.pnpm/..., so every icon glyph 404'd in production. scripts/postexport.js now runs after expo export, flattens those files into assets/vendor/, and rewrites all references in the emitted js/css/html. Luma-style mobile calendar and events: - Calendar tab on phones now defaults to a date-grouped Agenda timeline (Today/Tomorrow headers, timeline spine, animated cards) with a segmented Agenda/Grid toggle; tapping an event opens its full page. - New My Events screen (Upcoming/Past with past-event search) listing everything you're going to, maybe attending, created, or scheduled, linked from the calendar header and profile menu. - Event page rewritten Luma-style: cover art, host row, info tiles, spots-left, Add to Google Calendar / download .ics / share actions, and a sticky RSVP bar that reflects "You're going" state. - New utils/calendarLinks.ts (ICS builder, Google Calendar URL, share/copy helpers) with unit tests; suite is 30/30. - New reusable SegmentedControl and AgendaList components with smooth, reduce-motion-aware animations. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011nrgpkJbvZ9JCf2VA2kKvJ --- apps/client/app/(tabs)/calendar.tsx | 161 +++- apps/client/app/(tabs)/profile.tsx | 20 +- apps/client/app/_layout.tsx | 1 + apps/client/app/event/[id].tsx | 719 +++++++++++++----- apps/client/app/my-events.tsx | 308 ++++++++ apps/client/components/events/AgendaList.tsx | 423 +++++++++++ .../components/events/EventDetailSidebar.tsx | 85 +++ .../client/components/ui/SegmentedControl.tsx | 110 +++ apps/client/package.json | 4 +- apps/client/scripts/postexport.js | 88 +++ apps/client/tests/calendarLinks.test.ts | 94 +++ apps/client/utils/calendarLinks.ts | 128 ++++ 12 files changed, 1928 insertions(+), 213 deletions(-) create mode 100644 apps/client/app/my-events.tsx create mode 100644 apps/client/components/events/AgendaList.tsx create mode 100644 apps/client/components/ui/SegmentedControl.tsx create mode 100644 apps/client/scripts/postexport.js create mode 100644 apps/client/tests/calendarLinks.test.ts create mode 100644 apps/client/utils/calendarLinks.ts diff --git a/apps/client/app/(tabs)/calendar.tsx b/apps/client/app/(tabs)/calendar.tsx index fcf8f33..76d5701 100644 --- a/apps/client/app/(tabs)/calendar.tsx +++ b/apps/client/app/(tabs)/calendar.tsx @@ -1,5 +1,6 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; import { View, StyleSheet, Text, ActivityIndicator, TouchableOpacity, TextInput, ScrollView } from 'react-native'; +import { router } from 'expo-router'; import { useEvents } from '@/contexts/EventsContext'; import { useCalendar } from '@/hooks/useCalendar'; import { useResponsive } from '@/hooks/useResponsive'; @@ -21,6 +22,8 @@ import { useEventReminders } from '@/hooks/useEventReminders'; import { storage } from '@/lib/storage'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; +import { AgendaList, AgendaBadge } from '@/components/events/AgendaList'; +import { SegmentedControl } from '@/components/ui/SegmentedControl'; // Universify event id -> Google Calendar event id map, persisted so // unscheduling can delete the Google copy even after a reload @@ -46,10 +49,11 @@ export default function CalendarScreen() { unscheduleEvent: unscheduleEventForWeek, } = useScheduledEvents(currentUser?.id, weekKey); - const [selectedEvent, setSelectedEvent] = useState(null); const [expandedCardId, setExpandedCardId] = useState(null); const [customDays, setCustomDays] = useState(settings.calendarViewDays.toString()); const [timeSelection, setTimeSelection] = useState<{ startDate: Date; endDate: Date } | null>(null); + // Mobile defaults to a Luma-style agenda timeline; the hour grid is opt-in + const [mobileView, setMobileView] = useState<'agenda' | 'grid'>('agenda'); // Map Universify event id -> Google Calendar event id when we create in Google on schedule (so we can delete on unschedule) const scheduleEventToGoogleIdRef = useRef>(new Map()); @@ -143,6 +147,30 @@ export default function CalendarScreen() { currentUser?.preferences.notificationPreferences.eventReminders ?? false ); + // Agenda view: the user's scheduled events (with recurring occurrences) + // over the next 30 days, in a date-grouped timeline + const agendaEvents = useMemo(() => { + const now = new Date(); + const horizon = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + const scheduled = events.filter((e) => allScheduledEventIds.includes(e.id)); + return expandRecurringEvents(scheduled, now, horizon).filter( + (e) => new Date(e.endTime) >= now && new Date(e.startTime) <= horizon + ); + }, [events, allScheduledEventIds]); + + const agendaBadgeFor = (event: Event): AgendaBadge | null => { + const baseId = baseEventId(event.id); + if (currentUser?.createdEvents.includes(baseId)) return 'created'; + const rsvp = currentUser + ? events + .find((e) => e.id === baseId) + ?.attendees.find((a) => a.userId === currentUser.id)?.status + : null; + if (rsvp === 'going') return 'going'; + if (rsvp === 'maybe') return 'maybe'; + return 'scheduled'; + }; + // Get all events for sidebar (sorted by date) // Only include Universify events (Google events are already on the calendar) // Only show future/current events (end time >= now) so past events don't clutter the list @@ -173,12 +201,11 @@ export default function CalendarScreen() { // Recurring occurrences carry a synthetic "::" id; act on the // base event so scheduling/expansion always target the real record. const baseId = baseEventId(event.id); - const base = events.find((e) => e.id === baseId) ?? event; if (isDesktop) { // Toggle expansion setExpandedCardId((prevId) => (prevId === baseId ? null : baseId)); } else { - setSelectedEvent(base); + router.push(`/event/${baseId}`); } }; @@ -304,6 +331,75 @@ export default function CalendarScreen() { } }; + // ── Mobile: Luma-style agenda by default, hour grid opt-in ── + if (isMobile) { + return ( + + + Calendar + router.push('/my-events')} + > + My events + + + + + + + {mobileView === 'agenda' ? ( + isLoading || isLoadingScheduled ? ( + + + + ) : ( + router.push('/(tabs)/find') }} + /> + ) + ) : ( + + + + + {isLoading || isGoogleLoading ? ( + + + + ) : ( + + )} + + )} + + ); + } + return ( @@ -441,27 +537,6 @@ export default function CalendarScreen() { )} - {/* Event Detail Modal - Placeholder */} - {selectedEvent && ( - - setSelectedEvent(null)} - /> - - {selectedEvent.title} - - {selectedEvent.description} - - setSelectedEvent(null)} - > - Close - - - - )} ); } @@ -472,6 +547,44 @@ const createStyles = (colors: AppPalette, fontScale: number) => flex: 1, backgroundColor: colors.background, }, + mobileHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 8, + }, + mobileTitle: { + fontSize: 24 * fontScale, + fontWeight: '800', + letterSpacing: -0.5, + color: colors.textPrimary, + }, + myEventsLink: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 7, + }, + myEventsLinkText: { + fontSize: 13 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + mobileSegmentWrap: { + paddingHorizontal: 16, + paddingBottom: 10, + }, + mobileGridWrap: { + flex: 1, + paddingHorizontal: 8, + }, + mobileGridControls: { + paddingHorizontal: 8, + paddingBottom: 8, + }, content: { flex: 1, flexDirection: 'row', diff --git a/apps/client/app/(tabs)/profile.tsx b/apps/client/app/(tabs)/profile.tsx index 52fa11f..22aab5b 100644 --- a/apps/client/app/(tabs)/profile.tsx +++ b/apps/client/app/(tabs)/profile.tsx @@ -438,8 +438,15 @@ export default function ProfileScreen() { {/* Menu */} - router.push('/my-events')} + > + + My Events + + setActiveTab('activity')} > @@ -509,6 +516,15 @@ export default function ProfileScreen() { {/* Menu Items */} + router.push('/my-events')} + > + + My Events + + + router.push('/settings/account')} diff --git a/apps/client/app/_layout.tsx b/apps/client/app/_layout.tsx index 93ac099..e1432ab 100644 --- a/apps/client/app/_layout.tsx +++ b/apps/client/app/_layout.tsx @@ -28,6 +28,7 @@ function ThemedStack() { + diff --git a/apps/client/app/event/[id].tsx b/apps/client/app/event/[id].tsx index 34e9b3a..7d622e2 100644 --- a/apps/client/app/event/[id].tsx +++ b/apps/client/app/event/[id].tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { View, Text, @@ -6,29 +6,77 @@ import { StyleSheet, ActivityIndicator, Image, + Pressable, + Animated, + Platform, } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; +import * as Linking from 'expo-linking'; +import { Ionicons } from '@expo/vector-icons'; import { useEvents } from '@/contexts/EventsContext'; import { useAuth } from '@/contexts/AuthContext'; import { CategoryPill } from '@/components/ui/CategoryPill'; import { Button } from '@/components/ui/Button'; import { Event, RSVPStatus } from '@/types/event'; import { fetchEventAPI } from '@/lib/api'; -import { formatDate, formatTimeRange } from '@/utils/dateHelpers'; +import { formatDate, formatFullDate, formatTimeRange } from '@/utils/dateHelpers'; +import { googleCalendarUrl, downloadIcs, shareEvent } from '@/utils/calendarLinks'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; +type IoniconName = React.ComponentProps['name']; + +const recurrenceLabel = (event: Event): string | null => { + if (!event.recurring) return null; + const { frequency, interval, endDate } = event.recurring; + const unit = + frequency === 'daily' + ? interval > 1 ? `every ${interval} days` : 'daily' + : frequency === 'weekly' + ? interval > 1 ? `every ${interval} weeks` : 'weekly' + : interval > 1 ? `every ${interval} months` : 'monthly'; + return `Repeats ${unit}${endDate ? ` until ${formatDate(endDate)}` : ''}`; +}; + +function InfoRow({ + icon, + primary, + secondary, + styles, + colors, +}: { + icon: IoniconName; + primary: string; + secondary?: string; + styles: ReturnType; + colors: AppPalette; +}) { + return ( + + + + + + {primary} + {secondary ? {secondary} : null} + + + ); +} + export default function EventDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const { getEventById, updateRSVP, getRSVPStatus } = useEvents(); const { currentUser } = useAuth(); - const { colors, fontScale } = useAppTheme(); + const { colors, fontScale, reduceMotion } = useAppTheme(); const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); const contextEvent = id ? getEventById(id) : undefined; const [fetchedEvent, setFetchedEvent] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isUpdatingRSVP, setIsUpdatingRSVP] = useState(false); + const [isChoosingRsvp, setIsChoosingRsvp] = useState(false); + const [shareFeedback, setShareFeedback] = useState(null); const event = contextEvent ?? fetchedEvent ?? undefined; @@ -63,9 +111,43 @@ export default function EventDetailScreen() { await updateRSVP(event.id, currentUser.id, status); } finally { setIsUpdatingRSVP(false); + setIsChoosingRsvp(false); } }; + // Which face the bottom bar is showing — drives the quick fade/scale swap. + const barMode = !currentUser + ? 'signed-out' + : userRSVP === 'going' && !isChoosingRsvp + ? 'going' + : userRSVP === 'maybe' && !isChoosingRsvp + ? 'maybe' + : 'choose'; + + const barAnim = useRef(new Animated.Value(1)).current; + useEffect(() => { + if (reduceMotion) { + barAnim.setValue(1); + return; + } + barAnim.setValue(0); + Animated.spring(barAnim, { + toValue: 1, + tension: 140, + friction: 14, + useNativeDriver: true, + }).start(); + }, [barMode, reduceMotion, barAnim]); + + const copyAnim = useRef(new Animated.Value(0)).current; + const feedbackTimer = useRef | null>(null); + useEffect( + () => () => { + if (feedbackTimer.current) clearTimeout(feedbackTimer.current); + }, + [] + ); + if (isLoading) { return ( @@ -86,150 +168,277 @@ export default function EventDetailScreen() { ); } - const totalRSVPs = event.rsvpCounts.going + event.rsvpCounts.maybe; + const isWeb = Platform.OS === 'web' && typeof window !== 'undefined'; - return ( - - - + const openGoogleCalendar = () => { + const url = googleCalendarUrl(event); + if (isWeb) { + window.open(url, '_blank', 'noopener'); + } else { + Linking.openURL(url); + } + }; - {event.imageUrl ? ( - - ) : null} + const handleShare = async () => { + const url = isWeb ? window.location.href : Linking.createURL(`/event/${event.id}`); + const result = await shareEvent(event, url); + if (result !== 'copied') return; + setShareFeedback('Link copied'); + if (reduceMotion) { + copyAnim.setValue(1); + } else { + copyAnim.setValue(0); + Animated.timing(copyAnim, { toValue: 1, duration: 160, useNativeDriver: true }).start(); + } + if (feedbackTimer.current) clearTimeout(feedbackTimer.current); + feedbackTimer.current = setTimeout(() => setShareFeedback(null), 2200); + }; - - - {event.title} - {event.isClubEvent && ( - - Club - - )} - {event.isSocialEvent && ( - - Social - - )} - + const chooseRsvp = (status: Exclude) => { + if (userRSVP === status) { + setIsChoosingRsvp(false); + return; + } + handleRSVP(status); + }; - - 🕒 - - {formatDate(event.startTime)} • {formatTimeRange(event.startTime, event.endTime)} - - - {event.location ? ( - - 📍 - {event.location} - - ) : null} - - 👤 - {event.organizer.name} - - {event.recurring ? ( - - 🔁 - - Repeats {event.recurring.interval > 1 ? `every ${event.recurring.interval} ` : ''} - {event.recurring.frequency === 'daily' - ? event.recurring.interval > 1 ? 'days' : 'daily' - : event.recurring.frequency === 'weekly' - ? event.recurring.interval > 1 ? 'weeks' : 'weekly' - : event.recurring.interval > 1 ? 'months' : 'monthly'} - {event.recurring.endDate ? ` until ${formatDate(event.recurring.endDate)}` : ''} - - - ) : null} + const recurrence = recurrenceLabel(event); + const spotsLeft = event.capacity + ? Math.max(event.capacity - (event.rsvpCounts.going + event.rsvpCounts.maybe), 0) + : null; - {event.description ? ( - <> - About - {event.description} - - ) : null} + const barAnimStyle = { + opacity: barAnim, + transform: [ + { scale: barAnim.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }, + ], + }; - {event.categories.length > 0 && ( - <> - Categories - - {event.categories.map((category) => ( - - ))} - - + return ( + + + {/* Cover header */} + + {event.imageUrl ? ( + + ) : ( + + + + )} + - {event.rsvpEnabled && ( - <> - RSVPs - - - {event.rsvpCounts.going} - Going - - - {event.rsvpCounts.maybe} - Maybe - - - {event.rsvpCounts.notGoing} - Not Going - - + {/* Content card overlapping the cover */} + + + {event.title} - {event.capacity ? ( - - {Math.max(event.capacity - totalRSVPs, 0)} of {event.capacity} spots left + {/* Hosted by */} + + + + {event.organizer.name.charAt(0).toUpperCase()} + + + Hosted by {event.organizer.name} + + {event.organizer.type === 'club' ? 'Club' : 'Individual organizer'} + + + + + + + {/* Info rows */} + + + {event.location ? ( + + ) : null} + {recurrence ? ( + + ) : null} + {event.rsvpEnabled ? ( + ) : null} + + + {/* Calendar + share actions */} + + [styles.quietButton, pressed && styles.quietButtonPressed]} + > + + Add to Google Calendar + + downloadIcs(event)} + style={({ pressed }) => [styles.quietButton, pressed && styles.quietButtonPressed]} + > + + Download .ics + + [styles.quietButton, pressed && styles.quietButtonPressed]} + > + + Share + + + {shareFeedback ? ( + + {shareFeedback} + + ) : null} - {currentUser && ( - -