From 000d96646becbe0b154efb31aa2e9026953805d5 Mon Sep 17 00:00:00 2001 From: heybeaux Date: Fri, 3 Jul 2026 20:37:13 -0700 Subject: [PATCH 1/4] Add first-launch onboarding flow with premium CTA --- src/lib/onboarding.ts | 24 ++ src/navigation/AppNavigator.tsx | 35 ++- src/screens/onboarding/OnboardingScreen.tsx | 275 ++++++++++++++++++++ 3 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 src/lib/onboarding.ts create mode 100644 src/screens/onboarding/OnboardingScreen.tsx diff --git a/src/lib/onboarding.ts b/src/lib/onboarding.ts new file mode 100644 index 0000000..a41cb12 --- /dev/null +++ b/src/lib/onboarding.ts @@ -0,0 +1,24 @@ +/** + * Onboarding persistence — first-launch flag stored locally. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export const ONBOARDING_COMPLETE_KEY = '@ultraedge/onboarding-complete'; + +export async function isOnboardingComplete(): Promise { + try { + return (await AsyncStorage.getItem(ONBOARDING_COMPLETE_KEY)) === 'true'; + } catch { + // If storage is unreadable, don't trap the user in onboarding + return true; + } +} + +export async function setOnboardingComplete(): Promise { + try { + await AsyncStorage.setItem(ONBOARDING_COMPLETE_KEY, 'true'); + } catch (err) { + console.error('Failed to persist onboarding flag:', err); + } +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 766de1e..b5be8a4 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -3,7 +3,8 @@ * Bottom tabs + stack navigation for all CRUD screens */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { View } from 'react-native'; import { NavigationContainer, DefaultTheme, DarkTheme } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; @@ -18,6 +19,10 @@ import { useTheme } from '../theme'; // Home import HomeScreen from '../screens/HomeScreen'; +// Onboarding +import OnboardingScreen from '../screens/onboarding/OnboardingScreen'; +import { isOnboardingComplete } from '../lib/onboarding'; + // Events (will be created by agent) import EventsListScreen from '../screens/events/EventsListScreen'; import CreateEventScreen from '../screens/events/CreateEventScreen'; @@ -69,6 +74,9 @@ import AboutScreen from '../screens/settings/AboutScreen'; // ============================================================================ export type RootStackParamList = { + // Onboarding (first launch only) + Onboarding: undefined; + // Tabs Main: undefined; @@ -240,6 +248,13 @@ export default function AppNavigator() { const { colors } = theme; const screenOptions = useScreenOptions(); + // null = still reading the flag; render a blank screen so onboarding never flashes + const [onboardingComplete, setOnboardingComplete] = useState(null); + + useEffect(() => { + isOnboardingComplete().then(setOnboardingComplete); + }, []); + // Custom navigation theme const navigationTheme = { ...(isDarkMode ? DarkTheme : DefaultTheme), @@ -254,16 +269,30 @@ export default function AppNavigator() { }, }; + if (onboardingComplete === null) { + return ; + } + return ( - + + {/* Onboarding (first launch only) */} + + {/* Main Tabs */} - + {/* Event Screens */} ; + +interface Slide { + key: string; + icon: keyof typeof Ionicons.glyphMap; + iconColor: 'forest' | 'trail' | 'sky' | 'sunrise'; + title: string; + body: string; + premium?: boolean; +} + +const SLIDES: Slide[] = [ + { + key: 'plan', + icon: 'trail-sign-outline', + iconColor: 'forest', + title: 'Plan your race', + body: 'Build your events, drop in checkpoints, and dial your pacing before the gun goes off.', + }, + { + key: 'gear', + icon: 'bag-handle-outline', + iconColor: 'trail', + title: 'Dial in your gear', + body: 'Kit lists for every race. Track what you wear and what you carry — down to the gram.', + }, + { + key: 'crew', + icon: 'people-outline', + iconColor: 'sky', + title: 'Rally your crew', + body: 'Add your people once, then assign roles per event. Pacer at mile 60, driver at dawn.', + }, + { + key: 'premium', + icon: 'sparkles', + iconColor: 'sunrise', + title: 'Race with an edge', + body: 'Sync every plan across your devices, unlock full crew features, and get race-day intelligence when it counts.', + premium: true, + }, +]; + +export default function OnboardingScreen({ navigation }: Props) { + const { theme } = useTheme(); + const { colors, spacing } = theme; + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + + const listRef = useRef>(null); + const [currentIndex, setCurrentIndex] = useState(0); + + const onViewableItemsChanged = useRef( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + if (viewableItems.length > 0 && viewableItems[0].index != null) { + setCurrentIndex(viewableItems[0].index); + } + } + ).current; + + const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 60 }).current; + + const finish = useCallback( + async (destination: 'app' | 'subscription') => { + // Persist before navigating so onboarding can never reappear + await setOnboardingComplete(); + if (destination === 'subscription') { + navigation.reset({ + index: 1, + routes: [{ name: 'Main' }, { name: 'Subscription' }], + }); + } else { + navigation.reset({ index: 0, routes: [{ name: 'Main' }] }); + } + }, + [navigation] + ); + + const handleNext = () => { + if (currentIndex < SLIDES.length - 1) { + listRef.current?.scrollToIndex({ index: currentIndex + 1, animated: true }); + } + }; + + const renderSlide = ({ item }: { item: Slide }) => { + const tint = colors[item.iconColor]; + + return ( + + + + +

+ {item.title} +

