From 80ac109ee32d1ada0c705a61a0f848ba80e20c4b Mon Sep 17 00:00:00 2001 From: burkben <87715724+burkben@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:44:09 -0700 Subject: [PATCH] Share race & session results to the native share sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Phase 5 "share results": a Share button on the finished-race view and on a History session detail, each handing a clean text recap to the OS share sheet (Messages, Notes, AirDrop, etc.). - New pure apps/mobile/src/share/summary.ts (+13 tests): raceShareText (total time, best/avg/worst splits, lap-by-lap with the fastest lap flagged) and sessionShareText (date, pass count, duration, best, fastest passes). Speeds run through the same speed/format converter as every readout, so shared numbers honor the user's unit + calibration; lap times mirror race.tsx. - race.tsx Results: "Share result" button (RN Share API), resolving the car's garage nickname; honors the haptics setting. - history/[id].tsx: header "Share" action (shown once a session has passes), passing the current speed display + car nickname map. - Uses React Native's built-in Share — no new native module. Also refreshed stale ROADMAP Phase 5 checkboxes (Achievements #23, speed units #25, this as #26). 178 vitest pass; tsc --noEmit clean; iOS export clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/mobile/src/app/history/[id].tsx | 29 ++++- apps/mobile/src/app/race.tsx | 27 +++++ apps/mobile/src/share/summary.test.ts | 155 ++++++++++++++++++++++++++ apps/mobile/src/share/summary.ts | 100 +++++++++++++++++ docs/ROADMAP.md | 7 +- 5 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/share/summary.test.ts create mode 100644 apps/mobile/src/share/summary.ts diff --git a/apps/mobile/src/app/history/[id].tsx b/apps/mobile/src/app/history/[id].tsx index d619813..fdb05f3 100644 --- a/apps/mobile/src/app/history/[id].tsx +++ b/apps/mobile/src/app/history/[id].tsx @@ -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'; @@ -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 { @@ -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 ( @@ -75,6 +89,16 @@ export default function SessionDetailScreen() { > ‹ History + + {canShare && ( + [styles.share, pressed && styles.pressed]} + > + Share + + )} @@ -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 }, diff --git a/apps/mobile/src/app/race.tsx b/apps/mobile/src/app/race.tsx index ddb6a39..0f2baf8 100644 --- a/apps/mobile/src/app/race.tsx +++ b/apps/mobile/src/app/race.tsx @@ -21,6 +21,7 @@ import { Platform, Pressable, ScrollView, + Share, StyleSheet, Text, TextInput, @@ -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'; @@ -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 ( @@ -387,6 +397,13 @@ function Results({ result, onAgain }: { result: RaceResult; onAgain: () => void + [styles.shareBtn, pressed && styles.pressed]} + > + Share result + + = {}): 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 { + 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 { + 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'); + }); +}); diff --git a/apps/mobile/src/share/summary.ts b/apps/mobile/src/share/summary.ts new file mode 100644 index 0000000..80a38de --- /dev/null +++ b/apps/mobile/src/share/summary.ts @@ -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; + /** 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'); +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 753547e..477f241 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -144,11 +144,12 @@ race end-to-end. Pulls in the upstream roadmap's "future features" and more. -- ⬜ Achievements (top speed, lap streaks, collection milestones). +- ✅ Achievements (top speed, lap streaks, collection milestones) (PR #23). - ⬜ Richer car identity: art, model names, rarity from the Mattel id. -- ⬜ Multiplayer/turn-based race nights; share results. +- ⬜ Multiplayer/turn-based race nights. +- ✅ Share race & session results to the native share sheet (PR #26). - ⬜ Sound design; optional "TV/host mode." -- ✅ Speed units (mph / km/h) + display calibration to real-world speeds (PR #24). +- ✅ Speed units (mph / km/h) + display calibration to real-world speeds (PR #25). - ⬜ Decode remaining protocol unknowns. The **live-telemetry gate is solved** on modern firmware (the encrypted auth-service stream is fully decoded — see Phase 1 / ADR-0012). What remains is best-effort **car identity**: the full **NDEF / Mattel-id schema** (model name, art,