Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
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')

Check warning on line 19 in jest.setup.js

View workflow job for this annotation

GitHub Actions / ci

Missing trailing comma
);

jest.mock('expo-haptics', () => ({
impactAsync: jest.fn(),
notificationAsync: jest.fn(),
Expand Down
115 changes: 115 additions & 0 deletions src/__tests__/crewMigration.test.ts
Original file line number Diff line number Diff line change
@@ -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<StoredCrewMemberRecord> & { id: string }

Check warning on line 13 in src/__tests__/crewMigration.test.ts

View workflow job for this annotation

GitHub Actions / ci

Missing trailing comma
): 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']);
});
});
34 changes: 34 additions & 0 deletions src/__tests__/onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 14 additions & 10 deletions src/components/ExportRacePlanButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -41,12 +44,6 @@ interface EventGearAllocation {
notes?: string;
}

interface EventCrewAssignment {
eventId: string;
crewMemberId: string;
notes?: string;
}

interface ExportRacePlanButtonProps {
eventId: string;
fullWidth?: boolean;
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 32 additions & 13 deletions src/context/CrewContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading