Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b617ce2
perf(live): painel da equipe não trava mais em sessões longas
cardos0s May 25, 2026
4365119
fix(live): reverte batching — quebrou data flow no painel
cardos0s May 25, 2026
100f2fb
fix(format): padroniza tempo pra MM:SS.SSS em todo lugar
cardos0s May 26, 2026
9c227b3
fix(live): painel da equipe — VOLTA crescia sem parar + DELTA estático
cardos0s May 28, 2026
8f8b899
fix(gps): timestamp quantizado a segundos → tempos de volta redondos
cardos0s May 28, 2026
9dbc48b
chore(eas): remove placeholders quebrados do submit iOS (resolve inte…
cardos0s May 29, 2026
0c6126f
feat(tracks): pista custom — destrava kartódromo fora da lista
cardos0s May 29, 2026
6603a57
feat(recovery): auto-save anti-crash — não perde sessão se o app morrer
cardos0s May 29, 2026
3dea91b
feat(sync): backup anônimo de sessões na nuvem (device_id, sem login)
cardos0s May 29, 2026
4e49f61
feat(competition): wave 1 — schema + APIs de evento multi-piloto
cardos0s May 29, 2026
a38d64a
feat(competition): wave 2 — UI no app pra criar/entrar em evento
cardos0s May 29, 2026
6d7761a
feat(competition): wave 3 — web /event/[code] com ranking ao vivo
cardos0s May 29, 2026
ee7f651
feat(competition): wave 4 — ranking in-app + completa o modo competição
cardos0s May 29, 2026
16d1ab4
feat(competition): posição ao vivo + mapa com karts na pista
cardos0s May 29, 2026
a10457b
chore: renomeia app de Copilot pra Cockpit
cardos0s Jun 2, 2026
716d3be
fix(new-session): tela ficava carregando pra sempre se custom_tracks …
cardos0s Jun 7, 2026
8edd6e6
fix(session): tela de resultados ficava carregando pra sempre
cardos0s Jun 10, 2026
1917cd5
fix: 5 telas adicionais ficavam carregando pra sempre
cardos0s Jun 10, 2026
16b4f14
chore: bundle ID com.cortextech.copilot → com.cortextech.cockpit
cardos0s Jun 16, 2026
dfa5f51
chore(ios): ITSAppUsesNonExemptEncryption=false no Info.plist
cardos0s Jun 16, 2026
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
20 changes: 12 additions & 8 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const mapboxDownloadsToken = process.env.MAPBOX_DOWNLOADS_TOKEN ?? '';

module.exports = {
expo: {
name: 'Copilot',
name: 'Cockpit',
slug: 'kartlap',
version: '0.1.0',
orientation: 'default',
Expand All @@ -31,23 +31,27 @@ module.exports = {
newArchEnabled: true,
ios: {
supportsTablet: false,
package: 'com.cortextech.copilot',
package: 'com.cortextech.cockpit',
infoPlist: {
// Export compliance — app só usa HTTPS via system TLS + Keychain.
// Nenhuma crypto custom ou lib extra. Diz pra Apple não perguntar
// a cada build.
ITSAppUsesNonExemptEncryption: false,
UIBackgroundModes: ['location', 'location'],
NSLocationWhenInUseUsageDescription:
'O KartLap usa sua localização para gravar a trajetória na pista.',
'O Cockpit usa sua localização para gravar a trajetória na pista.',
NSLocationAlwaysAndWhenInUseUsageDescription:
'O KartLap precisa da localização em segundo plano para continuar gravando quando a tela estiver apagada.',
'O Cockpit precisa da localização em segundo plano para continuar gravando quando a tela estiver apagada.',
NSMotionUsageDescription:
'O KartLap usa sensores de movimento para melhorar a precisão da trajetória.',
'O Cockpit usa sensores de movimento para melhorar a precisão da trajetória.',
NSPhotoLibraryUsageDescription:
'Selecione uma foto da galeria para usar no seu perfil.',
NSCameraUsageDescription: 'Tire uma foto para usar no seu perfil.',
},
bundleIdentifier: 'com.cortextech.copilot',
bundleIdentifier: 'com.cortextech.cockpit',
},
android: {
package: 'com.cortextech.copilot',
package: 'com.cortextech.cockpit',
permissions: [
'ACCESS_FINE_LOCATION',
'ACCESS_COARSE_LOCATION',
Expand Down Expand Up @@ -76,7 +80,7 @@ module.exports = {
'expo-location',
{
locationAlwaysAndWhenInUsePermission:
'O KartLap precisa da localização em segundo plano para gravar a volta completa.',
'O Cockpit precisa da localização em segundo plano para gravar a volta completa.',
isIosBackgroundLocationEnabled: true,
isAndroidBackgroundLocationEnabled: true,
isAndroidForegroundServiceEnabled: true,
Expand Down
148 changes: 146 additions & 2 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useCallback, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { listSessions, Session, getLapsForSession } from '../../src/storage/db';
import {
loadRecoverySnapshot,
recoverSnapshotToSession,
clearRecoverySnapshot,
type RecoverySnapshot,
} from '../../src/storage/recovery';
import { getProfile, PilotProfile } from '../../src/storage/profile';
import { findTrackById } from '../../src/data/tracks';
import { TrackSilhouette } from '../../src/components/TrackSilhouette';
Expand Down Expand Up @@ -52,10 +58,19 @@ export default function Home() {
const insets = useSafeAreaInsets();
const [profile, setProfile] = useState<PilotProfile | null>(null);
const [sessions, setSessions] = useState<SessionWithStats[]>([]);
// Snapshot de recuperação anti-crash — não-null = sessão anterior foi
// interrompida (crash/SO) sem encerrar. Mostra banner pra recuperar.
const [recovery, setRecovery] = useState<RecoverySnapshot | null>(null);
const [recovering, setRecovering] = useState(false);

const load = useCallback(async () => {
const [prof, list] = await Promise.all([getProfile(), listSessions()]);
const [prof, list, recoverySnap] = await Promise.all([
getProfile(),
listSessions(),
loadRecoverySnapshot(),
]);
setProfile(prof);
setRecovery(recoverySnap);
const enriched = await Promise.all(
list.map(async (sess) => {
const laps = await getLapsForSession(sess.id);
Expand All @@ -70,6 +85,46 @@ export default function Home() {

useFocusEffect(useCallback(() => { load(); }, [load]));

const handleRecover = async () => {
if (!recovery || recovering) return;
setRecovering(true);
try {
const sessionId = await recoverSnapshotToSession(recovery);
setRecovery(null);
if (sessionId) {
await load();
router.push(`/session/${sessionId}` as any);
} else {
Alert.alert(
'Nada pra recuperar',
'A sessão interrompida não tinha voltas completas o suficiente.'
);
}
} catch (e: any) {
Alert.alert('Erro', e?.message ?? 'Falha ao recuperar a sessão.');
} finally {
setRecovering(false);
}
};

const handleDiscardRecovery = () => {
Alert.alert(
'Descartar sessão interrompida?',
'Os dados gravados serão perdidos pra sempre.',
[
{ text: 'Manter', style: 'cancel' },
{
text: 'Descartar',
style: 'destructive',
onPress: async () => {
await clearRecoverySnapshot();
setRecovery(null);
},
},
]
);
};

const firstName = profile?.name?.split(' ')[0] ?? '';
const greeting = greetingFor(new Date().getHours());

Expand Down Expand Up @@ -118,6 +173,47 @@ export default function Home() {
</Pressable>
</View>

{/* Banner de recuperação anti-crash — só aparece se a última sessão
* foi interrompida sem encerrar (crash/SO/bateria). */}
{recovery && (
<View style={s.recoveryBanner}>
<View style={{ flex: 1 }}>
<Text style={s.recoveryTitle}>Sessão interrompida</Text>
<Text style={s.recoverySub}>
{recovery.trackName} ·{' '}
{new Date(recovery.startedAt).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
{' · '}
{recovery.samples.length} pontos não salvos
</Text>
<View style={s.recoveryActions}>
<Pressable
onPress={handleRecover}
disabled={recovering}
style={s.recoveryBtn}
>
{recovering ? (
<ActivityIndicator color={colors.textOnPrimary} size="small" />
) : (
<Text style={s.recoveryBtnTxt}>Recuperar</Text>
)}
</Pressable>
<Pressable
onPress={handleDiscardRecovery}
disabled={recovering}
style={s.recoveryDiscard}
>
<Text style={s.recoveryDiscardTxt}>Descartar</Text>
</Pressable>
</View>
</View>
</View>
)}

{/* Citação Senna — destaque emocional, troca a cada abertura */}
<View style={{ marginTop: spacing.l }}>
<SennaQuoteCard />
Expand Down Expand Up @@ -238,6 +334,54 @@ function LastSessionSilhouette({ sessionId }: { sessionId: string }) {

const s = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
recoveryBanner: {
flexDirection: 'row',
marginTop: spacing.l,
padding: spacing.m,
borderRadius: 14,
backgroundColor: colors.warning + '14',
borderWidth: 1,
borderColor: colors.warning + '55',
},
recoveryTitle: {
color: colors.warning,
fontSize: 14,
fontWeight: '800',
},
recoverySub: {
color: colors.textSecondary,
fontSize: 12,
marginTop: 3,
lineHeight: 17,
},
recoveryActions: {
flexDirection: 'row',
gap: spacing.s,
marginTop: spacing.m,
},
recoveryBtn: {
backgroundColor: colors.warning,
paddingHorizontal: spacing.l,
paddingVertical: 8,
borderRadius: 10,
minWidth: 110,
alignItems: 'center',
},
recoveryBtnTxt: {
color: colors.textOnPrimary,
fontSize: 13,
fontWeight: '800',
},
recoveryDiscard: {
paddingHorizontal: spacing.m,
paddingVertical: 8,
borderRadius: 10,
},
recoveryDiscardTxt: {
color: colors.textMuted,
fontSize: 13,
fontWeight: '600',
},

headerRow: {
flexDirection: 'row',
Expand Down
11 changes: 8 additions & 3 deletions app/(tabs)/insights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,14 @@ function TrendsTab() {

const load = useCallback(async () => {
setLoading(true);
const result = await computeSmartInsights({ window: 3 });
setBundle(result);
setLoading(false);
try {
const result = await computeSmartInsights({ window: 3 });
setBundle(result);
} catch {
setBundle(null);
} finally {
setLoading(false);
}
}, []);

useFocusEffect(useCallback(() => { load(); }, [load]));
Expand Down
15 changes: 15 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
useFonts,
} from '@expo-google-fonts/playfair-display';
import { getProfile } from '../src/storage/profile';
import { listCustomTracks } from '../src/storage/db';
import { setCustomTracksCache } from '../src/data/tracks';
import { syncAllSessions } from '../src/lib/sessionSync';
import { SplashLoader } from '../src/components/SplashLoader';
import { colors } from '../src/theme';

Expand Down Expand Up @@ -94,6 +97,18 @@ export default function RootLayout() {
(async () => {
try {
await getProfile();
// Hidrata o cache de pistas custom — findTrackById/getAllTracks
// precisam disso sync em várias telas. Falha silenciosa: se o DB
// não responder, só não mostra custom tracks (hardcoded seguem).
try {
const customTracks = await listCustomTracks();
setCustomTracksCache(customTracks);
} catch {
/* segue sem custom tracks */
}
// Backfill de sync na nuvem — sobe sessões locais ainda não
// sincronizadas (idempotente). Background, não bloqueia boot.
syncAllSessions().catch(() => {});
} finally {
setProfileReady(true);
}
Expand Down
11 changes: 7 additions & 4 deletions app/career.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ export default function CareerScreen() {

const load = useCallback(async () => {
setLoading(true);
try {
const [state, unlocked, sessions] = await Promise.all([
getGamificationState(),
listUnlockedAchievements(),
listSessions(),
getGamificationState().catch(() => ({ xp: 0 } as any)),
listUnlockedAchievements().catch(() => []),
listSessions().catch(() => []),
]);
const unlockedIds = new Set(unlocked.map((u) => u.achievementId));
const currentLevel = levelForXp(state.xp);
Expand Down Expand Up @@ -100,7 +101,9 @@ export default function CareerScreen() {
achievements: unlocked.length,
totalAchievements: ACHIEVEMENTS.length,
});
setLoading(false);
} finally {
setLoading(false);
}

// Trigger anim
reveal.value = 0;
Expand Down
21 changes: 12 additions & 9 deletions app/challenges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,18 @@ export default function ChallengesScreen() {

const load = useCallback(async () => {
setLoading(true);
// Refresh primeiro pra refletir sessões recentes do dia
await refreshTodayChallenges();
const [cs, st] = await Promise.all([
getChallengesForToday(),
countChallengeStreak(),
]);
setChallenges(cs);
setStreak(st);
setLoading(false);
try {
// Refresh primeiro pra refletir sessões recentes do dia
await refreshTodayChallenges().catch(() => {});
const [cs, st] = await Promise.all([
getChallengesForToday().catch(() => []),
countChallengeStreak().catch(() => 0),
]);
setChallenges(cs);
setStreak(st);
} finally {
setLoading(false);
}
}, []);

useFocusEffect(useCallback(() => { load(); }, [load]));
Expand Down
11 changes: 8 additions & 3 deletions app/leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,14 @@ export default function LeaderboardScreen() {
return;
}
setLoading(true);
const data = await fetchLeaderboard(trackId, layoutId ?? null, 20);
setEntries(data);
setLoading(false);
try {
const data = await fetchLeaderboard(trackId, layoutId ?? null, 20);
setEntries(data);
} catch {
setEntries([]);
} finally {
setLoading(false);
}
reveal.value = 0;
reveal.value = withTiming(1, {
duration: 1400,
Expand Down
Loading