+ + {item.body} + + + {item.premium && ( + + {[ + { icon: 'cloud-done-outline' as const, label: 'Cloud sync across devices' }, + { icon: 'people-circle-outline' as const, label: 'Full crew management & notifications' }, + { icon: 'pulse-outline' as const, label: 'Live predictions & pace analysis' }, + ].map(feature => ( + + + + {feature.label} + + + ))} + + )} +
+ ); + }; + + const isLastSlide = currentIndex === SLIDES.length - 1; + + return ( + + {/* Skip */} + + {!isLastSlide ? ( + finish('app')} + style={styles.skipButton} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + Skip + + ) : ( + + )} + + + {/* Slides */} + item.key} + horizontal + pagingEnabled + showsHorizontalScrollIndicator={false} + bounces={false} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + getItemLayout={(_, index) => ({ length: width, offset: width * index, index })} + /> + + {/* Dots */} + + {SLIDES.map((slide, index) => ( + + ))} + + + {/* Actions */} + + {isLastSlide ? ( + <> + + + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + topBar: { + flexDirection: 'row', + justifyContent: 'flex-end', + paddingHorizontal: 20, + }, + skipButton: { + minWidth: 44, + minHeight: 32, + alignItems: 'flex-end', + justifyContent: 'center', + }, + slide: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 24, + }, + iconBadge: { + width: 160, + height: 160, + borderRadius: 80, + alignItems: 'center', + justifyContent: 'center', + }, + premiumList: { + marginTop: 28, + alignSelf: 'stretch', + paddingHorizontal: 32, + }, + premiumRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 12, + }, + dots: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 6, + marginBottom: 20, + }, + dot: { + height: 8, + borderRadius: 4, + }, + footer: { + paddingHorizontal: 24, + }, +}); From a52ba2e89d8370c6dedf58a1a42e4b89b23ed101 Mon Sep 17 00:00:00 2001 From: heybeaux Date: Fri, 3 Jul 2026 20:39:02 -0700 Subject: [PATCH 2/4] Move crew roles off member profiles with per-event migration --- jest.setup.js | 5 ++ src/__tests__/crewMigration.test.ts | 115 ++++++++++++++++++++++++++++ src/context/CrewContext.tsx | 45 +++++++---- src/lib/eventCrew.ts | 104 +++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/crewMigration.test.ts create mode 100644 src/lib/eventCrew.ts diff --git a/jest.setup.js b/jest.setup.js index 0258329..d5a87a2 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -14,6 +14,11 @@ jest.mock('expo-font', () => ({ loadAsync: jest.fn(), })); +// Mock AsyncStorage (native module unavailable in jest) +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +); + jest.mock('expo-haptics', () => ({ impactAsync: jest.fn(), notificationAsync: jest.fn(), diff --git a/src/__tests__/crewMigration.test.ts b/src/__tests__/crewMigration.test.ts new file mode 100644 index 0000000..da47b14 --- /dev/null +++ b/src/__tests__/crewMigration.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for the legacy crew role migration — profile roles move onto + * per-event assignments, then get stripped from member records. + */ + +import { + migrateLegacyCrewRoles, + StoredCrewMemberRecord, + EventCrewAssignment, +} from '../lib/eventCrew'; + +const baseMember = ( + overrides: Partial & { id: string } +): StoredCrewMemberRecord => ({ + name: 'Test Member', + phone: null, + email: null, + notes: null, + avatar_url: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, +}); + +describe('migrateLegacyCrewRoles', () => { + it('copies a legacy profile role onto all of that member\'s assignments', () => { + const members = [baseMember({ id: 'm1', role: 'pacer', customRole: null })]; + const assignments = [ + { eventId: 'e1', crewMemberId: 'm1' }, + { eventId: 'e2', crewMemberId: 'm1', notes: 'meet at CP3' }, + ] as unknown as EventCrewAssignment[]; + + const result = migrateLegacyCrewRoles(members, assignments); + + expect(result.changed).toBe(true); + expect(result.assignments[0].roles).toEqual(['pacer']); + expect(result.assignments[1].roles).toEqual(['pacer']); + expect(result.assignments[1].notes).toBe('meet at CP3'); + }); + + it('strips role and customRole from stored member records', () => { + const members = [ + baseMember({ id: 'm1', role: 'driver', customRole: null }), + baseMember({ id: 'm2', role: 'other', customRole: 'Chef' }), + ]; + + const result = migrateLegacyCrewRoles(members, []); + + expect(result.changed).toBe(true); + for (const member of result.members) { + expect(member).not.toHaveProperty('role'); + expect(member).not.toHaveProperty('customRole'); + } + // Other fields survive + expect(result.members[0].id).toBe('m1'); + expect(result.members[0].name).toBe('Test Member'); + }); + + it('carries a custom label onto assignments when the legacy role is "other"', () => { + const members = [baseMember({ id: 'm1', role: 'other', customRole: 'Support Runner' })]; + const assignments = [ + { eventId: 'e1', crewMemberId: 'm1' }, + ] as unknown as EventCrewAssignment[]; + + const result = migrateLegacyCrewRoles(members, assignments); + + expect(result.assignments[0].roles).toEqual(['other']); + expect(result.assignments[0].customRole).toBe('Support Runner'); + }); + + it('gives assignments an empty roles array when the member has no legacy role', () => { + const members = [baseMember({ id: 'm1' })]; + const assignments = [ + { eventId: 'e1', crewMemberId: 'm1' }, + { eventId: 'e1', crewMemberId: 'missing-member' }, + ] as unknown as EventCrewAssignment[]; + + const result = migrateLegacyCrewRoles(members, assignments); + + expect(result.assignments[0].roles).toEqual([]); + expect(result.assignments[1].roles).toEqual([]); + }); + + it('is idempotent — a second run reports no changes and alters nothing', () => { + const members = [ + baseMember({ id: 'm1', role: 'medical', customRole: null }), + baseMember({ id: 'm2', role: 'other', customRole: 'Navigator' }), + ]; + const assignments = [ + { eventId: 'e1', crewMemberId: 'm1' }, + { eventId: 'e1', crewMemberId: 'm2' }, + ] as unknown as EventCrewAssignment[]; + + const first = migrateLegacyCrewRoles(members, assignments); + expect(first.changed).toBe(true); + + const second = migrateLegacyCrewRoles(first.members, first.assignments); + expect(second.changed).toBe(false); + expect(second.members).toEqual(first.members); + expect(second.assignments).toEqual(first.assignments); + }); + + it('does not overwrite assignments that already have roles', () => { + const members = [baseMember({ id: 'm1', role: 'pacer', customRole: null })]; + const assignments: EventCrewAssignment[] = [ + { eventId: 'e1', crewMemberId: 'm1', roles: ['driver', 'medical'] }, + ]; + + const result = migrateLegacyCrewRoles(members, assignments); + + // Member still needs stripping, but the modern assignment is untouched + expect(result.changed).toBe(true); + expect(result.assignments[0].roles).toEqual(['driver', 'medical']); + }); +}); diff --git a/src/context/CrewContext.tsx b/src/context/CrewContext.tsx index 7c9017d..e5f4667 100644 --- a/src/context/CrewContext.tsx +++ b/src/context/CrewContext.tsx @@ -6,6 +6,13 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + EVENT_CREW_KEY, + migrateLegacyCrewRoles, + StoredCrewMemberRecord, + EventCrewAssignment, +} from '../lib/eventCrew'; + // Simple UUID generator for local storage const generateId = (): string => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { @@ -26,8 +33,6 @@ export interface CrewMember { name: string; phone: string | null; email: string | null; - role: CrewRole; - customRole: string | null; notes: string | null; avatar_url: string | null; created_at: string; @@ -38,8 +43,6 @@ export interface CrewMemberInsert { name: string; phone?: string | null; email?: string | null; - role?: CrewRole; - customRole?: string | null; notes?: string | null; avatar_url?: string | null; } @@ -48,8 +51,6 @@ export interface CrewMemberUpdate { name?: string; phone?: string | null; email?: string | null; - role?: CrewRole; - customRole?: string | null; notes?: string | null; avatar_url?: string | null; } @@ -106,12 +107,32 @@ export function CrewProvider({ children }: CrewProviderProps) { try { setLoading(true); setError(null); - const stored = await AsyncStorage.getItem(CREW_STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored) as CrewMember[]; + const [storedMembers, storedAssignments] = await Promise.all([ + AsyncStorage.getItem(CREW_STORAGE_KEY), + AsyncStorage.getItem(EVENT_CREW_KEY), + ]); + if (storedMembers) { + const parsed = JSON.parse(storedMembers) as StoredCrewMemberRecord[]; + const parsedAssignments: EventCrewAssignment[] = storedAssignments + ? JSON.parse(storedAssignments) + : []; + + // One-time migration: move legacy profile roles onto event assignments + const { members, assignments, changed } = migrateLegacyCrewRoles( + parsed, + parsedAssignments + ); + if (changed) { + await Promise.all([ + AsyncStorage.setItem(CREW_STORAGE_KEY, JSON.stringify(members)), + AsyncStorage.setItem(EVENT_CREW_KEY, JSON.stringify(assignments)), + ]); + } + + const loaded = members as unknown as CrewMember[]; // Sort by name alphabetically - parsed.sort((a, b) => a.name.localeCompare(b.name)); - setCrewMembers(parsed); + loaded.sort((a, b) => a.name.localeCompare(b.name)); + setCrewMembers(loaded); } } catch (err) { console.error('Failed to load crew members:', err); @@ -144,8 +165,6 @@ export function CrewProvider({ children }: CrewProviderProps) { name: memberData.name, phone: memberData.phone || null, email: memberData.email || null, - role: memberData.role || 'other', - customRole: memberData.customRole || null, notes: memberData.notes || null, avatar_url: memberData.avatar_url || null, created_at: now, diff --git a/src/lib/eventCrew.ts b/src/lib/eventCrew.ts new file mode 100644 index 0000000..111d422 --- /dev/null +++ b/src/lib/eventCrew.ts @@ -0,0 +1,104 @@ +/** + * Per-event crew assignments — shared type, storage access, and the one-time + * migration that moved roles off crew member profiles onto event assignments. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import type { CrewRole } from '../context/CrewContext'; + +export const EVENT_CREW_KEY = '@ultraedge/event-crew'; + +export interface EventCrewAssignment { + eventId: string; + crewMemberId: string; + /** A member can hold multiple roles for a single event. */ + roles: CrewRole[]; + /** Custom label used when `roles` includes 'other'. */ + customRole?: string | null; + notes?: string; +} + +export async function loadEventCrewAssignments(): Promise { + try { + const raw = await AsyncStorage.getItem(EVENT_CREW_KEY); + return raw ? (JSON.parse(raw) as EventCrewAssignment[]) : []; + } catch (err) { + console.error('Failed to load event crew assignments:', err); + return []; + } +} + +export async function saveEventCrewAssignments( + assignments: EventCrewAssignment[] +): Promise { + await AsyncStorage.setItem(EVENT_CREW_KEY, JSON.stringify(assignments)); +} + +// ============================================================================ +// LEGACY ROLE MIGRATION +// ============================================================================ + +/** + * Crew member records as they may exist on disk from older app versions, + * where `role`/`customRole` lived on the member profile. + */ +export interface StoredCrewMemberRecord { + id: string; + role?: CrewRole; + customRole?: string | null; + [key: string]: unknown; +} + +interface MigrationResult { + members: StoredCrewMemberRecord[]; + assignments: EventCrewAssignment[]; + changed: boolean; +} + +/** + * Copies each member's legacy profile role onto all of that member's event + * assignments (as a single-element `roles` array), then strips `role` and + * `customRole` from the member records. Idempotent: once members carry no + * legacy fields and assignments have `roles` arrays, this is a no-op. + */ +export function migrateLegacyCrewRoles( + members: StoredCrewMemberRecord[], + assignments: EventCrewAssignment[] +): MigrationResult { + let changed = false; + + const membersById = new Map(members.map(m => [m.id, m])); + + const migratedAssignments = assignments.map(assignment => { + if (Array.isArray(assignment.roles)) { + return assignment; + } + + changed = true; + const member = membersById.get(assignment.crewMemberId); + const legacyRole = member?.role; + + if (!legacyRole) { + return { ...assignment, roles: [] as CrewRole[] }; + } + + return { + ...assignment, + roles: [legacyRole], + customRole: + legacyRole === 'other' && member?.customRole ? member.customRole : null, + }; + }); + + const migratedMembers = members.map(member => { + if (!('role' in member) && !('customRole' in member)) { + return member; + } + changed = true; + const { role: _role, customRole: _customRole, ...rest } = member; + return rest as StoredCrewMemberRecord; + }); + + return { members: migratedMembers, assignments: migratedAssignments, changed }; +} From 98561864761333397c9b8bed2d9e4a4a5b75e631 Mon Sep 17 00:00:00 2001 From: heybeaux Date: Fri, 3 Jul 2026 20:45:10 -0700 Subject: [PATCH 3/4] Assign multiple crew roles per event across crew and event screens --- src/components/ExportRacePlanButton.tsx | 24 ++- src/screens/crew/CreateCrewScreen.tsx | 121 +---------- src/screens/crew/CrewDetailScreen.tsx | 137 +++++++++--- src/screens/crew/CrewListScreen.tsx | 167 +-------------- src/screens/crew/EditCrewScreen.tsx | 91 +------- src/screens/events/EventDetailScreen.tsx | 69 ++++-- src/screens/events/SelectCrewScreen.tsx | 262 +++++++++++++++++------ 7 files changed, 379 insertions(+), 492 deletions(-) diff --git a/src/components/ExportRacePlanButton.tsx b/src/components/ExportRacePlanButton.tsx index 70dd87d..de32221 100644 --- a/src/components/ExportRacePlanButton.tsx +++ b/src/components/ExportRacePlanButton.tsx @@ -18,6 +18,10 @@ import { Button } from './ui'; import { useEvents } from '../context/EventContext'; import { useCheckpoints } from '../context/CheckpointContext'; import { useCrewMembers, ROLE_CONFIG } from '../context/CrewContext'; +import { + EVENT_CREW_KEY, + EventCrewAssignment, +} from '../lib/eventCrew'; import { useGear } from '../context/GearContext'; import { useDropBags } from '../context/DropBagContext'; import { @@ -30,7 +34,6 @@ import { // Per-event relationship records (same storage as EventDetailScreen). const EVENT_GEAR_KEY = '@ultraedge/event-gear'; -const EVENT_CREW_KEY = '@ultraedge/event-crew'; interface EventGearAllocation { eventId: string; @@ -41,12 +44,6 @@ interface EventGearAllocation { notes?: string; } -interface EventCrewAssignment { - eventId: string; - crewMemberId: string; - notes?: string; -} - interface ExportRacePlanButtonProps { eventId: string; fullWidth?: boolean; @@ -95,10 +92,17 @@ export function ExportRacePlanButton({ eventId, fullWidth = true, style }: Expor for (const assignment of crewAssignments) { const member = getCrewMember(assignment.crewMemberId); if (!member) {continue;} + const roles = assignment.roles ?? []; const roleLabel = - member.role === 'other' && member.customRole - ? member.customRole - : ROLE_CONFIG[member.role]?.label ?? 'Crew'; + roles.length > 0 + ? roles + .map(role => + role === 'other' && assignment.customRole + ? assignment.customRole + : ROLE_CONFIG[role]?.label ?? 'Crew', + ) + .join(' / ') + : 'Crew'; crew.push({ name: member.name, role: roleLabel, diff --git a/src/screens/crew/CreateCrewScreen.tsx b/src/screens/crew/CreateCrewScreen.tsx index 08d5dab..82f630d 100644 --- a/src/screens/crew/CreateCrewScreen.tsx +++ b/src/screens/crew/CreateCrewScreen.tsx @@ -19,8 +19,8 @@ import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import { useTheme } from '../../theme'; -import { Text, H2, Body, BodySmall, Button, Card, CardContent, Label } from '../../components/ui'; -import { useCrewMembers, ROLE_CONFIG, ROLES, CrewRole } from '../../context/CrewContext'; +import { H2, Body, BodySmall, Button, Card, CardContent, Label } from '../../components/ui'; +import { useCrewMembers } from '../../context/CrewContext'; export default function CreateCrewScreen({ navigation }: any) { const { theme } = useTheme(); @@ -33,8 +33,6 @@ export default function CreateCrewScreen({ navigation }: any) { const [name, setName] = useState(''); const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); - const [role, setRole] = useState('pacer'); - const [customRole, setCustomRole] = useState(''); const [notes, setNotes] = useState(''); const [saving, setSaving] = useState(false); @@ -51,8 +49,6 @@ export default function CreateCrewScreen({ navigation }: any) { name: name.trim(), phone: phone.trim() || null, email: email.trim() || null, - role, - customRole: role === 'other' ? customRole.trim() || null : null, notes: notes.trim() || null, }); @@ -164,72 +160,6 @@ export default function CreateCrewScreen({ navigation }: any) { /> - {/* Role */} - - - - {ROLES.map((r) => { - const config = ROLE_CONFIG[r]; - const isSelected = role === r; - - return ( - { - setRole(r); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - > - - - {config.label} - - - ); - })} - - - - {/* Custom Role (if Other selected) */} - {role === 'other' && ( - - - - - )} - {/* Notes */} @@ -253,34 +183,19 @@ export default function CreateCrewScreen({ navigation }: any) { /> - {/* Role Info Card */} + {/* Roles Info Card */} - - - Role Descriptions - + + Roles are set per event - - - • Pacer — Runs alongside you during designated sections - - - • Crew Chief — Coordinates logistics at aid stations - - - • Driver — Transports crew and gear between checkpoints - - - • Medical — Provides medical support when needed - - - • Photographer — Captures your race moments - - + + Assign this person to an event to give them roles like Pacer, Crew Chief, + or Driver — they can hold different roles for different races. + @@ -320,21 +235,6 @@ const styles = StyleSheet.create({ fontSize: 16, marginTop: 8, }, - roleGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 10, - marginTop: 8, - }, - roleButton: { - width: '31%', - aspectRatio: 1, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1.5, - borderRadius: 12, - padding: 8, - }, notesInput: { minHeight: 100, paddingTop: 14, @@ -347,7 +247,4 @@ const styles = StyleSheet.create({ alignItems: 'center', marginBottom: 12, }, - roleDescriptions: { - paddingLeft: 4, - }, }); diff --git a/src/screens/crew/CrewDetailScreen.tsx b/src/screens/crew/CrewDetailScreen.tsx index f82c24c..669c3da 100644 --- a/src/screens/crew/CrewDetailScreen.tsx +++ b/src/screens/crew/CrewDetailScreen.tsx @@ -3,7 +3,7 @@ * View full details of a single crew member */ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { View, ScrollView, @@ -15,10 +15,13 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; +import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../../theme'; import { Text, H1, H3, Body, BodySmall, Button, Card, CardContent } from '../../components/ui'; import { useCrewMembers, ROLE_CONFIG } from '../../context/CrewContext'; +import { useEvents } from '../../context/EventContext'; +import { loadEventCrewAssignments, EventCrewAssignment } from '../../lib/eventCrew'; export default function CrewDetailScreen({ navigation, route }: any) { const { theme } = useTheme(); @@ -26,6 +29,7 @@ export default function CrewDetailScreen({ navigation, route }: any) { const insets = useSafeAreaInsets(); const { crewMembers, deleteCrewMember } = useCrewMembers(); + const { getEvent } = useEvents(); // Get crew member from context const member = useMemo(() => { @@ -33,6 +37,23 @@ export default function CrewDetailScreen({ navigation, route }: any) { return crewMembers.find((m) => m.id === crewId) || null; }, [route.params?.crewId, crewMembers]); + // Per-event assignments for this member + const [assignments, setAssignments] = useState([]); + + useFocusEffect( + useCallback(() => { + let active = true; + loadEventCrewAssignments().then(all => { + if (active) { + setAssignments(all.filter(a => a.crewMemberId === route.params?.crewId)); + } + }); + return () => { + active = false; + }; + }, [route.params?.crewId]) + ); + if (!member) { return ( @@ -55,11 +76,6 @@ export default function CrewDetailScreen({ navigation, route }: any) { ); } - const roleConfig = ROLE_CONFIG[member.role]; - const displayRole = member.role === 'other' && member.customRole - ? member.customRole - : roleConfig.label; - // Get initials for avatar const getInitials = (name: string): string => { const parts = name.trim().split(' '); @@ -137,25 +153,17 @@ export default function CrewDetailScreen({ navigation, route }: any) { {/* Hero Section */} {/* Avatar */} - + {member.avatar_url ? ( - + ) : ( - + {getInitials(member.name)} )}

