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
29 changes: 28 additions & 1 deletion apps/mobile/src/app/history/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* shows the car's nickname when the Garage knows it, else the shortened UID.
*/
import { useCallback, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { FlatList, Pressable, Share, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Link, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';

Expand All @@ -14,6 +14,7 @@ import { getSessionRepository } from '@/store/persistence/historyAccess';
import type { SessionPass, SessionSummary } from '@/store/persistence/sessionRepository';
import { useSettingsStore } from '@/store/settingsStore';
import { speedUnitLabel } from '@/speed/format';
import { sessionShareText } from '@/share/summary';
import { carLabel, shortUid } from '@/garage/format';
import { colors, fontSize, fontWeight, radius, spacing } from '@/theme/tokens';
import {
Expand Down Expand Up @@ -65,6 +66,19 @@ export default function SessionDetailScreen() {
return car ? carLabel(car) : shortUid(uid);
};

const canShare = !!session && !!passes && passes.length > 0;
const onShare = () => {
if (!session || !passes) return;
const carNames = new Map(
cars
.filter((c) => c.name?.trim())
.map((c) => [c.uid, c.name!.trim()] as const),
);
Share.share({
message: sessionShareText(session, passes, { display: speedDisplay, carNames }),
}).catch(() => {});
};

return (
<View style={[styles.screen, { paddingTop: insets.top + spacing(2) }]}>
<View style={styles.header}>
Expand All @@ -75,6 +89,16 @@ export default function SessionDetailScreen() {
>
<Text style={styles.backText}>‹ History</Text>
</Pressable>
<View style={styles.headerSpacer} />
{canShare && (
<Pressable
hitSlop={12}
onPress={onShare}
style={({ pressed }) => [styles.share, pressed && styles.pressed]}
>
<Text style={styles.shareText}>Share</Text>
</Pressable>
)}
</View>

<View style={styles.summary}>
Expand Down Expand Up @@ -147,6 +171,9 @@ const styles = StyleSheet.create({
},
back: { paddingVertical: spacing(1), paddingRight: spacing(1) },
backText: { color: colors.accentBlue, fontSize: fontSize.md, fontWeight: fontWeight.medium },
headerSpacer: { flex: 1 },
share: { paddingVertical: spacing(1), paddingLeft: spacing(1) },
shareText: { color: colors.accent, fontSize: fontSize.md, fontWeight: fontWeight.bold },
summary: { paddingHorizontal: spacing(5), paddingBottom: spacing(3), gap: 4 },
summaryDate: { color: colors.textPrimary, fontSize: fontSize.xl, fontWeight: fontWeight.heavy },
summaryMeta: { color: colors.textSecondary, fontSize: fontSize.sm },
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/app/race.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Platform,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
Expand All @@ -33,8 +34,10 @@ import { useReducedMotion } from 'react-native-reanimated';

import { LAP_OPTIONS, currentLapElapsed, type RaceResult } from '@/race/raceEngine';
import { useRaceStore } from '@/store/raceStore';
import { useGarageStore } from '@/store/garageStore';
import { usePortalStore } from '@/store/portalStore';
import { useSettingsStore } from '@/store/settingsStore';
import { raceShareText } from '@/share/summary';
import { getActiveTransportControls } from '@/transport/active';
import { colors, fontSize, fontWeight, radius, spacing } from '@/theme/tokens';

Expand Down Expand Up @@ -369,6 +372,13 @@ function LapList({ lapTimes, bestLap }: { lapTimes: readonly number[]; bestLap:
}

function Results({ result, onAgain }: { result: RaceResult; onAgain: () => void }) {
const carName = useGarageStore((s) => s.cars.find((c) => c.uid === result.carUid)?.name ?? null);

const onShare = () => {
haptic(() => Haptics.selectionAsync());
Share.share({ message: raceShareText(result, { carName }) }).catch(() => {});
};

return (
<View style={styles.section}>
<View style={styles.resultHero}>
Expand All @@ -387,6 +397,13 @@ function Results({ result, onAgain }: { result: RaceResult; onAgain: () => void

<LapList lapTimes={result.lapTimes} bestLap={result.bestLap} />

<Pressable
onPress={onShare}
style={({ pressed }) => [styles.shareBtn, pressed && styles.pressed]}
>
<Text style={styles.shareBtnText}>Share result</Text>
</Pressable>

<View style={styles.actionRow}>
<Pressable
onPress={onAgain}
Expand Down Expand Up @@ -577,6 +594,16 @@ const styles = StyleSheet.create({

actionRow: { flexDirection: 'row', gap: spacing(2) },
flex1: { flex: 1 },
shareBtn: {
backgroundColor: colors.surfaceAlt,
borderColor: colors.border,
borderWidth: 1,
borderRadius: radius.md,
paddingVertical: spacing(3),
alignItems: 'center',
marginBottom: spacing(2),
},
shareBtnText: { color: colors.accent, fontSize: fontSize.md, fontWeight: fontWeight.bold },
ghostBtn: {
backgroundColor: colors.surfaceAlt,
borderColor: colors.border,
Expand Down
155 changes: 155 additions & 0 deletions apps/mobile/src/share/summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';

import type { RaceResult } from '../race/raceEngine';
import type { SessionPass, SessionSummary } from '../store/persistence/sessionRepository';
import { formatLapTime, raceShareText, sessionShareText } from './summary';

const EM_DASH = '\u2014';
const FLASH = '\u26A1';

function makeResult(over: Partial<RaceResult> = {}): RaceResult {
return {
player: 'Ben',
carUid: '6C:C4:5A:2B:64:81',
lapCount: 3,
lapTimes: [2.5, 2.1, 2.8],
totalTime: 7.4,
bestLap: 2.1,
bestLapNum: 2,
worstLap: 2.8,
worstLapNum: 3,
avgLap: 2.47,
finishedAt: 1_700_000_000_000,
...over,
};
}

function makeSession(over: Partial<SessionSummary> = {}): SessionSummary {
return {
id: 1,
startedAt: 1_700_000_000_000,
endedAt: 1_700_000_090_000,
passCount: 3,
bestMph: 250,
...over,
};
}

function makePass(over: Partial<SessionPass> = {}): SessionPass {
return {
id: 1,
sessionId: 1,
carUid: '6C:C4:5A:2B:64:81',
serial: null,
raw: 0,
scaleMph: 100,
at: 1_700_000_000_000,
...over,
};
}

describe('formatLapTime', () => {
it('formats sub-minute as SS.sss', () => {
expect(formatLapTime(2.1)).toBe('2.10s');
});

it('rolls over past a minute to M:SS.ss', () => {
expect(formatLapTime(65)).toBe('1:05.00');
});

it('returns an em dash for non-finite input', () => {
expect(formatLapTime(Number.NaN)).toBe(EM_DASH);
expect(formatLapTime(Number.POSITIVE_INFINITY)).toBe(EM_DASH);
});
});

describe('raceShareText', () => {
it('summarizes a multi-lap race with splits and a lap list', () => {
const text = raceShareText(makeResult());
expect(text).toContain('Ben finished 3 laps in 7.40s');
expect(text).toContain('Car: 64:81'); // falls back to short UID
expect(text).toContain('Best lap 2.10s (lap 2)');
expect(text).toContain('Average 2.47s');
expect(text).toContain('Worst lap 2.80s (lap 3)');
expect(text).toContain('Laps:');
expect(text).toContain('via RedlineID');
});

it('flags the fastest lap in the lap list', () => {
const lines = raceShareText(makeResult()).split('\n');
expect(lines).toContain(` 2. 2.10s ${FLASH}`);
expect(lines).toContain(' 1. 2.50s');
expect(lines).toContain(' 3. 2.80s');
});

it('uses the singular lap label for a one-lap race', () => {
const text = raceShareText(makeResult({ lapCount: 1, lapTimes: [3.2], bestLapNum: 1, worstLapNum: 1 }));
expect(text).toContain('finished 1 lap in');
});

it('prefers a provided car nickname over the UID', () => {
const text = raceShareText(makeResult(), { carName: 'Twin Mill' });
expect(text).toContain('Car: Twin Mill');
expect(text).not.toContain('64:81');
});

it('omits the lap list when there are no recorded laps', () => {
const text = raceShareText(makeResult({ lapTimes: [] }));
expect(text).not.toContain('Laps:');
expect(text).toContain('via RedlineID');
});
});

describe('sessionShareText', () => {
const passes = [
makePass({ id: 1, scaleMph: 100, carUid: 'AA' }),
makePass({ id: 2, scaleMph: 250, carUid: 'BB' }),
makePass({ id: 3, scaleMph: 180, carUid: null }),
];

it('recaps a session in mph with the fastest passes first', () => {
const text = sessionShareText(makeSession(), passes, {
carNames: new Map([['BB', 'Bone Shaker']]),
});
expect(text).toContain('Race session');
expect(text).toContain('3 passes');
expect(text).toContain('Best 250 mph');
const lines = text.split('\n');
expect(lines).toContain('Top 3 passes:');
expect(lines).toContain(' 1. 250 mph \u2014 Bone Shaker'); // nickname from map
expect(lines).toContain(' 2. 180 mph \u2014 Unknown car'); // null carUid
expect(lines).toContain(' 3. 100 mph \u2014 AA'); // short UID fallback
expect(text).toContain('via RedlineID');
});

it('converts speeds and unit label to km/h', () => {
const text = sessionShareText(makeSession({ bestMph: 200 }), [makePass({ scaleMph: 200, carUid: 'AA' })], {
display: { unit: 'kmh', calibration: 1 },
});
expect(text).toContain('Best 322 km/h'); // 200 * 1.609344 = 321.87 -> 322
expect(text).toContain(' 1. 322 km/h \u2014 AA');
});

it('applies a calibration trim to shared speeds', () => {
const text = sessionShareText(makeSession({ bestMph: 200 }), [], {
display: { unit: 'mph', calibration: 1.1 },
});
expect(text).toContain('Best 220 mph'); // 200 * 1.1
});

it('handles an empty session: em-dash best, no top list', () => {
const text = sessionShareText(makeSession({ passCount: 0, bestMph: 0 }), []);
expect(text).toContain(`Best ${EM_DASH} mph`);
expect(text).not.toContain('Top');
expect(text).toContain('via RedlineID');
});

it('respects topN and singular labels', () => {
const text = sessionShareText(makeSession({ passCount: 1 }), passes, { topN: 1 });
expect(text).toContain('1 pass');
const lines = text.split('\n');
expect(lines).toContain('Top pass:');
expect(lines).toContain(' 1. 250 mph \u2014 BB');
expect(lines).not.toContain(' 2. 180 mph \u2014 Unknown car');
});
});
100 changes: 100 additions & 0 deletions apps/mobile/src/share/summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Pure builders for the human-shareable race & session summaries handed to the
* native Share sheet. Framework-free so they unit test under Node and stay
* consistent with what each screen renders (lap times mirror race.tsx's clock;
* speeds run through the same speed/format converter as every readout, so a
* shared number matches what the user sees for their chosen unit + calibration).
*/
import type { RaceResult } from '../race/raceEngine';
import type { SessionPass, SessionSummary } from '../store/persistence/sessionRepository';
import { shortUid } from '../garage/format';
import { formatDuration, formatSessionDate } from '../history/format';
import {
formatBestSpeed,
formatSpeedValue,
speedUnitLabel,
type SpeedDisplay,
} from '../speed/format';

const SIGNATURE = 'via RedlineID';

/** Lap/total time as `SS.ss`s, rolling over to `M:SS.ss` past a minute (mirrors race.tsx). */
export function formatLapTime(seconds: number): string {
if (!Number.isFinite(seconds)) return '\u2014';
if (seconds >= 60) {
const m = Math.floor(seconds / 60);
return `${m}:${(seconds - m * 60).toFixed(2).padStart(5, '0')}`;
}
return `${seconds.toFixed(2)}s`;
}

export interface RaceShareOpts {
/** The car's nickname, when known; falls back to the shortened UID. */
readonly carName?: string | null;
}

/** Celebratory recap of a finished race: total time, splits, and lap-by-lap. */
export function raceShareText(result: RaceResult, opts: RaceShareOpts = {}): string {
const car = opts.carName?.trim() ? opts.carName.trim() : shortUid(result.carUid);
const laps = result.lapCount === 1 ? '1 lap' : `${result.lapCount} laps`;

const lines: string[] = [
`\u{1F3C1} ${result.player} finished ${laps} in ${formatLapTime(result.totalTime)}`,
`Car: ${car}`,
'',
`Best lap ${formatLapTime(result.bestLap)} (lap ${result.bestLapNum})`,
`Average ${formatLapTime(result.avgLap)}`,
`Worst lap ${formatLapTime(result.worstLap)} (lap ${result.worstLapNum})`,
];

if (result.lapTimes.length > 0) {
lines.push('', 'Laps:');
result.lapTimes.forEach((t, i) => {
const flash = i + 1 === result.bestLapNum ? ' \u26A1' : '';
lines.push(` ${i + 1}. ${formatLapTime(t)}${flash}`);
});
}

lines.push('', SIGNATURE);
return lines.join('\n');
}

export interface SessionShareOpts {
/** Unit + calibration so shared speeds match the on-screen readouts. */
readonly display?: SpeedDisplay;
/** Optional car nicknames keyed by UID, used to label the top passes. */
readonly carNames?: ReadonlyMap<string, string>;
/** How many of the fastest passes to list (default 3). */
readonly topN?: number;
}

/** Recap of a saved session: date, pass count, duration, best, and the fastest passes. */
export function sessionShareText(
session: SessionSummary,
passes: readonly SessionPass[],
opts: SessionShareOpts = {},
): string {
const { display, carNames, topN = 3 } = opts;
const unit = speedUnitLabel(display?.unit ?? 'mph');
const passLabel = session.passCount === 1 ? '1 pass' : `${session.passCount} passes`;

const lines: string[] = [
`\u{1F3CE}\uFE0F Race session \u2014 ${formatSessionDate(session.startedAt)}`,
`${passLabel} \u00b7 ${formatDuration(session.startedAt, session.endedAt)}`,
`Best ${formatBestSpeed(session.bestMph, display)} ${unit}`,
];

const top = [...passes].sort((a, b) => b.scaleMph - a.scaleMph).slice(0, Math.max(0, topN));
if (top.length > 0) {
lines.push('', top.length === 1 ? 'Top pass:' : `Top ${top.length} passes:`);
top.forEach((p, i) => {
const name = p.carUid
? carNames?.get(p.carUid)?.trim() || shortUid(p.carUid)
: 'Unknown car';
lines.push(` ${i + 1}. ${formatSpeedValue(p.scaleMph, display)} ${unit} \u2014 ${name}`);
});
}

lines.push('', SIGNATURE);
return lines.join('\n');
}
Loading
Loading