{member.name}

- - {/* Role Badge */} - - - - {displayRole} - -
{/* Quick Actions */} @@ -234,20 +242,71 @@ export default function CrewDetailScreen({ navigation, route }: any) {
)} - {/* Events Section (Future Feature Placeholder) */} + {/* Assigned Events with per-event roles */}

Assigned Events

- - - - Event assignments coming soon! - - - You'll be able to assign crew members to specific events and checkpoints. - - + {assignments.length === 0 ? ( + + + + Not assigned to any events yet. + + + Assign {member.name.split(' ')[0]} to an event to set their roles for race day. + + + ) : ( + assignments.map((assignment, index) => { + const event = getEvent(assignment.eventId); + const roles = assignment.roles ?? []; + + return ( + + + {event?.name ?? 'Unknown event'} + + + {roles.length === 0 ? ( + No roles set + ) : ( + roles.map(role => { + const config = ROLE_CONFIG[role]; + const label = + role === 'other' && assignment.customRole + ? assignment.customRole + : config.label; + return ( + + + + {label} + + + ); + }) + )} + + + ); + }) + )}
@@ -323,14 +382,6 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - roleBadge: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - marginTop: 12, - }, quickActions: { flexDirection: 'row', justifyContent: 'center', @@ -368,6 +419,22 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: 16, }, + eventRow: { + paddingVertical: 12, + }, + roleChips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginTop: 6, + }, + roleChip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + }, actionsSection: { marginTop: 8, }, diff --git a/src/screens/crew/CrewListScreen.tsx b/src/screens/crew/CrewListScreen.tsx index a5f0c3d..a4b00a1 100644 --- a/src/screens/crew/CrewListScreen.tsx +++ b/src/screens/crew/CrewListScreen.tsx @@ -6,7 +6,6 @@ import React, { useState, useMemo, useCallback } from 'react'; import { View, - ScrollView, StyleSheet, RefreshControl, TextInput, @@ -19,7 +18,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../../theme'; import { Text, H1, H2, H3, Body, BodySmall, Button, Card, CardContent } from '../../components/ui'; -import { useCrewMembers, ROLE_CONFIG, ROLES, CrewRole } from '../../context/CrewContext'; +import { useCrewMembers } from '../../context/CrewContext'; export default function CrewListScreen({ navigation }: any) { const { theme } = useTheme(); @@ -30,7 +29,6 @@ export default function CrewListScreen({ navigation }: any) { const [refreshing, setRefreshing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [selectedRole, setSelectedRole] = useState('all'); // Refresh data when screen comes into focus useFocusEffect( @@ -53,13 +51,8 @@ export default function CrewListScreen({ navigation }: any) { ); } - // Apply role filter - if (selectedRole !== 'all') { - filtered = filtered.filter((member) => member.role === selectedRole); - } - return filtered; - }, [crewMembers, searchQuery, selectedRole]); + }, [crewMembers, searchQuery]); const onRefresh = useCallback(async () => { setRefreshing(true); @@ -75,11 +68,6 @@ export default function CrewListScreen({ navigation }: any) { }; const renderCrewMember = ({ item }: { item: typeof crewMembers[0] }) => { - const roleConfig = ROLE_CONFIG[item.role]; - const displayRole = item.role === 'other' && item.customRole - ? item.customRole - : roleConfig.label; - return ( {/* Avatar */} - + {item.avatar_url ? ( - + ) : ( - + {getInitials(item.name)} )} @@ -102,24 +90,9 @@ export default function CrewListScreen({ navigation }: any) { {/* Info */} {item.name} - - - - - {displayRole} - - - - {item.phone && ( - - {item.phone} + {(item.phone || item.email) && ( + + {item.phone || item.email} )} @@ -181,33 +154,6 @@ export default function CrewListScreen({ navigation }: any) { - - {ROLES.slice(0, 3).map((role) => { - const count = crewMembers.filter(m => m.role === role).length; - if (count === 0) return null; - return ( - - - - {count} - - - ); - })} - @@ -230,67 +176,10 @@ export default function CrewListScreen({ navigation }: any) { )}
- {/* Role Filter Pills */} - - setSelectedRole('all')} - > - - All - - - - {ROLES.map((role) => { - const config = ROLE_CONFIG[role]; - const isSelected = selectedRole === role; - - return ( - setSelectedRole(role)} - > - - - {config.label} - - - ); - })} -
{/* Crew List */} - {crewMembers.length === 0 && !searchQuery && selectedRole === 'all' ? ( + {crewMembers.length === 0 && !searchQuery ? ( renderEmptyState() ) : ( (originalMember?.role || 'pacer'); - const [customRole, setCustomRole] = useState(originalMember?.customRole || ''); const [notes, setNotes] = useState(originalMember?.notes || ''); const [saving, setSaving] = useState(false); @@ -72,8 +70,6 @@ export default function EditCrewScreen({ navigation, route }: any) { name !== originalMember.name || phone !== (originalMember.phone || '') || email !== (originalMember.email || '') || - role !== originalMember.role || - customRole !== (originalMember.customRole || '') || notes !== (originalMember.notes || ''); const handleSave = async () => { @@ -87,8 +83,6 @@ export default function EditCrewScreen({ navigation, route }: any) { name: name.trim(), phone: phone.trim() || null, email: email.trim() || null, - role, - customRole: role === 'other' ? customRole.trim() || null : null, notes: notes.trim() || null, }); @@ -233,72 +227,6 @@ export default function EditCrewScreen({ navigation, route }: any) { />
- {/* Role */} - - - - {ROLES.map((r) => { - const config = ROLE_CONFIG[r]; - const isSelected = role === r; - - return ( - { - setRole(r); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - > - - - {config.label} - - - ); - })} - - - - {/* Custom Role (if Other selected) */} - {role === 'other' && ( - - - - - )} - {/* Notes */} @@ -378,21 +306,6 @@ const styles = StyleSheet.create({ fontSize: 16, marginTop: 8, }, - roleGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 10, - marginTop: 8, - }, - roleButton: { - width: '31%', - aspectRatio: 1, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1.5, - borderRadius: 12, - padding: 8, - }, notesInput: { minHeight: 100, paddingTop: 14, diff --git a/src/screens/events/EventDetailScreen.tsx b/src/screens/events/EventDetailScreen.tsx index 8b18da9..8c1a22d 100644 --- a/src/screens/events/EventDetailScreen.tsx +++ b/src/screens/events/EventDetailScreen.tsx @@ -41,15 +41,15 @@ import { useGear, GearItem } from '../../context/GearContext'; import { useCrewMembers, CrewMember, ROLE_CONFIG } from '../../context/CrewContext'; import { useDropBags } from '../../context/DropBagContext'; import { Event, EventStatus, EventUpdate, Checkpoint } from '../../lib/database.types'; +import { EVENT_CREW_KEY, EventCrewAssignment } from '../../lib/eventCrew'; import { eventStatsFromRoute } from '../../lib/gpx'; import GPXRouteSection from '../../components/gpx/GPXRouteSection'; import ExportRacePlanButton from '../../components/ExportRacePlanButton'; type Props = NativeStackScreenProps; -// Storage keys for event relationships +// Storage key for event gear relationships const EVENT_GEAR_KEY = '@ultraedge/event-gear'; -const EVENT_CREW_KEY = '@ultraedge/event-crew'; // Types for event relationships interface EventGearAllocation { @@ -61,12 +61,6 @@ interface EventGearAllocation { notes?: string; } -interface EventCrewAssignment { - eventId: string; - crewMemberId: string; - notes?: string; -} - export default function EventDetailScreen({ navigation, route }: Props) { const { theme, isDarkMode } = useTheme(); const { colors, spacing, radius } = theme; @@ -455,8 +449,10 @@ export default function EventDetailScreen({ navigation, route }: Props) { { assignment, member }: { assignment: EventCrewAssignment; member: CrewMember }, index: number ) => { - const roleInfo = ROLE_CONFIG[member.role]; - + const roles = assignment.roles ?? []; + const primaryRoleInfo = roles.length > 0 ? ROLE_CONFIG[roles[0]] : null; + const iconColor = primaryRoleInfo?.color ?? colors.trail; + return ( navigation.navigate('CrewDetail', { crewId: member.id })} style={[ styles.listItem, - { + { backgroundColor: colors.surface, borderBottomColor: colors.border, borderBottomWidth: index < eventCrewMembers.length - 1 ? 1 : 0, } ]} > - - + + {member.name} - - {member.customRole || roleInfo.label} - {member.phone ? ` • ${member.phone}` : ''} - + {roles.length > 0 ? ( + + {roles.map(role => { + const config = ROLE_CONFIG[role]; + const label = + role === 'other' && assignment.customRole + ? assignment.customRole + : config.label; + return ( + + + {label} + + + ); + })} + + ) : ( + + {member.phone || 'No roles set'} + + )} @@ -954,6 +978,17 @@ const styles = StyleSheet.create({ flex: 1, marginRight: 8, }, + crewRoleChips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + marginTop: 3, + }, + crewRoleChip: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 6, + }, deleteAction: { width: 70, justifyContent: 'center', diff --git a/src/screens/events/SelectCrewScreen.tsx b/src/screens/events/SelectCrewScreen.tsx index 3b45859..26c5def 100644 --- a/src/screens/events/SelectCrewScreen.tsx +++ b/src/screens/events/SelectCrewScreen.tsx @@ -1,6 +1,7 @@ /** * UltraEdge Select Crew Screen - * Select existing crew members to assign to an event + * Select existing crew members to assign to an event, with per-event roles. + * A member can hold multiple roles for one event. */ import React, { useState, useEffect } from 'react'; @@ -8,61 +9,55 @@ import { View, StyleSheet, FlatList, + TextInput, TouchableOpacity, Alert, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Haptics from 'expo-haptics'; import { useTheme } from '../../theme'; -import { - H1, - H3, - Body, - BodySmall, - Button, +import { + Text, + H1, + H3, + Body, + BodySmall, + Button, } from '../../components/ui'; -import { useCrewMembers, CrewMember, ROLE_CONFIG } from '../../context/CrewContext'; +import { useCrewMembers, CrewMember, CrewRole, ROLES, ROLE_CONFIG } from '../../context/CrewContext'; +import { + EventCrewAssignment, + loadEventCrewAssignments, + saveEventCrewAssignments, +} from '../../lib/eventCrew'; type Props = NativeStackScreenProps; -const EVENT_CREW_KEY = '@ultraedge/event-crew'; - -interface EventCrewAssignment { - eventId: string; - crewMemberId: string; - notes?: string; -} - export default function SelectCrewScreen({ navigation, route }: Props) { const { theme } = useTheme(); const { colors, spacing } = theme; const insets = useSafeAreaInsets(); const { crewMembers } = useCrewMembers(); - + const eventId = route.params?.eventId; - + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [rolesById, setRolesById] = useState>({}); + const [customRoleById, setCustomRoleById] = useState>({}); const [alreadyAddedIds, setAlreadyAddedIds] = useState>(new Set()); const [saving, setSaving] = useState(false); // Load already assigned crew useEffect(() => { const loadExisting = async () => { - try { - const stored = await AsyncStorage.getItem(EVENT_CREW_KEY); - if (stored) { - const allCrew: EventCrewAssignment[] = JSON.parse(stored); - const eventCrewIds = new Set( - allCrew.filter(c => c.eventId === eventId).map(c => c.crewMemberId) - ); - setAlreadyAddedIds(eventCrewIds); - } - } catch (error) { - console.error('Failed to load existing crew:', error); - } + const allCrew = await loadEventCrewAssignments(); + const eventCrewIds = new Set( + allCrew.filter(c => c.eventId === eventId).map(c => c.crewMemberId) + ); + setAlreadyAddedIds(eventCrewIds); }; loadExisting(); }, [eventId]); @@ -70,7 +65,7 @@ export default function SelectCrewScreen({ navigation, route }: Props) { // Filter out already-assigned crew const availableCrew = crewMembers.filter(c => !alreadyAddedIds.has(c.id)); - // Toggle selection + // Toggle member selection const toggleSelection = (id: string) => { setSelectedIds(prev => { const next = new Set(prev); @@ -83,6 +78,18 @@ export default function SelectCrewScreen({ navigation, route }: Props) { }); }; + // Toggle a role on a selected member + const toggleRole = (memberId: string, role: CrewRole) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setRolesById(prev => { + const current = prev[memberId] ?? []; + const next = current.includes(role) + ? current.filter(r => r !== role) + : [...current, role]; + return { ...prev, [memberId]: next }; + }); + }; + // Save selections const handleSave = async () => { if (selectedIds.size === 0) { @@ -92,18 +99,25 @@ export default function SelectCrewScreen({ navigation, route }: Props) { setSaving(true); try { - const stored = await AsyncStorage.getItem(EVENT_CREW_KEY); - const allCrew: EventCrewAssignment[] = stored ? JSON.parse(stored) : []; - - // Add new assignments - const newAssignments: EventCrewAssignment[] = Array.from(selectedIds).map(crewMemberId => ({ - eventId, - crewMemberId, - })); - - const updated = [...allCrew, ...newAssignments]; - await AsyncStorage.setItem(EVENT_CREW_KEY, JSON.stringify(updated)); - + const allCrew = await loadEventCrewAssignments(); + + // Add new assignments with their per-event roles + const newAssignments: EventCrewAssignment[] = Array.from(selectedIds).map( + crewMemberId => { + const roles = rolesById[crewMemberId] ?? []; + return { + eventId, + crewMemberId, + roles, + customRole: roles.includes('other') + ? customRoleById[crewMemberId]?.trim() || null + : null, + }; + } + ); + + await saveEventCrewAssignments([...allCrew, ...newAssignments]); + navigation.goBack(); } catch (error) { console.error('Failed to save crew:', error); @@ -113,38 +127,114 @@ export default function SelectCrewScreen({ navigation, route }: Props) { } }; + // Get initials for avatar + const getInitials = (name: string): string => { + const parts = name.trim().split(' '); + if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); + return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); + }; + // Render crew item const renderCrewItem = ({ item }: { item: CrewMember }) => { const isSelected = selectedIds.has(item.id); - const roleInfo = ROLE_CONFIG[item.role]; - + const memberRoles = rolesById[item.id] ?? []; + return ( - toggleSelection(item.id)} + - - - - - {item.name} - - {item.customRole || roleInfo.label} - {item.phone ? ` • ${item.phone}` : ''} - - - - + toggleSelection(item.id)} style={styles.memberRow}> + + + {getInitials(item.name)} + + + + {item.name} + {(item.phone || item.email) && ( + + {item.phone || item.email} + + )} + + + + + {/* Per-event role chips (multi-select) */} + {isSelected && ( + + + Roles for this event + + + {ROLES.map(role => { + const config = ROLE_CONFIG[role]; + const isActive = memberRoles.includes(role); + + return ( + toggleRole(item.id, role)} + style={[ + styles.roleChip, + { + backgroundColor: isActive ? config.color : colors.cream, + borderColor: isActive ? config.color : colors.border, + }, + ]} + > + + + {config.label} + + + ); + })} + + + {/* Custom label when "Other" is included */} + {memberRoles.includes('other') && ( + + setCustomRoleById(prev => ({ ...prev, [item.id]: text })) + } + autoCapitalize="words" + /> + )} + + )} + ); }; @@ -156,7 +246,7 @@ export default function SelectCrewScreen({ navigation, route }: Props) { No Crew Available - {alreadyAddedIds.size > 0 + {alreadyAddedIds.size > 0 ? 'All your crew members are already assigned to this event.' : 'Create some crew members first, then come back to assign them.'} @@ -187,8 +277,8 @@ export default function SelectCrewScreen({ navigation, route }: Props) {

Select Crew

- @@ -208,6 +298,7 @@ export default function SelectCrewScreen({ navigation, route }: Props) { availableCrew.length === 0 && styles.emptyListContent, ]} showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" ListEmptyComponent={renderEmptyState} ItemSeparatorComponent={() => } /> @@ -247,12 +338,14 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, listItem: { - flexDirection: 'row', - alignItems: 'center', padding: 16, borderRadius: 12, borderWidth: 2, }, + memberRow: { + flexDirection: 'row', + alignItems: 'center', + }, listItemIcon: { width: 40, height: 40, @@ -265,6 +358,33 @@ const styles = StyleSheet.create({ flex: 1, marginRight: 12, }, + rolesSection: { + marginTop: 12, + paddingTop: 12, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(0,0,0,0.08)', + }, + roleChips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + roleChip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + }, + customRoleInput: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + marginTop: 10, + }, emptyState: { alignItems: 'center', justifyContent: 'center', From 5921775994a848a7d33dfd2d7dc6cb191ce40731 Mon Sep 17 00:00:00 2001 From: heybeaux Date: Fri, 3 Jul 2026 20:45:41 -0700 Subject: [PATCH 4/4] Add tests for onboarding completion flag --- src/__tests__/onboarding.test.ts | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/__tests__/onboarding.test.ts diff --git a/src/__tests__/onboarding.test.ts b/src/__tests__/onboarding.test.ts new file mode 100644 index 0000000..ee77eea --- /dev/null +++ b/src/__tests__/onboarding.test.ts @@ -0,0 +1,34 @@ +/** + * Tests for the first-launch onboarding flag. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { + isOnboardingComplete, + setOnboardingComplete, + ONBOARDING_COMPLETE_KEY, +} from '../lib/onboarding'; + +describe('onboarding flag', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + }); + + it('reports incomplete on first launch', async () => { + expect(await isOnboardingComplete()).toBe(false); + }); + + it('reports complete after the flag is set', async () => { + await setOnboardingComplete(); + expect(await isOnboardingComplete()).toBe(true); + expect(await AsyncStorage.getItem(ONBOARDING_COMPLETE_KEY)).toBe('true'); + }); + + it('defaults to complete if storage is unreadable', async () => { + jest + .spyOn(AsyncStorage, 'getItem') + .mockRejectedValueOnce(new Error('storage unavailable')); + expect(await isOnboardingComplete()).toBe(true); + }); +});