From b617ce2808485d7e30bb2727c1f3b1ee451c326a Mon Sep 17 00:00:00 2001 From: cardos0s Date: Sun, 24 May 2026 23:09:22 -0300 Subject: [PATCH 01/20] =?UTF-8?q?perf(live):=20painel=20da=20equipe=20n?= =?UTF-8?q?=C3=A3o=20trava=20mais=20em=20sess=C3=B5es=20longas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sintoma: dashboard web da equipe travava após 2-5min de sessão. Causa: setState a cada INSERT do realtime (10Hz GPS) = 10 re-renders/seg + samples crescendo sem cap (3000+ pontos no SVG) = death spiral. == Fix lado web (web-spectator/lib/useLive.ts) == - pendingSamplesRef + flush timer (400ms): samples vindos do realtime vão pra um ref, e um único setState ocorre a cada 400ms com todos os samples acumulados. Re-renders caem de 10/s pra 2.5/s (-75%). - MAX_SAMPLES_IN_STATE = 3000: cap nos samples mantidos no state. Em sessões muito longas (>5min @ 10Hz) os mais antigos saem do array pra bound memória. Dado continua salvo no Supabase. == Fix render do traçado (team/[code]/page.tsx) == - TrackPanel agora decima samples pra max 500 pontos ANTES de projectTrack + render do . Mantém formato visual da pista intacto (oval/circuito reconhecível) mas reduz custo do SVG em ~6x. - Garante que último sample sempre tá incluído (continuidade). == Fix lado piloto (app/recording.tsx) == - PUBLISH_DECIMATION = 3: publica 1 a cada 3 samples = ~3.3Hz efetivo em vez de 10Hz. Coaching ao vivo não precisa de 10Hz; 3-4Hz dá posição precisa o suficiente. - Fire-and-forget (sem await no loop): se uma INSERT engasgar por rede ruim, a próxima não espera. Antes uma INSERT lenta atrasava todas as seguintes. Combinação: ~12x menos load no painel (decimation no publish 3x × batch no realtime 4x × decimation no render 6x quando aplicável). Pra sessão de 20min: 60s × 20 × 3.3Hz × cap = ~3000 samples no state máximo, sempre. --- app/recording.tsx | 25 +++++++----- web-spectator/app/team/[code]/page.tsx | 23 ++++++++++- web-spectator/lib/useLive.ts | 55 +++++++++++++++++++++++--- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/app/recording.tsx b/app/recording.tsx index 410ad0a..35b72db 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -279,18 +279,26 @@ export default function Recording() { setLiveModalOpen(false); }; - // Publica deltas de samples (1 por sample, com cap de batch). Roda quando - // o array de liveSamples cresce. Sem rate limit do nosso lado — Supabase - // realtime aguenta tranquilo o ritmo de ~1Hz do GPS. + // Publica samples em batch + decimados pra realtime. Antes: 1 INSERT + // por sample, await em loop, ~10Hz. Resultado: lag no painel da equipe + // após 3-5min (3000+ samples = re-render lento + tantas inserts/seg + // saturam network/Supabase). + // + // Agora: pega só 1 a cada PUBLISH_DECIMATION (4Hz efetivo), e dispara + // fire-and-forget (sem await no loop) — se a rede engasgar, sample + // seguinte não espera. Coaching ao vivo não precisa de 10Hz; 4Hz dá + // ~25cm de precisão em movimento mesmo a 80km/h. useEffect(() => { if (!live) return; + const PUBLISH_DECIMATION = 3; // pega 1 a cada 3 samples = ~3.3Hz const newOnes = liveSamples.slice(lastSampleIdxRef.current); if (newOnes.length === 0) return; lastSampleIdxRef.current = liveSamples.length; + const toSend = newOnes.filter((_, i) => i % PUBLISH_DECIMATION === 0); + // Fire-and-forget — não bloqueia se network engasgar (async () => { - for (const s of newOnes) { - try { - await publishSample(live.id, { + for (const s of toSend) { + publishSample(live.id, { t: s.t, lat: s.lat, lng: s.lng, @@ -313,10 +321,9 @@ export default function Recording() { s3Ms: info.currentSectors.s3Ms, altitude: s.altitude ?? null, altitudeAccuracy: s.altitudeAccuracy ?? null, - }); - } catch { + }).catch(() => { /* engole — não pode quebrar gravação se realtime falhar */ - } + }); } })(); }, [ diff --git a/web-spectator/app/team/[code]/page.tsx b/web-spectator/app/team/[code]/page.tsx index fdf8310..101fa2b 100644 --- a/web-spectator/app/team/[code]/page.tsx +++ b/web-spectator/app/team/[code]/page.tsx @@ -240,7 +240,28 @@ function TrackPanel({ }) { const W = 800; const H = 540; - const { points, project } = useMemo(() => projectTrack(samples, W, H), [samples]); + + // Decimação pra render do traçado — mantém formato visual mas reduz + // dramaticamente custo de SVG path com muitos pontos. Pra 3000 samples + // cap em 500 pontos = step 6. Reduz o custo de projectTrack + render do + // em ~6x. Marker do kart usa `lastSample` separado, então a + // posição atual continua precisa apesar da decimação do traçado. + const decimatedSamples = useMemo(() => { + if (samples.length <= 500) return samples; + const step = Math.ceil(samples.length / 500); + const out: LiveSample[] = []; + for (let i = 0; i < samples.length; i += step) out.push(samples[i]); + // Garante que o último sample tá incluído (continuidade do traço até "agora") + if (out[out.length - 1] !== samples[samples.length - 1]) { + out.push(samples[samples.length - 1]); + } + return out; + }, [samples]); + + const { points, project } = useMemo( + () => projectTrack(decimatedSamples, W, H), + [decimatedSamples] + ); const currentSector = lastSample?.currentSectorIdx; const sectorLabel = diff --git a/web-spectator/lib/useLive.ts b/web-spectator/lib/useLive.ts index f71d14f..d8a83f6 100644 --- a/web-spectator/lib/useLive.ts +++ b/web-spectator/lib/useLive.ts @@ -1,10 +1,26 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { RealtimeChannel } from '@supabase/supabase-js'; import { getSupabase } from './supabase'; import { LiveLap, LiveMessage, LiveSample, LiveSessionInfo, MessageSeverity } from './liveTypes'; +/** + * Cap de samples mantidos no state — ~5min de GPS a 10Hz. Suficiente pra + * traçado completo da volta + recente; samples antigos saem (continuam no + * Supabase pra quem precisar de histórico via query). Sem cap, painel + * congela após ~5min de sessão (3000+ samples re-renderizando). + */ +const MAX_SAMPLES_IN_STATE = 3000; + +/** + * Janela de batching dos samples chegando via realtime. Em vez de + * setState a cada INSERT (10Hz = 10 re-renders/seg), acumulamos os que + * chegam e fazemos 1 setState a cada N ms. ~2.5 re-renders/seg = painel + * fluido sem perder dado. + */ +const SAMPLE_BATCH_FLUSH_MS = 400; + /** * Hook que assina uma live session pelo código. * @@ -98,11 +114,17 @@ function mapMessage(row: any): LiveMessage { export function useLive(code: string | null): LiveState { const [state, setState] = useState({ kind: 'loading' }); + // Buffer de samples não-flushados. setState só roda no flush timer, + // não em cada INSERT do realtime. Crítico pra performance — sem isso + // o painel acumula 10 re-renders/s e congela em 3-5min. + const pendingSamplesRef = useRef([]); + useEffect(() => { if (!code) return; let cancelled = false; let channel: RealtimeChannel | null = null; let pollTimer: ReturnType | null = null; + let flushTimer: ReturnType | null = null; (async () => { let supabase; @@ -161,6 +183,9 @@ export function useLive(code: string | null): LiveState { }); // 3. Realtime — escuta INSERTs nas duas tabelas filtrados por session. + // Samples vão pro pendingSamplesRef e são flushados em batch a + // SAMPLE_BATCH_FLUSH_MS (em vez de setState a cada um). Crítico + // pra performance — 10Hz × 5min sem batch matava o painel. channel = supabase .channel(`live:${info.code}`) .on( @@ -173,11 +198,7 @@ export function useLive(code: string | null): LiveState { }, (payload) => { if (cancelled) return; - const s = mapSample(payload.new); - setState((prev) => { - if (prev.kind !== 'live' && prev.kind !== 'ended') return prev; - return { ...prev, samples: [...prev.samples, s] }; - }); + pendingSamplesRef.current.push(mapSample(payload.new)); } ) .on( @@ -216,6 +237,26 @@ export function useLive(code: string | null): LiveState { ) .subscribe(); + // 3.5. Flush timer dos samples batchados. Drena o buffer e faz um + // único setState com todos os samples acumulados desde o último flush. + // Aplica cap em MAX_SAMPLES_IN_STATE pra não acumular memória + // infinitamente em sessões longas (>5min). + flushTimer = setInterval(() => { + if (cancelled) return; + const batch = pendingSamplesRef.current; + if (batch.length === 0) return; + pendingSamplesRef.current = []; + setState((prev) => { + if (prev.kind !== 'live' && prev.kind !== 'ended') return prev; + const merged = prev.samples.concat(batch); + const capped = + merged.length > MAX_SAMPLES_IN_STATE + ? merged.slice(-MAX_SAMPLES_IN_STATE) + : merged; + return { ...prev, samples: capped }; + }); + }, SAMPLE_BATCH_FLUSH_MS); + // 4. Polling do info — detecta ended_at sem precisar de outro canal. pollTimer = setInterval(async () => { const { data } = await supabase @@ -241,6 +282,8 @@ export function useLive(code: string | null): LiveState { } catch {} } if (pollTimer) clearInterval(pollTimer); + if (flushTimer) clearInterval(flushTimer); + pendingSamplesRef.current = []; }; }, [code]); From 4365119efba52be66c2a53202f5ecb02be4db8fa Mon Sep 17 00:00:00 2001 From: cardos0s Date: Sun, 24 May 2026 23:24:47 -0300 Subject: [PATCH 02/20] =?UTF-8?q?fix(live):=20reverte=20batching=20?= =?UTF-8?q?=E2=80=94=20quebrou=20data=20flow=20no=20painel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sintoma reportado: depois do deploy do batching, painel da equipe parava de atualizar velocidade/tempo após ~2 updates. Speed/time estagnavam mesmo com sample ainda chegando do realtime. O batching com pendingSamplesRef + flushTimer setInterval introduziu uma camada extra de complexidade onde algo dropava em certas condições (hipóteses: timer não re-armando, closure stale, race com cleanup). Sem visibilidade do log do browser, mais seguro reverter. Volta pra setState a cada INSERT do realtime (10Hz) MAS com cap em MAX_SAMPLES_IN_STATE = 3000 aplicado na própria operação (shift + push quando estoura). Garante: - UI continua "viva" 10x/seg (igual antes do batching) - Memória bounded em sessão longa (>5min) - Custo do SVG render bounded pela decimação que já existe no TrackPanel (max 500 pontos no path) Net: cap + decimation = melhoria similar ao batching, sem o risco de quebrar data flow. Se ainda lagar em sessão >10min, aí volto pra batching com testes mais cuidadosos. --- web-spectator/lib/useLive.ts | 59 +++++++++++------------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/web-spectator/lib/useLive.ts b/web-spectator/lib/useLive.ts index d8a83f6..f67d062 100644 --- a/web-spectator/lib/useLive.ts +++ b/web-spectator/lib/useLive.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { RealtimeChannel } from '@supabase/supabase-js'; import { getSupabase } from './supabase'; import { LiveLap, LiveMessage, LiveSample, LiveSessionInfo, MessageSeverity } from './liveTypes'; @@ -10,17 +10,14 @@ import { LiveLap, LiveMessage, LiveSample, LiveSessionInfo, MessageSeverity } fr * traçado completo da volta + recente; samples antigos saem (continuam no * Supabase pra quem precisar de histórico via query). Sem cap, painel * congela após ~5min de sessão (3000+ samples re-renderizando). + * + * Cada INSERT do realtime continua disparando setState (mantém UI viva), + * mas o array é sempre podado ao adicionar — render fica bounded mesmo + * em sessão longa. Decimação no consumidor (TrackPanel etc) cuida do + * custo do SVG render. */ const MAX_SAMPLES_IN_STATE = 3000; -/** - * Janela de batching dos samples chegando via realtime. Em vez de - * setState a cada INSERT (10Hz = 10 re-renders/seg), acumulamos os que - * chegam e fazemos 1 setState a cada N ms. ~2.5 re-renders/seg = painel - * fluido sem perder dado. - */ -const SAMPLE_BATCH_FLUSH_MS = 400; - /** * Hook que assina uma live session pelo código. * @@ -114,17 +111,11 @@ function mapMessage(row: any): LiveMessage { export function useLive(code: string | null): LiveState { const [state, setState] = useState({ kind: 'loading' }); - // Buffer de samples não-flushados. setState só roda no flush timer, - // não em cada INSERT do realtime. Crítico pra performance — sem isso - // o painel acumula 10 re-renders/s e congela em 3-5min. - const pendingSamplesRef = useRef([]); - useEffect(() => { if (!code) return; let cancelled = false; let channel: RealtimeChannel | null = null; let pollTimer: ReturnType | null = null; - let flushTimer: ReturnType | null = null; (async () => { let supabase; @@ -183,9 +174,10 @@ export function useLive(code: string | null): LiveState { }); // 3. Realtime — escuta INSERTs nas duas tabelas filtrados por session. - // Samples vão pro pendingSamplesRef e são flushados em batch a - // SAMPLE_BATCH_FLUSH_MS (em vez de setState a cada um). Crítico - // pra performance — 10Hz × 5min sem batch matava o painel. + // Samples são apendados ao state e o array é PODADO em MAX_SAMPLES_IN_STATE + // pra bound memória. Cada INSERT vira setState — UI continua "viva" + // 10x/seg. Custo do render é controlado pela decimação no consumidor + // (ex: TrackPanel só pinta 500 pontos máx do SVG). channel = supabase .channel(`live:${info.code}`) .on( @@ -198,7 +190,14 @@ export function useLive(code: string | null): LiveState { }, (payload) => { if (cancelled) return; - pendingSamplesRef.current.push(mapSample(payload.new)); + const s = mapSample(payload.new); + setState((prev) => { + if (prev.kind !== 'live' && prev.kind !== 'ended') return prev; + const next = prev.samples.length >= MAX_SAMPLES_IN_STATE + ? [...prev.samples.slice(1), s] // shift + push pra manter cap + : [...prev.samples, s]; + return { ...prev, samples: next }; + }); } ) .on( @@ -237,26 +236,6 @@ export function useLive(code: string | null): LiveState { ) .subscribe(); - // 3.5. Flush timer dos samples batchados. Drena o buffer e faz um - // único setState com todos os samples acumulados desde o último flush. - // Aplica cap em MAX_SAMPLES_IN_STATE pra não acumular memória - // infinitamente em sessões longas (>5min). - flushTimer = setInterval(() => { - if (cancelled) return; - const batch = pendingSamplesRef.current; - if (batch.length === 0) return; - pendingSamplesRef.current = []; - setState((prev) => { - if (prev.kind !== 'live' && prev.kind !== 'ended') return prev; - const merged = prev.samples.concat(batch); - const capped = - merged.length > MAX_SAMPLES_IN_STATE - ? merged.slice(-MAX_SAMPLES_IN_STATE) - : merged; - return { ...prev, samples: capped }; - }); - }, SAMPLE_BATCH_FLUSH_MS); - // 4. Polling do info — detecta ended_at sem precisar de outro canal. pollTimer = setInterval(async () => { const { data } = await supabase @@ -282,8 +261,6 @@ export function useLive(code: string | null): LiveState { } catch {} } if (pollTimer) clearInterval(pollTimer); - if (flushTimer) clearInterval(flushTimer); - pendingSamplesRef.current = []; }; }, [code]); From 100f2fbd13a269cdccce12c679aec8f0548dec2c Mon Sep 17 00:00:00 2001 From: cardos0s Date: Mon, 25 May 2026 21:32:04 -0300 Subject: [PATCH 03/20] fix(format): padroniza tempo pra MM:SS.SSS em todo lugar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes tinha inconsistência: - App HUD do kart (recording.tsx fmtLapShort): "34.872" pra voltas <60s, sem minuto. Difícil ler "tempo total" rápido — usuário não sabe se é 34s ou 1m34s. - Web format.ts fmtLap: "0:34.872" (1 dígito minuto). OK, mas inconsistente com sessions.tsx do app que já fazia "00:34.872". - Replay screen tinha formatador próprio com 2 decimais ("34.87") — perdia precisão de milésimo. Agora padrão único em tudo (motorsport standard, MyChron/Speedhive): MM:SS.SSS (minuto sempre 2 dígitos, milésimo sempre 3 dígitos) Exemplo: 34.872s → "00:34.872", 1m23.456s → "01:23.456" Centésimos ficam embutidos nos 2 primeiros dígitos do milésimo: "00:34.876" = 34s + 87 centésimos + 876 milésimos. Arquivos: - web-spectator/lib/format.ts: fmtLap + fmtTime ganham padStart no minuto - app/recording.tsx: removido fmtLapShort, todos os usos viraram fmtLap - app/replay/[id].tsx: fmtTime local reescrito pro formato padrão --- app/recording.tsx | 27 +++++++++++---------------- app/replay/[id].tsx | 13 +++++++++---- web-spectator/lib/format.ts | 19 ++++++++++++++++--- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/app/recording.tsx b/app/recording.tsx index 35b72db..7ca057f 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -60,25 +60,20 @@ import { LapResultOverlay } from '../src/components/LapResultOverlay'; import { PilotMessageOverlay } from '../src/components/PilotMessageOverlay'; import { colors, radius, spacing, typography } from '../src/theme'; -function fmtLap(ms: number) { - const totalS = ms / 1000; - const m = Math.floor(totalS / 60); - const s = totalS - m * 60; - return `${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`; -} - /** - * Tempo curto pro cronômetro central do HUD: "34.872" quando <60s, - * "1:14.523" quando ≥60s. Kart médio fica entre 30-90s/volta — quase - * sempre cabe na forma curta, que é menos visualmente "pesada" que - * o "00:34.872" tradicional. + * Formato padrão de tempo: MM:SS.SSS (minuto:segundo.milésimo). + * Centésimos ficam embutidos nos 2 primeiros dígitos do milésimo. + * Convenção de telemetria de motorsport (MyChron, Speedhive). + * + * Antes tinha fmtLap que omitia minuto pra voltas <60s ("34.872"), + * mas removido por inconsistência — agora todo lugar mostra formato + * cheio "00:34.872" pra clareza. */ -function fmtLapShort(ms: number) { +function fmtLap(ms: number) { const totalS = ms / 1000; - if (totalS < 60) return totalS.toFixed(3); const m = Math.floor(totalS / 60); const s = totalS - m * 60; - return `${m}:${s.toFixed(3).padStart(6, '0')}`; + return `${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`; } function fmtDelta(ms: number) { @@ -782,7 +777,7 @@ export default function Recording() { numberOfLines={1} adjustsFontSizeToFit > - {fmtLapShort(currentLapMs)} + {fmtLap(currentLapMs)} @@ -1079,7 +1074,7 @@ function SectorPanel({ S{i + 1} - {ms !== null ? fmtLapShort(ms) : '—'} + {ms !== null ? fmtLap(ms) : '—'} ); diff --git a/app/replay/[id].tsx b/app/replay/[id].tsx index 720164e..379035c 100644 --- a/app/replay/[id].tsx +++ b/app/replay/[id].tsx @@ -536,11 +536,16 @@ function LapChip({ // Helpers // ============================================================================ +/** + * Tempo no formato padrão MM:SS.SSS (minuto:segundo.milésimo). + * Centésimos embutidos nos 2 primeiros dígitos do milésimo. + */ function fmtTime(ms: number): string { - if (ms < 60000) return (ms / 1000).toFixed(2); - const m = Math.floor(ms / 60000); - const s = (ms % 60000) / 1000; - return `${m}:${s.toFixed(2).padStart(5, '0')}`; + if (!Number.isFinite(ms) || ms < 0) return '00:00.000'; + const totalS = ms / 1000; + const m = Math.floor(totalS / 60); + const s = totalS - m * 60; + return `${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`; } function buildScene( diff --git a/web-spectator/lib/format.ts b/web-spectator/lib/format.ts index e6b2d94..f838a92 100644 --- a/web-spectator/lib/format.ts +++ b/web-spectator/lib/format.ts @@ -1,16 +1,29 @@ +/** + * Formato padrão de tempo de volta/cronômetro: MM:SS.SSS + * - Minutos sempre com 2 dígitos (zero à esquerda) + * - Segundos com 2 dígitos + * - Milésimos com 3 dígitos (inclui centésimo embutido nos 2 primeiros) + * + * Exemplos: + * 34872ms → "00:34.872" + * 83456ms → "01:23.456" + * 125000ms → "02:05.000" + * + * Padrão de telemetria de motorsport (AiM MyChron, Mylaps Speedhive, etc). + */ export function fmtLap(ms: number): string { const totalS = ms / 1000; const m = Math.floor(totalS / 60); const s = totalS - m * 60; - return `${m}:${s.toFixed(3).padStart(6, '0')}`; + return `${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`; } export function fmtTime(ms: number): string { - if (!Number.isFinite(ms) || ms < 0) return '0:00.000'; + if (!Number.isFinite(ms) || ms < 0) return '00:00.000'; const totalS = ms / 1000; const m = Math.floor(totalS / 60); const s = totalS - m * 60; - return `${m}:${s.toFixed(3).padStart(6, '0')}`; + return `${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`; } export function fmtDelta(ms: number): string { From 9c227b3318b535ed58bfe327f61de74201d15efb Mon Sep 17 00:00:00 2001 From: cardos0s Date: Thu, 28 May 2026 14:37:03 -0300 Subject: [PATCH 04/20] =?UTF-8?q?fix(live):=20painel=20da=20equipe=20?= =?UTF-8?q?=E2=80=94=20VOLTA=20crescia=20sem=20parar=20+=20DELTA=20est?= =?UTF-8?q?=C3=A1tico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois bugs no publishSample (app → realtime → painel): 1. lapElapsedMs publicava info.elapsedMs (tempo TOTAL da sessão) em vez de info.currentLapElapsedMs (tempo da volta atual). Resultado: o campo "VOLTA" no painel mostrava 8:35 crescendo sem parar, em vez de resetar a cada volta (0:01 → 0:42 → reset). 2. deltaVsRefMs publicava um valor ESTÁTICO (info.bestLapMs − reference.durationMs) que só mudava ao bater PB. Por isso o "DELTA · LIVE" ficava congelado (+1.000s no print). Agora publica info.liveDeltaMs — o delta MyChron real no ponto atual da pista, que o hook já calcula a cada sample. Deps do useEffect atualizadas (info.currentLapElapsedMs, info.liveDeltaMs). Precisa rebuild do APK pra valer (mudança no app, não no web). --- app/recording.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/recording.tsx b/app/recording.tsx index 7ca057f..4fbd376 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -301,12 +301,16 @@ export default function Recording() { heading: s.heading, accuracy: s.accuracy, lapNumber: info.lapsCompleted, - lapElapsedMs: info.elapsedMs, + // Tempo da volta ATUAL (reseta a cada cruzamento da linha), + // NÃO o total da sessão. Antes mandava info.elapsedMs (total) + // e o painel mostrava "VOLTA 8:35" crescendo sem parar. + lapElapsedMs: info.currentLapElapsedMs ?? info.elapsedMs, bestLapMs: info.bestLapMs ?? null, - deltaVsRefMs: - reference && info.bestLapMs !== null - ? info.bestLapMs - reference.durationMs - : null, + // Delta MyChron AO VIVO no ponto atual da pista (vem do tracker + // do hook). Antes mandava um valor estático (melhor − referência + // do layout) que só mudava ao bater PB — por isso "DELTA LIVE" + // ficava congelado em +1.000s. + deltaVsRefMs: info.liveDeltaMs, // Setores — null quando o app não tem layout reference carregada. // Team panel usa esses pra mostrar delta por setor + ranking. currentSectorIdx: info.currentSectorIdx, @@ -326,7 +330,9 @@ export default function Recording() { liveSamples, info.lapsCompleted, info.elapsedMs, + info.currentLapElapsedMs, info.bestLapMs, + info.liveDeltaMs, info.currentSectorIdx, info.currentSectorElapsedMs, info.currentSectors, From 8f8b899ce2f9cdf54f56c9549dec0bee5e068a12 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Thu, 28 May 2026 14:51:52 -0300 Subject: [PATCH 05/20] =?UTF-8?q?fix(gps):=20timestamp=20quantizado=20a=20?= =?UTF-8?q?segundos=20=E2=86=92=20tempos=20de=20volta=20redondos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raiz do bug "ÚLTIMA travada + tempos redondos" reportado em pista real: o GPS de alguns Android entrega loc.timestamp QUANTIZADO a segundos cheios (sempre múltiplo de 1000ms). Como durationMs = sample[fim].t − sample[início].t, os tempos de volta saíam sempre redondos (42.000, 43.999) — sem centésimo/milésimo. E voltas consecutivas com valores quase idênticos pareciam "travadas" no painel da equipe. Fix no BG task: prefere Date.now() (precisão de ms, fica a poucos ms do tempo real do fix porque o task roda quase em tempo real gravando) quando loc.timestamp vem quantizado ou ausente. Heurística por sample: - loc.timestamp com precisão sub-segundo (% 1000 != 0) → confia nele (devices bons mantêm comportamento atual) - quantizado/0/ausente → Date.now() com spread intra-batch (~100ms/ sample retroativo) pra batch com várias locations não colidir no mesmo t. No caso comum (1 location/call em foreground), t = Date.now() distinto por call = timing real preservado. Resolve os 2 sintomas de uma vez: tempos precisos + ÚLTIMA deixa de "travar" (valores passam a variar de verdade volta a volta). Precisa rebuild do APK (mudança no app). --- src/hooks/useLapRecorder.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/hooks/useLapRecorder.ts b/src/hooks/useLapRecorder.ts index 2fe65d4..140ca87 100644 --- a/src/hooks/useLapRecorder.ts +++ b/src/hooks/useLapRecorder.ts @@ -28,12 +28,31 @@ TaskManager.defineTask(BG_TASK, async ({ data, error }) => { } const { locations } = (data as any) ?? {}; if (!locations) return; - for (const loc of locations as Location.LocationObject[]) { + + // Timestamp dos samples — fonte de verdade pro tempo de volta. + // + // Problema observado em campo: alguns Android entregam loc.timestamp + // QUANTIZADO a segundos cheios (sempre múltiplo de 1000ms). Isso fazia + // durationMs = sample[fim].t − sample[início].t sair sempre redondo + // (42.000, 43.999...) — sem precisão de centésimo/milésimo. + // + // Date.now() no momento do processamento do BG task tem precisão de ms + // e fica a poucos ms do tempo real do fix (o task roda quase em + // tempo real quando a app está em foreground gravando). Então: + // - loc.timestamp COM precisão sub-segundo (% 1000 != 0) → confia nele + // - senão (quantizado, 0, ou ausente) → Date.now() com spread + // intra-batch pra samples não colidirem no mesmo ms. + const arrivalNow = Date.now(); + const locs = locations as Location.LocationObject[]; + const n = locs.length; + for (let i = 0; i < n; i++) { + const loc = locs[i]; if ((loc.coords.accuracy ?? 999) > 30) continue; - // Fallback pra Date.now() porque algumas builds Expo/Android entregam - // loc.timestamp = 0 ou undefined; sem isso a volta inteira fica com - // tMs constante e a análise por setor zera (curMs = 0 em tudo). - const t = loc.timestamp && loc.timestamp > 0 ? loc.timestamp : Date.now(); + const rawTs = loc.timestamp; + const hasSubSecond = rawTs && rawTs > 0 && rawTs % 1000 !== 0; + // Quando cai no Date.now(), espalha ~100ms por sample retroativamente + // (assume GPS ~10Hz) pra batch com várias locations não virar um único t. + const t = hasSubSecond ? rawTs : arrivalNow - (n - 1 - i) * 100; buf.samples.push({ t, lat: loc.coords.latitude, From 9dbc48b304d3749df11c66274fae4f81fb00a493 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 11:27:37 -0300 Subject: [PATCH 06/20] chore(eas): remove placeholders quebrados do submit iOS (resolve interativo) --- eas.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/eas.json b/eas.json index fd28bae..61dff02 100644 --- a/eas.json +++ b/eas.json @@ -25,11 +25,6 @@ } }, "submit": { - "production": { - "ios": { - "ascAppId": "PRECHEER_DEPOIS_DE_CRIAR_NO_APP_STORE_CONNECT", - "appleTeamId": "PREENCHER_DEPOIS" - } - } + "production": {} } } \ No newline at end of file From 0c6126fea2535dc9fa0fee5e0bb76e4d0fe4037f Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 11:56:14 -0300 Subject: [PATCH 07/20] =?UTF-8?q?feat(tracks):=20pista=20custom=20?= =?UTF-8?q?=E2=80=94=20destrava=20kart=C3=B3dromo=20fora=20da=20lista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bloqueador #1 pra teste de campo: só havia 8 pistas hardcoded. Quem estivesse num kartódromo fora da lista não conseguia nem começar uma sessão. Agora dá pra criar a pista na hora. - src/storage/db.ts: migration v3→v4 cria tabela custom_tracks. CRUD: listCustomTracks, addCustomTrack (id "custom--"), deleteCustomTrack. Importa TrackRef de data/tracks. - src/data/tracks.ts: cache em memória (customTracksCache) + setCustomTracksCache / getCustomTracksCached / getAllTracks / isCustomTrack. findTrackById agora checa hardcoded E custom — mantém SÍNCRONO (muitos call sites chamam sem await). Cache hidratado no boot. - app/_layout.tsx: hidrata o cache no boot (listCustomTracks → setCustomTracksCache), falha silenciosa. - app/new-track.tsx (novo): form de criar pista — nome (obrigatório), cidade/UF (opcional), captura GPS atual (Location.getCurrentPosition). Salva no DB + re-hidrata cache + volta. - app/new-session.tsx: lista agora usa getAllTracks() (hardcoded + custom). Re-hidrata cache no load. Botão tracejado "Minha pista não está aqui" → /new-track. Pista custom funciona igual hardcoded daí pra frente: escolhe traçado, grava reconhecimento, etc. Precisa rebuild do APK (mudança no app). --- app/_layout.tsx | 11 ++ app/new-session.tsx | 57 ++++++++++- app/new-track.tsx | 244 ++++++++++++++++++++++++++++++++++++++++++++ src/data/tracks.ts | 31 +++++- src/storage/db.ts | 97 ++++++++++++++++++ 5 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 app/new-track.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 697119c..23c4866 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,6 +12,8 @@ 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 { SplashLoader } from '../src/components/SplashLoader'; import { colors } from '../src/theme'; @@ -94,6 +96,15 @@ 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 */ + } } finally { setProfileReady(true); } diff --git a/app/new-session.tsx b/app/new-session.tsx index e58bbad..6c06ceb 100644 --- a/app/new-session.tsx +++ b/app/new-session.tsx @@ -5,8 +5,9 @@ import { useFocusEffect } from 'expo-router'; import * as Location from 'expo-location'; import Svg, { Path } from 'react-native-svg'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { TRACKS, TrackRef, distanceKm, findTrackById } from '../src/data/tracks'; -import { listAllLayoutsGrouped, TrackLayout } from '../src/storage/db'; +import { getAllTracks, TrackRef, distanceKm, findTrackById } from '../src/data/tracks'; +import { listAllLayoutsGrouped, listCustomTracks, TrackLayout } from '../src/storage/db'; +import { setCustomTracksCache } from '../src/data/tracks'; import { getProfile } from '../src/storage/profile'; import { TrackSilhouette } from '../src/components/TrackSilhouette'; import { colors, spacing, radius, typography } from '../src/theme'; @@ -72,10 +73,15 @@ export default function NewSession() { const [loading, setLoading] = useState(true); const load = useCallback(async () => { - const [grouped, profile] = await Promise.all([ + const [grouped, profile, customTracks] = await Promise.all([ listAllLayoutsGrouped(), getProfile(), + listCustomTracks(), ]); + // Re-hidrata cache de custom tracks toda vez que entra na tela — pega + // pistas criadas em outras sessões/devices e a recém-criada ao voltar + // do new-track. + setCustomTracksCache(customTracks); setLayoutsByTrack(grouped); setHomeTrackId(profile?.homeTrackId ?? null); setLoading(false); @@ -103,7 +109,7 @@ export default function NewSession() { }, []); const rows: TrackRow[] = useMemo(() => { - const all = TRACKS.map((t) => { + const all = getAllTracks().map((t) => { const layouts = layoutsByTrack.get(t.id) ?? []; // listAllLayoutsGrouped ordena: is_default DESC, recorded_at DESC. // O primeiro é o default (se houver). @@ -252,6 +258,20 @@ export default function NewSession() { {rows.length === 0 && ( Nenhuma pista encontrada com "{query}" )} + + {/* Criar pista custom — pro piloto num kartódromo fora da lista. + * Crítico pra teste de campo: sem isso, quem está numa pista + * desconhecida não consegue nem começar. */} + router.push('/new-track' as any)} + style={({ pressed }) => [s.addTrackBtn, pressed && { opacity: 0.7 }]} + > + + + + Minha pista não está aqui + Criar uma pista nova com o GPS atual + + @@ -377,4 +397,33 @@ const s = StyleSheet.create({ textAlign: 'center', padding: spacing.xl, }, + addTrackBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.m, + padding: spacing.m, + marginTop: spacing.s, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: colors.primary + '55', + borderStyle: 'dashed', + borderRadius: radius.l, + }, + addTrackPlus: { + color: colors.primary, + fontSize: 28, + fontWeight: '300', + width: 64, + textAlign: 'center', + }, + addTrackTitle: { + color: colors.primary, + fontSize: 15, + fontWeight: '700', + }, + addTrackSub: { + color: colors.textMuted, + fontSize: 12, + marginTop: 2, + }, }); \ No newline at end of file diff --git a/app/new-track.tsx b/app/new-track.tsx new file mode 100644 index 0000000..c694acd --- /dev/null +++ b/app/new-track.tsx @@ -0,0 +1,244 @@ +/** + * Criar pista custom — pro piloto que está num kartódromo fora da lista + * hardcoded (caso comum em teste de campo com desconhecidos). + * + * Captura: nome (obrigatório), cidade/estado (opcional) e a localização + * GPS atual (pra ordenação por distância + futura detecção automática de + * "você está nessa pista"). Salva no SQLite (custom_tracks) e re-hidrata + * o cache em memória pra a pista aparecer na lista na hora. + * + * Fluxo: new-session → "Minha pista não está aqui" → aqui → salva → + * volta pra new-session com a pista nova no topo. + */ + +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import * as Location from 'expo-location'; +import { addCustomTrack, listCustomTracks } from '../src/storage/db'; +import { setCustomTracksCache } from '../src/data/tracks'; +import { colors, radius, spacing, typography } from '../src/theme'; + +export default function NewTrack() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const [name, setName] = useState(''); + const [city, setCity] = useState(''); + const [state, setState] = useState(''); + const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null); + const [locating, setLocating] = useState(true); + const [locError, setLocError] = useState(null); + const [saving, setSaving] = useState(false); + + // Captura GPS atual ao montar. Não bloqueia o save (pista pode ser + // salva sem coords se o GPS falhar — só perde ordenação por distância). + const captureLocation = async () => { + setLocating(true); + setLocError(null); + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + setLocError('Permissão de localização negada — a pista será salva sem coordenadas.'); + return; + } + const loc = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + }); + setCoords({ lat: loc.coords.latitude, lng: loc.coords.longitude }); + } catch { + setLocError('Não consegui pegar o GPS agora. Tenta de novo ou salva sem coordenadas.'); + } finally { + setLocating(false); + } + }; + + useEffect(() => { + captureLocation(); + }, []); + + const canSave = name.trim().length >= 2 && !saving; + + const handleSave = async () => { + if (!canSave) return; + setSaving(true); + try { + const trimmedName = name.trim(); + await addCustomTrack({ + name: trimmedName, + // shortName = nome curtinho. Se o piloto digitou algo longo, corta. + shortName: trimmedName.length > 22 ? trimmedName.slice(0, 22) : trimmedName, + city: city.trim() || null, + state: state.trim().toUpperCase() || null, + // Coords: usa GPS capturado, ou 0/0 como fallback (sem distância). + lat: coords?.lat ?? 0, + lng: coords?.lng ?? 0, + lengthM: null, + }); + // Re-hidrata o cache pra a pista aparecer na lista imediatamente. + const all = await listCustomTracks(); + setCustomTracksCache(all); + router.back(); + } catch (e: any) { + Alert.alert('Erro', e?.message ?? 'Não consegui salvar a pista.'); + setSaving(false); + } + }; + + return ( + + + router.back()} hitSlop={12} style={s.backBtn}> + ‹ Voltar + + Nova pista + + Tá num kartódromo que não está na lista? Cria aqui. Usa o GPS pra + marcar onde fica. + + + + + NOME DA PISTA * + + + + + CIDADE + + + + UF + + + + + {/* Status do GPS — mostra coords capturadas ou estado/erro */} + + {locating ? ( + + + Pegando sua localização… + + ) : coords ? ( + + + + {coords.lat.toFixed(5)}, {coords.lng.toFixed(5)} + + + atualizar + + + ) : ( + + {locError ?? 'Sem localização.'} + + tentar de novo + + + )} + + + + + + {saving ? ( + + ) : ( + Salvar pista + )} + + + + ); +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + header: { paddingHorizontal: spacing.xl, paddingBottom: spacing.l }, + backBtn: { marginBottom: spacing.m }, + backTxt: { color: colors.textSecondary, fontSize: 15, fontWeight: '600' }, + title: { color: colors.textPrimary, fontSize: 26, fontWeight: '800', letterSpacing: -0.3 }, + subtitle: { + color: colors.textSecondary, + fontSize: 13, + marginTop: spacing.s, + lineHeight: 19, + }, + form: { paddingHorizontal: spacing.xl, gap: spacing.m, flex: 1 }, + label: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '800', + letterSpacing: 1.2, + marginBottom: 6, + }, + input: { + backgroundColor: colors.bgElevated, + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.m, + paddingHorizontal: spacing.m, + paddingVertical: 12, + color: colors.textPrimary, + fontSize: 15, + fontWeight: '500', + }, + row: { flexDirection: 'row', gap: spacing.m }, + gpsBox: { + backgroundColor: colors.bgElevated, + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.m, + padding: spacing.m, + marginTop: spacing.s, + }, + gpsRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.s }, + gpsDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.success }, + gpsText: { color: colors.textSecondary, fontSize: 13, flex: 1 }, + gpsRefresh: { color: colors.primary, fontSize: 12, fontWeight: '700' }, + gpsError: { color: colors.warning, fontSize: 12, lineHeight: 17, marginBottom: 6 }, + footer: { paddingHorizontal: spacing.xl, paddingTop: spacing.m }, + saveBtn: { + backgroundColor: colors.primary, + borderRadius: radius.m, + paddingVertical: 16, + alignItems: 'center', + }, + saveTxt: { color: colors.textOnPrimary, fontSize: 16, fontWeight: '800' }, +}); diff --git a/src/data/tracks.ts b/src/data/tracks.ts index 474d1db..5336b36 100644 --- a/src/data/tracks.ts +++ b/src/data/tracks.ts @@ -93,8 +93,37 @@ export const TRACKS: TrackRef[] = [ }, ]; +/** + * Cache em memória das pistas custom (criadas pelo usuário, vivem no + * SQLite). Mantido aqui pra que findTrackById/getAllTracks continuem + * SÍNCRONOS — muitos call sites (sessions, session/[id], etc) chamam + * findTrackById sem await. O cache é hidratado no boot do app + * (_layout.tsx) e re-hidratado quando uma pista nova é criada. + */ +let customTracksCache: TrackRef[] = []; + +/** Substitui o cache de pistas custom. Chamado após carregar do DB. */ +export function setCustomTracksCache(tracks: TrackRef[]): void { + customTracksCache = tracks; +} + +/** Pistas custom atualmente em cache (sync). */ +export function getCustomTracksCached(): TrackRef[] { + return customTracksCache; +} + +/** Todas as pistas: hardcoded + custom. */ +export function getAllTracks(): TrackRef[] { + return [...TRACKS, ...customTracksCache]; +} + +/** True se o id é de uma pista custom (criada pelo usuário). */ +export function isCustomTrack(id: string): boolean { + return id.startsWith('custom-'); +} + export function findTrackById(id: string): TrackRef | undefined { - return TRACKS.find((t) => t.id === id); + return TRACKS.find((t) => t.id === id) ?? customTracksCache.find((t) => t.id === id); } /** Distância haversine em km */ diff --git a/src/storage/db.ts b/src/storage/db.ts index 460da03..330f9d0 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -1,6 +1,7 @@ import * as SQLite from 'expo-sqlite'; import { GpsSample } from '../lib/geometry'; import { LapRecord } from '../lib/analysis'; +import type { TrackRef } from '../data/tracks'; let dbInstance: SQLite.SQLiteDatabase | null = null; @@ -199,6 +200,30 @@ async function db() { } await dbInstance.execAsync('PRAGMA user_version = 3'); } + + // Migration v3 → v4: tabela de pistas custom criadas pelo usuário. + // Hardcoded TRACKS cobrem só 8 kartódromos; quando o piloto está numa + // pista fora da lista, cria a sua aqui (nome + GPS atual). Mescladas + // com as hardcoded em getAllTracks(). + const v4 = await dbInstance.getFirstAsync<{ user_version: number }>( + 'PRAGMA user_version' + ); + if ((v4?.user_version ?? 0) < 4) { + await dbInstance.execAsync(` + CREATE TABLE IF NOT EXISTS custom_tracks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + short_name TEXT NOT NULL, + city TEXT, + state TEXT, + lat REAL NOT NULL, + lng REAL NOT NULL, + length_m REAL, + created_at INTEGER NOT NULL + ); + `); + await dbInstance.execAsync('PRAGMA user_version = 4'); + } } return dbInstance; } @@ -332,6 +357,78 @@ export async function getTrackHistory( })); } +// ========================= +// Custom tracks +// ========================= + +export type NewCustomTrack = { + name: string; + shortName: string; + city?: string | null; + state?: string | null; + lat: number; + lng: number; + lengthM?: number | null; +}; + +/** Lista pistas custom criadas pelo usuário (ordem: mais recente primeiro). */ +export async function listCustomTracks(): Promise { + const d = await db(); + const rows = await d.getAllAsync( + `SELECT id, name, short_name, city, state, lat, lng, length_m + FROM custom_tracks + ORDER BY created_at DESC` + ); + return rows.map((r) => ({ + id: r.id, + name: r.name, + shortName: r.short_name, + city: r.city ?? '', + state: r.state ?? '', + lat: r.lat, + lng: r.lng, + lengthM: r.length_m ?? 0, + })); +} + +/** Cria uma pista custom. Retorna o TrackRef pronto pra usar. */ +export async function addCustomTrack(t: NewCustomTrack): Promise { + const d = await db(); + // id estável + único. Prefixo "custom-" distingue de hardcoded em qualquer + // lugar que precise diferenciar (ex: não deixar deletar hardcoded). + const id = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + await d.runAsync( + `INSERT INTO custom_tracks (id, name, short_name, city, state, lat, lng, length_m, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, + t.name, + t.shortName, + t.city ?? null, + t.state ?? null, + t.lat, + t.lng, + t.lengthM ?? null, + Date.now() + ); + return { + id, + name: t.name, + shortName: t.shortName, + city: t.city ?? '', + state: t.state ?? '', + lat: t.lat, + lng: t.lng, + lengthM: t.lengthM ?? 0, + }; +} + +/** Remove uma pista custom (e seus layouts/sessões ficam órfãos por trackId + * string — não impacta o histórico já gravado). */ +export async function deleteCustomTrack(id: string): Promise { + const d = await db(); + await d.runAsync('DELETE FROM custom_tracks WHERE id = ?', id); +} + export async function deleteSession(id: string): Promise { const d = await db(); // FK ON DELETE CASCADE existe no schema, mas PRAGMA foreign_keys não tá From 6603a572a8dab4ac081809c87153d6db1f474477 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 12:17:56 -0300 Subject: [PATCH 08/20] =?UTF-8?q?feat(recovery):=20auto-save=20anti-crash?= =?UTF-8?q?=20=E2=80=94=20n=C3=A3o=20perde=20sess=C3=A3o=20se=20o=20app=20?= =?UTF-8?q?morrer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes: samples viviam só em memória (allSamplesRef) até o Encerrar. Crash / app morto pelo SO / bateria acabar no meio = perde tudo. Agora: snapshot do estado bruto (samples + IMU + metadata) num arquivo JSON a cada volta fechada + a cada 30s. Encerramento limpo apaga o arquivo. No boot, se o arquivo existe → houve interrupção → home mostra banner pra recuperar ou descartar. - src/storage/recovery.ts (novo): saveRecoverySnapshot / loadRecoverySnapshot / hasRecoverySnapshot / clearRecoverySnapshot via expo-file-system (documentDirectory, sobrescreve arquivo único). recoverSnapshotToSession: roda detectLaps nos samples brutos, cria session + salva voltas com samples + IMU recortados por timestamp. Versão enxuta (sem gamification/ IA) — o que importa é não perder o dado. - src/hooks/useLapRecorder.ts: start(recoveryMeta?) recebe metadata da sessão. Poll grava snapshot on lap close + a cada 30s (fire-and-forget, não bloqueia). stop() limpa o snapshot (encerramento limpo). - app/recording.tsx: handleStart passa metadata (trackId/Name, layoutId, kartSetupId, mode) pro start(). - app/(tabs)/index.tsx: banner de recuperação no topo da home quando há snapshot. Botões Recuperar (reconstrói + abre a sessão) / Descartar. Migration: nenhuma no SQLite (usa filesystem). Precisa rebuild do APK. --- app/(tabs)/index.tsx | 148 +++++++++++++++++++++++++++++++++++- app/recording.tsx | 10 ++- src/hooks/useLapRecorder.ts | 50 +++++++++++- src/storage/recovery.ts | 144 +++++++++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 src/storage/recovery.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 782d43f..0a7ae72 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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'; @@ -52,10 +58,19 @@ export default function Home() { const insets = useSafeAreaInsets(); const [profile, setProfile] = useState(null); const [sessions, setSessions] = useState([]); + // 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(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); @@ -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()); @@ -118,6 +173,47 @@ export default function Home() { + {/* Banner de recuperação anti-crash — só aparece se a última sessão + * foi interrompida sem encerrar (crash/SO/bateria). */} + {recovery && ( + + + Sessão interrompida + + {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 + + + + {recovering ? ( + + ) : ( + Recuperar + )} + + + Descartar + + + + + )} + {/* Citação Senna — destaque emocional, troca a cada abertura */} @@ -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', diff --git a/app/recording.tsx b/app/recording.tsx index 4fbd376..db216ce 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -223,7 +223,15 @@ export default function Recording() { const handleStart = async () => { setStarting(true); try { - await start(); + // Passa metadata pro auto-save anti-crash. Se o app morrer no meio, + // o boot detecta o snapshot e oferece recuperar a sessão. + await start({ + trackId: params.trackId ?? null, + trackName: params.trackName ?? 'Pista', + layoutId: params.layoutId ?? null, + kartSetupId: params.kartSetupId ?? null, + mode: 'race', + }); } catch (e: any) { Alert.alert('Erro', e.message ?? 'Falha ao iniciar GPS'); } diff --git a/src/hooks/useLapRecorder.ts b/src/hooks/useLapRecorder.ts index 140ca87..ffa6229 100644 --- a/src/hooks/useLapRecorder.ts +++ b/src/hooks/useLapRecorder.ts @@ -6,6 +6,16 @@ import { Accelerometer, Gyroscope } from 'expo-sensors'; import { GpsSample, ImuSample, LatLng } from '../lib/geometry'; import { detectLaps, DetectedLap } from '../lib/lapDetector'; import { DeltaTracker } from '../lib/realtimeDelta'; +import { saveRecoverySnapshot, clearRecoverySnapshot } from '../storage/recovery'; + +/** Metadata da sessão pro snapshot de recuperação anti-crash. */ +export type RecoveryMeta = { + trackId: string | null; + trackName: string; + layoutId: string | null; + kartSetupId: string | null; + mode: 'race' | 'reference'; +}; const BG_TASK = 'KARTLAP_BG_LOCATION'; @@ -307,6 +317,9 @@ export function useLapRecorder(options?: LapRecorderOptions) { // mas é local — não publica em realtime (sobrecarga de rede). Stop() // recorta esses por timestamp pra cada lap. const allImuRef = useRef([]); + // Recovery anti-crash: metadata da sessão + timestamp do último snapshot. + const recoveryMetaRef = useRef(null); + const lastSnapshotAtRef = useRef(0); const lastDetectionRef = useRef([]); const movingStartIdxRef = useRef(-1); const targetReachedRef = useRef(false); @@ -353,8 +366,10 @@ export function useLapRecorder(options?: LapRecorderOptions) { // tem ciclo próprio (não some após 1s — fica visível até a próxima volta). const lastClosedLapSectorsRef = useRef(null); - const start = useCallback(async () => { + const start = useCallback(async (recoveryMeta?: RecoveryMeta) => { setState('requesting'); + recoveryMetaRef.current = recoveryMeta ?? null; + lastSnapshotAtRef.current = 0; buf.samples = []; buf.imu = []; allSamplesRef.current = []; @@ -683,6 +698,33 @@ export function useLapRecorder(options?: LapRecorderOptions) { } else { setLiveSamples([]); } + + // ===== Auto-save anti-crash ===== + // Snapshot do estado bruto em arquivo on lap close + a cada 30s. + // Se o app morrer (crash/SO/bateria) no meio da sessão, o boot + // detecta o arquivo e oferece recuperar. Fire-and-forget (async, + // não bloqueia o poll). Só roda se recoveryMeta foi passado no start. + const meta = recoveryMetaRef.current; + if (meta && all.length > 0) { + const now = Date.now(); + const shouldSnapshot = + closedNewLap || now - lastSnapshotAtRef.current > 30_000; + if (shouldSnapshot) { + lastSnapshotAtRef.current = now; + saveRecoverySnapshot({ + version: 1, + startedAt: startTRef.current, + updatedAt: now, + trackId: meta.trackId, + trackName: meta.trackName, + layoutId: meta.layoutId, + kartSetupId: meta.kartSetupId, + mode: meta.mode, + samples: all, + imuSamples: allImuRef.current, + }); + } + } }, 500); }, [options?.targetLaps, options?.onTargetReached]); @@ -700,6 +742,12 @@ export function useLapRecorder(options?: LapRecorderOptions) { stopImuCapture(); deactivateKeepAwake('copilot-recording'); + // Encerramento limpo — apaga o snapshot de recovery. A sessão vai ser + // salva normalmente pelo caller (createSession + saveLap), então não + // há o que recuperar. fire-and-forget. + recoveryMetaRef.current = null; + clearRecoverySnapshot(); + // Última drenagem do buffer — pode ter samples chegando entre o poll // anterior e agora. GPS + IMU. if (buf.samples.length > 0) { diff --git a/src/storage/recovery.ts b/src/storage/recovery.ts new file mode 100644 index 0000000..fcdf3cd --- /dev/null +++ b/src/storage/recovery.ts @@ -0,0 +1,144 @@ +/** + * Recuperação anti-crash de sessão de gravação. + * + * Problema: durante a gravação os samples vivem só em memória + * (allSamplesRef no hook), salvos no SQLite só no Encerrar. Se o app + * crashar/for morto pelo SO/bateria acabar no meio de uma sessão de + * 15min, perde TUDO. + * + * Solução: a cada volta fechada + a cada 30s, gravamos um snapshot do + * estado bruto (samples + imu + metadata) num arquivo JSON. No encerramento + * limpo, apagamos. No boot do app, se o arquivo existe → houve crash → + * oferecemos recuperar. + * + * Arquivo único (sobrescreve) em documentDirectory. Snapshot completo a + * cada escrita (não append) — mais simples e robusto; o custo de escrever + * ~1-3MB a cada 30s é aceitável e roda async sem travar a gravação. + */ + +import * as FileSystem from 'expo-file-system'; +import type { GpsSample, ImuSample } from '../lib/geometry'; +import { detectLaps } from '../lib/lapDetector'; +import type { LapRecord } from '../lib/analysis'; +import { createSession, saveLap } from './db'; + +// expo-file-system v19 expõe API legada via cast (mesmo padrão do +// profile.ts). documentDirectory + read/write/delete/getInfo. +const FS = FileSystem as any; +const RECOVERY_URI = (FS.documentDirectory ?? '') + 'copilot-recovery.json'; + +export type RecoverySnapshot = { + version: 1; + startedAt: number; + updatedAt: number; + trackId: string | null; + trackName: string; + layoutId: string | null; + kartSetupId: string | null; + mode: 'race' | 'reference'; + samples: GpsSample[]; + imuSamples: ImuSample[]; +}; + +/** Grava (sobrescreve) o snapshot. Best-effort — falha não quebra gravação. */ +export async function saveRecoverySnapshot(snap: RecoverySnapshot): Promise { + try { + await FS.writeAsStringAsync(RECOVERY_URI, JSON.stringify(snap)); + } catch { + /* best-effort: se o disco falhar, a gravação em memória segue normal */ + } +} + +/** Lê o snapshot se existir e for válido. null caso contrário. */ +export async function loadRecoverySnapshot(): Promise { + try { + const info = await FS.getInfoAsync(RECOVERY_URI); + if (!info?.exists) return null; + const raw = await FS.readAsStringAsync(RECOVERY_URI); + const parsed = JSON.parse(raw); + if ( + parsed?.version !== 1 || + !Array.isArray(parsed.samples) || + parsed.samples.length === 0 + ) { + return null; + } + return parsed as RecoverySnapshot; + } catch { + return null; + } +} + +/** Existe snapshot recuperável? (mais barato que loadRecoverySnapshot). */ +export async function hasRecoverySnapshot(): Promise { + try { + const info = await FS.getInfoAsync(RECOVERY_URI); + return !!info?.exists; + } catch { + return false; + } +} + +/** Apaga o snapshot — chamado no encerramento limpo ou após recuperar/descartar. */ +export async function clearRecoverySnapshot(): Promise { + try { + await FS.deleteAsync(RECOVERY_URI, { idempotent: true }); + } catch { + /* idempotente; ignora se já não existe */ + } +} + +/** + * Reconstrói uma sessão salva a partir do snapshot e apaga o arquivo. + * Roda detectLaps nos samples brutos (mesma fonte de verdade do stop()), + * cria a session no SQLite e salva cada volta com seus samples + IMU + * recortados por timestamp. + * + * Versão enxuta vs handleFinish do recording: NÃO roda gamification/IA/ + * leaderboard (nice-to-have). O importante é não perder o dado bruto — + * análise pode ser refeita depois sobre a sessão recuperada. + * + * Retorna o id da sessão criada, ou null se não deu pra reconstruir + * (ex: poucos samples / nenhuma volta fechada). + */ +export async function recoverSnapshotToSession( + snap: RecoverySnapshot +): Promise { + const detection = detectLaps(snap.samples); + if (detection.laps.length === 0) { + // Sem volta fechada — nada útil pra salvar. Limpa e desiste. + await clearRecoverySnapshot(); + return null; + } + + const session = await createSession({ + trackName: snap.trackName, + kart: null, + notes: 'Sessão recuperada após interrupção', + weather: 'dry', + trackId: snap.trackId, + mode: snap.mode, + layoutId: snap.layoutId, + kartSetupId: snap.kartSetupId, + }); + + for (let i = 0; i < detection.laps.length; i++) { + const lap = detection.laps[i]; + const lapEndT = lap.startedAt + lap.durationMs; + const imuSlice = snap.imuSamples.filter( + (s) => s.t >= lap.startedAt && s.t <= lapEndT + ); + const rec: LapRecord = { + id: `${session.id}_lap_${i + 1}`, + sessionId: session.id, + samples: snap.samples.slice(lap.startIdx, lap.endIdx + 1), + startedAt: lap.startedAt, + durationMs: lap.durationMs, + imuSamples: imuSlice.length > 0 ? imuSlice : undefined, + }; + await saveLap(rec); + } + + await clearRecoverySnapshot(); + return session.id; +} From 3dea91ba032edbd4890fcaadec60141ad6fe6bfb Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 12:51:33 -0300 Subject: [PATCH 09/20] =?UTF-8?q?feat(sync):=20backup=20an=C3=B4nimo=20de?= =?UTF-8?q?=20sess=C3=B5es=20na=20nuvem=20(device=5Fid,=20sem=20login)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opção B (enxuta): sincroniza metadata + tempos de volta pro Supabase por device_id. Backup pro tester (não perde histórico ao reinstalar/ trocar de celular) + visibilidade pro dev (ver uso de campo dos testers sem precisar pegar o celular deles). Sem login = zero fricção. NÃO sincroniza sample arrays brutos (GPS/IMU) — grandes demais pro free tier. Só metadata + tempos. Telemetria completa fica local. - supabase/schema.sql: tabelas synced_sessions (metadata + best_lap + lap_count) e synced_laps (tempos por volta + colunas de setor prontas). Upsert idempotente via UNIQUE(device_id, local_session_id[, lap_number]). RLS permissivo + policies idempotentes. - src/lib/sessionSync.ts (novo): syncSession (upsert 1 sessão + voltas) + syncAllSessions (backfill das 50 mais recentes). Respeita toggle de opt-out. Falha silenciosa — nunca quebra o fluxo (funciona offline). - src/storage/preferences.ts: getCloudSyncEnabled/setCloudSyncEnabled (default ON). - app/recording.tsx: syncSession fire-and-forget após salvar a sessão. - app/_layout.tsx: syncAllSessions no boot (background). - app/settings.tsx: toggle "Sincronizar sessões" em BACKUP NA NUVEM — opt-out de privacidade (LGPD: usuário pode recusar). Dev vê os dados no Supabase Table Editor (synced_sessions / synced_laps) por enquanto. Dashboard web dedicado pode vir depois. Nota LGPD: pra produção, adicionar tela de consentimento explícito no onboarding. O toggle default-ON + opt-out é um começo razoável pra teste com pessoas conhecidas. Precisa rodar o schema.sql atualizado no Supabase + rebuild do APK. --- app/_layout.tsx | 4 ++ app/recording.tsx | 5 ++ app/settings.tsx | 24 ++++++++- src/lib/sessionSync.ts | 106 +++++++++++++++++++++++++++++++++++++ src/storage/preferences.ts | 10 ++++ supabase/schema.sql | 58 ++++++++++++++++++++ 6 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/lib/sessionSync.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 23c4866..e3c8f43 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,6 +14,7 @@ import { 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'; @@ -105,6 +106,9 @@ export default function RootLayout() { } 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); } diff --git a/app/recording.tsx b/app/recording.tsx index db216ce..803b1a8 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -55,6 +55,7 @@ import { } from '../src/lib/liveSession'; import { polylineLength } from '../src/lib/geometry'; import { LapRecord } from '../src/lib/analysis'; +import { syncSession } from '../src/lib/sessionSync'; import { Button, Card, Icon } from '../src/components/ui'; import { LapResultOverlay } from '../src/components/LapResultOverlay'; import { PilotMessageOverlay } from '../src/components/PilotMessageOverlay'; @@ -436,6 +437,10 @@ export default function Recording() { await saveLap(lap); } + // Sync anônimo da sessão pra nuvem (backup + visibilidade dev). + // Fire-and-forget, respeita opt-out, falha silenciosa offline. + syncSession(session, lapsToSave).catch(() => {}); + if (lapsToSave.length === 0) { Alert.alert( 'Nenhuma volta completa', diff --git a/app/settings.tsx b/app/settings.tsx index 42fb1b1..c8c84f4 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -4,7 +4,11 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { clearProfile, getProfile, PilotProfile } from '../src/storage/profile'; import { getStoredCredential } from '../src/storage/apiKey'; import { getClient } from '../src/lib/llm'; -import { useCoachFloatingEnabled } from '../src/storage/preferences'; +import { + useCoachFloatingEnabled, + getCloudSyncEnabled, + setCloudSyncEnabled, +} from '../src/storage/preferences'; import { BrandMark, Card, @@ -26,6 +30,7 @@ export default function Settings() { const [profile, setProfile] = useState(null); const [aiProviderLabel, setAiProviderLabel] = useState(null); const [coachFloatingEnabled, setCoachFloatingEnabled] = useCoachFloatingEnabled(); + const [cloudSync, setCloudSync] = useState(true); const [state, setState] = useState({ unit: 'metric', notifications: true, @@ -38,9 +43,15 @@ export default function Settings() { getStoredCredential().then((c) => setAiProviderLabel(c ? getClient(c.provider).meta.displayName : null) ); + getCloudSyncEnabled().then(setCloudSync); }, []) ); + const handleCloudSyncChange = (v: boolean) => { + setCloudSync(v); + setCloudSyncEnabled(v); + }; + const handleSignOut = () => { Alert.alert( 'Sair da conta?', @@ -138,6 +149,17 @@ export default function Settings() { /> + {/* Privacidade / backup */} + BACKUP NA NUVEM + + + + {/* Conta */} {profile && ( <> diff --git a/src/lib/sessionSync.ts b/src/lib/sessionSync.ts new file mode 100644 index 0000000..8a0269e --- /dev/null +++ b/src/lib/sessionSync.ts @@ -0,0 +1,106 @@ +/** + * Sync anônimo de sessões pro Supabase — backup do tester + visibilidade + * do dev na fase de campo. SEM login: chaveado por device_id. + * + * O QUE sincroniza: metadata da sessão (pista, modo, data) + tempos de + * volta + setores. NÃO sincroniza os sample arrays brutos (GPS/IMU) — + * grandes demais pro free tier do Supabase. Telemetria completa fica + * local; pode ser sincronizada sob demanda no futuro. + * + * Idempotente: upsert por (device_id, local_session_id). Pode rodar + * quantas vezes quiser sem duplicar. Respeita o toggle getCloudSyncEnabled + * (opt-out de privacidade nas configurações). + * + * Falha silenciosa em tudo — sync é "nice to have", nunca quebra o fluxo + * principal (gravar/salvar local sempre funciona offline). + */ + +import { getSupabase } from './supabase'; +import { getDeviceId } from './deviceId'; +import { getProfile } from '../storage/profile'; +import { getCloudSyncEnabled } from '../storage/preferences'; +import { listSessions, getLapsForSession, type Session } from '../storage/db'; +import type { LapRecord } from './analysis'; + +/** + * Sincroniza UMA sessão (metadata + voltas). Chamado após salvar uma + * sessão nova. Best-effort. + */ +export async function syncSession(session: Session, laps: LapRecord[]): Promise { + if (!(await getCloudSyncEnabled())) return; + const supabase = getSupabase(); + if (!supabase) return; + + try { + const deviceId = await getDeviceId(); + const profile = await getProfile(); + const lapMsList = laps.map((l) => l.durationMs); + const bestLapMs = lapMsList.length > 0 ? Math.min(...lapMsList) : null; + + // Upsert da sessão (idempotente por device_id + local_session_id) + await supabase.from('synced_sessions').upsert( + { + device_id: deviceId, + local_session_id: session.id, + pilot_name: profile?.name ?? null, + track_id: session.trackId, + track_name: session.trackName, + layout_id: session.layoutId ?? null, + mode: session.mode, + started_at: new Date(session.startedAt).toISOString(), + best_lap_ms: bestLapMs, + lap_count: laps.length, + synced_at: new Date().toISOString(), + }, + { onConflict: 'device_id,local_session_id' } + ); + + // Upsert das voltas. lap_number derivado do índice (1-based) — estável + // porque getLapsForSession ordena por started_at asc. + if (laps.length > 0) { + const rows = laps.map((lap, i) => ({ + device_id: deviceId, + local_session_id: session.id, + lap_number: i + 1, + duration_ms: lap.durationMs, + // Setores: o LapRecord não carrega sectors hoje (são derivados em + // runtime). Deixa null por enquanto — pode ser preenchido quando + // a detecção de setor for persistida no lap. Mantém o schema pronto. + s1_ms: null, + s2_ms: null, + s3_ms: null, + })); + await supabase + .from('synced_laps') + .upsert(rows, { onConflict: 'device_id,local_session_id,lap_number' }); + } + } catch { + /* best-effort — sync nunca quebra o fluxo principal */ + } +} + +/** + * Backfill: sincroniza TODAS as sessões locais que ainda não estão na + * nuvem (ou re-sincroniza — upsert é idempotente). Roda uma vez por + * abertura do app (chamado no boot), em background. + * + * Pra footprint pequeno: como upsert é idempotente, re-enviar tudo é + * seguro. Pra N pequeno (testers têm dezenas de sessões) é tranquilo. + * Se crescer muito, dá pra adicionar flag synced_at local e filtrar. + */ +export async function syncAllSessions(): Promise { + if (!(await getCloudSyncEnabled())) return; + const supabase = getSupabase(); + if (!supabase) return; + + try { + const sessions = await listSessions(); + // Limita a 50 sessões mais recentes pra não martelar a rede no boot. + for (const session of sessions.slice(0, 50)) { + const laps = await getLapsForSession(session.id); + await syncSession(session, laps); + } + } catch { + /* best-effort */ + } +} diff --git a/src/storage/preferences.ts b/src/storage/preferences.ts index 1376ebf..b2867e4 100644 --- a/src/storage/preferences.ts +++ b/src/storage/preferences.ts @@ -36,6 +36,16 @@ const COACH_FLOATING_KEY = 'coach_floating_enabled'; export const getCoachFloatingEnabled = () => getBool(COACH_FLOATING_KEY, true); export const setCoachFloatingEnabled = (v: boolean) => setBool(COACH_FLOATING_KEY, v); +// ===== Toggle: sync de sessões na nuvem (backup anônimo por device_id) ===== +// Default ON — backup + visibilidade dev na fase de teste. Opt-out nas +// configurações pra respeitar privacidade (LGPD: usuário pode recusar). +// Sincroniza só metadata + tempos (não samples brutos). + +const CLOUD_SYNC_KEY = 'cloud_sync_enabled'; + +export const getCloudSyncEnabled = () => getBool(CLOUD_SYNC_KEY, true); +export const setCloudSyncEnabled = (v: boolean) => setBool(CLOUD_SYNC_KEY, v); + /** * Hook React que mantém o estado reativo do toggle. Componentes podem usar * pra esconder o botão quando o piloto desativar nas configurações. diff --git a/supabase/schema.sql b/supabase/schema.sql index fb48bad..e626d52 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -156,6 +156,48 @@ begin exception when duplicate_object then null; end $$; +-- ===================== +-- Sync anônimo de sessões (backup + visibilidade dev) +-- ===================== +-- Sincroniza METADATA + tempos de volta (não os sample arrays brutos — +-- grandes demais pro free tier). Chaveado por device_id (anônimo, sem +-- login). Dá backup pro tester + dashboard pro dev ver uso de campo. +-- Telemetria completa (samples/IMU) continua local; sincroniza sob demanda. +-- +-- Upsert idempotente via UNIQUE(device_id, local_session_id). + +create table if not exists synced_sessions ( + id uuid primary key default gen_random_uuid(), + device_id text not null, + local_session_id text not null, -- id da session no SQLite do device + pilot_name text, + track_id text, + track_name text, + layout_id text, + mode text, + started_at timestamptz, + best_lap_ms int, + lap_count int, + synced_at timestamptz default now(), + unique (device_id, local_session_id) +); + +create index if not exists idx_synced_sessions_device on synced_sessions(device_id, started_at desc); + +create table if not exists synced_laps ( + id bigserial primary key, + device_id text not null, + local_session_id text not null, + lap_number int not null, + duration_ms int not null, + s1_ms int, + s2_ms int, + s3_ms int, + unique (device_id, local_session_id, lap_number) +); + +create index if not exists idx_synced_laps_session on synced_laps(device_id, local_session_id); + -- ===================== -- RLS (Row-Level Security) -- ===================== @@ -172,6 +214,8 @@ alter table live_sessions enable row level security; alter table live_samples enable row level security; alter table live_laps enable row level security; alter table live_messages enable row level security; +alter table synced_sessions enable row level security; +alter table synced_laps enable row level security; -- Policies — `create policy` não tem "if not exists" no Postgres, então -- precedemos cada uma com `drop policy if exists` pra que o schema rode @@ -201,6 +245,20 @@ create policy "public read live_laps" on live_laps for select using (true); drop policy if exists "public insert live_laps" on live_laps; create policy "public insert live_laps" on live_laps for insert with check (true); +drop policy if exists "public read synced_sessions" on synced_sessions; +create policy "public read synced_sessions" on synced_sessions for select using (true); +drop policy if exists "public insert synced_sessions" on synced_sessions; +create policy "public insert synced_sessions" on synced_sessions for insert with check (true); +drop policy if exists "public update synced_sessions" on synced_sessions; +create policy "public update synced_sessions" on synced_sessions for update using (true); + +drop policy if exists "public read synced_laps" on synced_laps; +create policy "public read synced_laps" on synced_laps for select using (true); +drop policy if exists "public insert synced_laps" on synced_laps; +create policy "public insert synced_laps" on synced_laps for insert with check (true); +drop policy if exists "public update synced_laps" on synced_laps; +create policy "public update synced_laps" on synced_laps for update using (true); + drop policy if exists "public read live_messages" on live_messages; create policy "public read live_messages" on live_messages for select using (true); drop policy if exists "public insert live_messages" on live_messages; From 4e49f61ac18ebc3354352500a5447f0ab551847f Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 13:24:00 -0300 Subject: [PATCH 10/20] =?UTF-8?q?feat(competition):=20wave=201=20=E2=80=94?= =?UTF-8?q?=20schema=20+=20APIs=20de=20evento=20multi-piloto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modo competição: evento agrupa várias live_sessions (1 por piloto) sob um código. Ranking ao vivo agrega a melhor volta de cada piloto. Schema (supabase/schema.sql): - Tabela events (code, name, track, created_by, expires 12h) - live_sessions.event_id + live_laps.event_id (denormalizado pro realtime filtrar por evento) - Realtime em events + RLS/policies idempotentes + índices APIs (src/lib/liveSession.ts): - EventInfo + EventRankingRow; eventId em CreateOpts/LiveSessionInfo - createEvent, findEventByCode, createLiveSession grava event_id - loadEventRanking (agrega por piloto, ordena por melhor volta) - subscribeEventLaps (realtime de voltas do evento) - publishLap aceita eventId --- src/lib/liveSession.ts | 207 ++++++++++++++++++++++++++++++++++++++++- supabase/schema.sql | 45 +++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/src/lib/liveSession.ts b/src/lib/liveSession.ts index 95105d3..857b889 100644 --- a/src/lib/liveSession.ts +++ b/src/lib/liveSession.ts @@ -45,6 +45,26 @@ export type LiveLap = { s3Ms?: number | null; }; +/** Evento / competição que agrupa várias live_sessions. */ +export type EventInfo = { + id: string; + code: string; + name: string | null; + trackId: string | null; + trackName: string | null; +}; + +/** Uma linha do ranking do evento — agregado por piloto. */ +export type EventRankingRow = { + pilotName: string; + kartNumber: string | null; + bestLapMs: number | null; + lastLapMs: number | null; + lapCount: number; + /** Timestamp da última volta — pra ordenar/detectar atividade. */ + lastLapAt: number | null; +}; + /** * Mensagem da equipe pro piloto. Severidade controla a cor do overlay * no app (info=verde, warning=amarelo, critical=vermelho). @@ -74,6 +94,8 @@ export type LiveSessionInfo = { referenceLapMs: number | null; startedAt: number; endedAt: number | null; + /** Evento de competição ao qual a sessão pertence (null se avulsa). */ + eventId: string | null; }; const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // sem 0/O/1/I/L @@ -118,6 +140,8 @@ export type CreateOpts = { trackName: string | null; trackId: string | null; referenceLapMs?: number | null; + /** Se a sessão faz parte de uma competição, o id do evento. */ + eventId?: string | null; }; /** @@ -140,6 +164,7 @@ export async function createLiveSession(opts: CreateOpts): Promise { + const supabase = getSupabase(); + if (!supabase) throw new Error('Competição precisa do Supabase configurado.'); + const deviceId = await getDeviceId(); + + for (let attempt = 0; attempt < 5; attempt++) { + const code = generateCode(); + const { data, error } = await supabase + .from('events') + .insert({ + code, + name: opts.name, + track_id: opts.trackId, + track_name: opts.trackName, + created_by: deviceId, + }) + .select('*') + .single(); + if (!error && data) return mapEventRow(data); + if (error?.code !== '23505') { + throw new Error(error?.message ?? 'Erro ao criar evento'); + } + } + throw new Error('Não foi possível gerar um código único após 5 tentativas'); +} + +/** Busca um evento pelo código. null se não existe. */ +export async function findEventByCode(code: string): Promise { + const supabase = getSupabase(); + if (!supabase) return null; + const { data } = await supabase + .from('events') + .select('*') + .eq('code', code.toUpperCase()) + .maybeSingle(); + return data ? mapEventRow(data) : null; +} + +/** + * Carrega o ranking agregado do evento. Junta todas as live_sessions do + * evento + suas voltas + nome do piloto, e agrega por piloto: + * bestLapMs = menor duração, lastLapMs = volta mais recente, lapCount. + * + * Ordenado por melhor volta (asc) — líder primeiro. Pilotos sem volta + * fechada ainda vão pro fim. + */ +export async function loadEventRanking(eventId: string): Promise { + const supabase = getSupabase(); + if (!supabase) return []; + + // 1. Sessions do evento + pilotos + const { data: sessRows } = await supabase + .from('live_sessions') + .select('id, pilot_id, pilots(display_name, kart_number)') + .eq('event_id', eventId); + if (!sessRows || sessRows.length === 0) return []; + + // 2. Todas as voltas do evento (denormalizado por event_id) + const { data: lapRows } = await supabase + .from('live_laps') + .select('live_session_id, duration_ms, finished_at') + .eq('event_id', eventId) + .order('finished_at', { ascending: true }); + + // Mapa session → piloto + const pilotBySession = new Map(); + for (const s of sessRows as any[]) { + pilotBySession.set(s.id, { + name: s.pilots?.display_name ?? 'Piloto', + kart: s.pilots?.kart_number ?? null, + }); + } + + // Agrega por NOME do piloto (junta múltiplas sessions do mesmo piloto + // — ex: parou e voltou). Chave por nome+kart é suficiente pra MVP. + const byPilot = new Map(); + for (const lap of (lapRows ?? []) as any[]) { + const pilot = pilotBySession.get(lap.live_session_id); + if (!pilot) continue; + const key = `${pilot.name}#${pilot.kart ?? ''}`; + const t = new Date(lap.finished_at).getTime(); + const existing = byPilot.get(key); + if (!existing) { + byPilot.set(key, { + pilotName: pilot.name, + kartNumber: pilot.kart, + bestLapMs: lap.duration_ms, + lastLapMs: lap.duration_ms, + lapCount: 1, + lastLapAt: t, + }); + } else { + existing.lapCount += 1; + if (lap.duration_ms < (existing.bestLapMs ?? Infinity)) { + existing.bestLapMs = lap.duration_ms; + } + // lapRows vem ordenado asc por finished_at, então o último visto é o mais recente + existing.lastLapMs = lap.duration_ms; + existing.lastLapAt = t; + } + } + + // Pilotos sem volta ainda — entram no ranking com null (no fim) + for (const [, pilot] of pilotBySession) { + const key = `${pilot.name}#${pilot.kart ?? ''}`; + if (!byPilot.has(key)) { + byPilot.set(key, { + pilotName: pilot.name, + kartNumber: pilot.kart, + bestLapMs: null, + lastLapMs: null, + lapCount: 0, + lastLapAt: null, + }); + } + } + + return Array.from(byPilot.values()).sort((a, b) => { + if (a.bestLapMs === null && b.bestLapMs === null) return 0; + if (a.bestLapMs === null) return 1; + if (b.bestLapMs === null) return -1; + return a.bestLapMs - b.bestLapMs; + }); +} + +/** + * Assina novas voltas do evento (de QUALQUER piloto). Callback dispara a + * cada volta fechada — o consumidor re-carrega o ranking (debounced). + * Retorna unsubscribe. + */ +export function subscribeEventLaps(eventId: string, onLap: () => void): () => void { + const supabase = getSupabase(); + if (!supabase) return () => {}; + const channel: RealtimeChannel = supabase + .channel(`event:${eventId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'live_laps', + filter: `event_id=eq.${eventId}`, + }, + () => onLap() + ) + .subscribe(); + return () => { + supabase.removeChannel(channel); + }; +} + export async function endLiveSession(code: string): Promise { const supabase = getSupabase(); if (!supabase) return; @@ -189,7 +387,11 @@ export async function publishSample(sessionId: string, sample: LiveSample): Prom if (error && __DEV__) console.warn('[liveSession] publishSample error:', error.message); } -export async function publishLap(sessionId: string, lap: LiveLap): Promise { +export async function publishLap( + sessionId: string, + lap: LiveLap, + eventId?: string | null +): Promise { const supabase = getSupabase(); if (!supabase) return; const { error } = await supabase.from('live_laps').insert({ @@ -200,6 +402,8 @@ export async function publishLap(sessionId: string, lap: LiveLap): Promise s1_ms: lap.s1Ms ?? null, s2_ms: lap.s2Ms ?? null, s3_ms: lap.s3Ms ?? null, + // Denormalizado pro ranking de evento via realtime filtrado por event_id. + event_id: eventId ?? null, }); if (error && __DEV__) console.warn('[liveSession] publishLap error:', error.message); } @@ -387,6 +591,7 @@ function mapSessionRow(row: any): LiveSessionInfo { referenceLapMs: row.reference_lap_ms ?? null, startedAt: new Date(row.started_at).getTime(), endedAt: row.ended_at ? new Date(row.ended_at).getTime() : null, + eventId: row.event_id ?? null, }; } diff --git a/supabase/schema.sql b/supabase/schema.sql index e626d52..1a3ef4d 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -96,6 +96,37 @@ alter table live_laps add column if not exists s1_ms int; alter table live_laps add column if not exists s2_ms int; alter table live_laps add column if not exists s3_ms int; +-- ===================== +-- Eventos / Modo Competição +-- ===================== +-- Um EVENTO agrupa várias live_sessions (uma por piloto) sob um código. +-- Ranking ao vivo agrega a melhor volta de cada piloto. GPS outdoor — +-- cada piloto roda o app, entra no código, e cronometra suas voltas. +-- +-- live_sessions.event_id liga a sessão ao evento. live_laps.event_id é +-- denormalizado (mesmo valor) pra permitir realtime filtrado por evento +-- (subscribe em live_laps WHERE event_id = X pega voltas de TODOS os +-- pilotos do evento num canal só). + +create table if not exists events ( + id uuid primary key default gen_random_uuid(), + code text unique not null, + name text, + track_id text, + track_name text, + created_by text, -- device_id do criador, ou 'web' + created_at timestamptz default now(), + expires_at timestamptz default now() + interval '12 hours' +); + +create index if not exists idx_events_code on events(code); + +alter table live_sessions add column if not exists event_id uuid references events(id) on delete set null; +alter table live_laps add column if not exists event_id uuid; + +create index if not exists idx_live_laps_event on live_laps(event_id, finished_at); +create index if not exists idx_live_sessions_event on live_sessions(event_id); + -- ===================== -- Mensagens da equipe pro piloto (Team→Pilot) -- ===================== @@ -156,6 +187,12 @@ begin exception when duplicate_object then null; end $$; +do $$ +begin + alter publication supabase_realtime add table events; +exception when duplicate_object then null; +end $$; + -- ===================== -- Sync anônimo de sessões (backup + visibilidade dev) -- ===================== @@ -216,6 +253,7 @@ alter table live_laps enable row level security; alter table live_messages enable row level security; alter table synced_sessions enable row level security; alter table synced_laps enable row level security; +alter table events enable row level security; -- Policies — `create policy` não tem "if not exists" no Postgres, então -- precedemos cada uma com `drop policy if exists` pra que o schema rode @@ -259,6 +297,13 @@ create policy "public insert synced_laps" on synced_laps for insert with check ( drop policy if exists "public update synced_laps" on synced_laps; create policy "public update synced_laps" on synced_laps for update using (true); +drop policy if exists "public read events" on events; +create policy "public read events" on events for select using (true); +drop policy if exists "public insert events" on events; +create policy "public insert events" on events for insert with check (true); +drop policy if exists "public update events" on events; +create policy "public update events" on events for update using (true); + drop policy if exists "public read live_messages" on live_messages; create policy "public read live_messages" on live_messages for select using (true); drop policy if exists "public insert live_messages" on live_messages; From a38d64ab3febfc0b05ba639f68b7b08a2a1264de Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 13:37:07 -0300 Subject: [PATCH 11/20] =?UTF-8?q?feat(competition):=20wave=202=20=E2=80=94?= =?UTF-8?q?=20UI=20no=20app=20pra=20criar/entrar=20em=20evento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompetitionPanel na tela idle (abaixo do toggle de live): - "Criar competição" → createEvent → live session nasce vinculada ao evento (eventId), mostra o código pra compartilhar - "Entrar com código" → findEventByCode → idem - Quando em evento: card destacado (ciano) com código + link do ranking web Entrar/criar implica ativar live (a sessão precisa jorrar voltas pro ranking). handleStartLive agora aceita eventId; publishLap manda eventId (denormalizado) pra cada volta entrar no ranking via realtime. handleStopLive limpa o event. Imports: ActivityIndicator + TextInput. --- app/recording.tsx | 253 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 243 insertions(+), 10 deletions(-) diff --git a/app/recording.tsx b/app/recording.tsx index 803b1a8..de3be24 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState } from 'react'; import { + ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, + TextInput, useWindowDimensions, Vibration, View, @@ -46,6 +48,9 @@ import { ensurePilot } from '../src/lib/liveSession'; import { createLiveSession, endLiveSession, + createEvent, + findEventByCode, + EventInfo, LiveMessage, LiveSessionInfo, ackMessage, @@ -129,6 +134,9 @@ export default function Recording() { const [live, setLive] = useState(null); const [liveModalOpen, setLiveModalOpen] = useState(false); const [liveStarting, setLiveStarting] = useState(false); + // Competição: evento ao qual o piloto entrou (null = corrida avulsa). + const [event, setEvent] = useState(null); + const [eventBusy, setEventBusy] = useState(false); const lastSampleIdxRef = useRef(0); const lastLapCountRef = useRef(0); // Mensagem mais recente vinda da equipe pelo realtime. Quando vira não-null, @@ -251,13 +259,14 @@ export default function Recording() { * Se chamado durante recording (legacy path), também não abre modal — * tap no badge do top bar abre quando precisar. */ - const handleStartLive = async () => { + const handleStartLive = async (eventId?: string | null) => { setLiveStarting(true); try { const info = await createLiveSession({ trackId: params.trackId ?? null, trackName: params.trackName ?? 'Pista', referenceLapMs: reference?.durationMs ?? null, + eventId: eventId ?? null, }); setLive(info); lastSampleIdxRef.current = 0; @@ -280,9 +289,51 @@ export default function Recording() { /* silencioso — se já caiu, segue */ } setLive(null); + setEvent(null); setLiveModalOpen(false); }; + // ===== Competição (evento multi-piloto) ===== + // Entrar/criar um evento implica ativar o live broadcast (a sessão tem + // que jorrar voltas pro ranking). Por isso esses handlers chamam + // handleStartLive com o eventId — a live session já nasce vinculada. + + const handleCreateEvent = async () => { + setEventBusy(true); + try { + const ev = await createEvent({ + name: params.trackName ? `Corrida · ${params.trackName}` : 'Competição', + trackId: params.trackId ?? null, + trackName: params.trackName ?? null, + }); + setEvent(ev); + await handleStartLive(ev.id); + } catch (err: any) { + Alert.alert('Erro', err?.message ?? 'Não foi possível criar a competição.'); + } finally { + setEventBusy(false); + } + }; + + const handleJoinEvent = async (code: string) => { + const clean = code.trim().toUpperCase(); + if (clean.length < 4) return; + setEventBusy(true); + try { + const ev = await findEventByCode(clean); + if (!ev) { + Alert.alert('Código não encontrado', `Não achei a competição "${clean}".`); + return; + } + setEvent(ev); + await handleStartLive(ev.id); + } catch (err: any) { + Alert.alert('Erro', err?.message ?? 'Não foi possível entrar na competição.'); + } finally { + setEventBusy(false); + } + }; + // Publica samples em batch + decimados pra realtime. Antes: 1 INSERT // por sample, await em loop, ~10Hz. Resultado: lag no painel da equipe // após 3-5min (3000+ samples = re-render lento + tantas inserts/seg @@ -362,14 +413,19 @@ export default function Recording() { const sectors = info.lastClosedLapSectors; lastLapCountRef.current = lapNumber; if (ms != null) { - publishLap(live.id, { - lapNumber, - durationMs: ms, - finishedAt: Date.now(), - s1Ms: sectors?.s1Ms ?? null, - s2Ms: sectors?.s2Ms ?? null, - s3Ms: sectors?.s3Ms ?? null, - }).catch(() => {}); + publishLap( + live.id, + { + lapNumber, + durationMs: ms, + finishedAt: Date.now(), + s1Ms: sectors?.s1Ms ?? null, + s2Ms: sectors?.s2Ms ?? null, + s3Ms: sectors?.s3Ms ?? null, + }, + // eventId denormalizado → ranking da competição via realtime. + live.eventId + ).catch(() => {}); } }, [live, info.lapsCompleted, info.bestLapMs, info.lastClosedLap, info.lastClosedLapSectors]); @@ -691,8 +747,12 @@ export default function Recording() { isLandscape={isLandscape} live={live} liveStarting={liveStarting} - onEnableLive={handleStartLive} + onEnableLive={() => handleStartLive()} onDisableLive={handleStopLive} + event={event} + eventBusy={eventBusy} + onCreateEvent={handleCreateEvent} + onJoinEvent={handleJoinEvent} /> ); } @@ -1115,6 +1175,10 @@ function IdleView({ liveStarting, onEnableLive, onDisableLive, + event, + eventBusy, + onCreateEvent, + onJoinEvent, }: { trackName: string; reference: TrackLayout | null; @@ -1126,6 +1190,10 @@ function IdleView({ liveStarting: boolean; onEnableLive: () => Promise | void; onDisableLive: () => Promise | void; + event: EventInfo | null; + eventBusy: boolean; + onCreateEvent: () => Promise | void; + onJoinEvent: (code: string) => Promise | void; }) { const insets = useSafeAreaInsets(); return ( @@ -1171,6 +1239,16 @@ function IdleView({ onDisable={onDisableLive} /> + {/* Competição — entrar/criar evento multi-piloto. Entrar implica + * ativar o live (a sessão tem que jorrar voltas pro ranking). + * Por isso fica logo abaixo do toggle de live. */} + + Pronto pra correr? @@ -1217,6 +1295,88 @@ function IdleView({ * O switch visual é um Pressable com 2 estados — não usa o Switch nativo * pra manter o look custom do app. */ +/** + * Painel de Competição — entrar ou criar um evento multi-piloto. Quando + * o piloto entra/cria, a live session nasce vinculada ao evento e as + * voltas dele contam pro ranking ao vivo. + * + * 3 estados: + * - fora de evento: botão "Criar competição" + campo "entrar com código" + * - ocupado: spinner + * - dentro de evento: mostra o código + link do ranking web + */ +function CompetitionPanel({ + event, + busy, + onCreate, + onJoin, +}: { + event: EventInfo | null; + busy: boolean; + onCreate: () => Promise | void; + onJoin: (code: string) => Promise | void; +}) { + const [code, setCode] = useState(''); + + if (event) { + return ( + + 🏁 NA COMPETIÇÃO + + {event.code} + + + Suas voltas contam pro ranking. Acompanhe em{'\n'} + copilot-mu-eight.vercel.app/event/{event.code} + + + ); + } + + return ( + + 🏁 COMPETIÇÃO + Corra contra outros pilotos com ranking ao vivo. + + !busy && onCreate()} + disabled={busy} + style={({ pressed }) => [s.compCreateBtn, (pressed || busy) && { opacity: 0.6 }]} + > + {busy ? ( + + ) : ( + Criar competição + )} + + + + setCode(t.toUpperCase())} + placeholder="Código" + placeholderTextColor={colors.textMuted} + autoCapitalize="characters" + maxLength={8} + editable={!busy} + /> + !busy && onJoin(code)} + disabled={busy || code.trim().length < 4} + style={({ pressed }) => [ + s.compJoinBtn, + (busy || code.trim().length < 4) && { opacity: 0.4 }, + pressed && { opacity: 0.7 }, + ]} + > + Entrar + + + + ); +} + function LiveTogglePanel({ live, liveStarting, @@ -1674,6 +1834,79 @@ const s = StyleSheet.create({ fontWeight: '800', letterSpacing: 1.2, }, + // ===== Competição (idle) ===== + compTitle: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '900', + letterSpacing: 1.5, + }, + compSub: { + color: colors.textSecondary, + fontSize: 13, + marginTop: 4, + marginBottom: spacing.m, + lineHeight: 18, + }, + compCreateBtn: { + backgroundColor: colors.accentCyan, + borderRadius: radius.m, + paddingVertical: 12, + alignItems: 'center', + }, + compCreateTxt: { + color: colors.textOnPrimary, + fontSize: 14, + fontWeight: '800', + }, + compJoinRow: { + flexDirection: 'row', + gap: spacing.s, + marginTop: spacing.s, + }, + compInput: { + flex: 1, + backgroundColor: colors.bg, + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.m, + paddingHorizontal: spacing.m, + paddingVertical: 10, + color: colors.textPrimary, + fontSize: 15, + fontWeight: '700', + letterSpacing: 2, + }, + compJoinBtn: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.accentCyan, + borderRadius: radius.m, + paddingHorizontal: spacing.l, + alignItems: 'center', + justifyContent: 'center', + }, + compJoinTxt: { + color: colors.accentCyan, + fontSize: 14, + fontWeight: '800', + }, + compCodeRow: { + marginTop: 6, + }, + compCode: { + color: colors.accentCyan, + fontSize: 28, + fontWeight: '900', + letterSpacing: 3, + }, + compHint: { + color: colors.textMuted, + fontSize: 11, + marginTop: spacing.s, + lineHeight: 16, + }, + // Placeholder enquanto não há delta (1ª volta). Mesma altura do pill // pra não deslocar o layout quando o delta aparecer. deltaPillPlaceholder: { From 6d7761ab0357d009a3859ed0f994940ce11e5729 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 13:48:30 -0300 Subject: [PATCH 12/20] =?UTF-8?q?feat(competition):=20wave=203=20=E2=80=94?= =?UTF-8?q?=20web=20/event/[code]=20com=20ranking=20ao=20vivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web-spectator/lib/liveTypes.ts: EventInfo + EventRankingRow - web-spectator/lib/useEventRanking.ts: carrega evento por código, agrega ranking (melhor volta por piloto, espelha a lógica do app), assina realtime (nova volta de qualquer piloto + piloto novo entrando) e recarrega debounced. - web-spectator/app/event/[code]/page.tsx: leaderboard ao vivo — posição, piloto, kart#, melhor volta, gap pro líder, última, nº voltas. Líder destacado. Ideal num tablet/TV no box. - web-spectator/lib/createEvent.ts + home: 3º modo "Competição" no seletor (Espectador/Equipe/Competição). Ver ranking por código OU "+ Criar nova competição" (organizador que não corre cria pela web). --- web-spectator/app/event/[code]/page.tsx | 150 ++++++++++++++++++ web-spectator/app/page.tsx | 61 ++++++-- web-spectator/lib/createEvent.ts | 43 ++++++ web-spectator/lib/liveTypes.ts | 17 +++ web-spectator/lib/useEventRanking.ts | 193 ++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 10 deletions(-) create mode 100644 web-spectator/app/event/[code]/page.tsx create mode 100644 web-spectator/lib/createEvent.ts create mode 100644 web-spectator/lib/useEventRanking.ts diff --git a/web-spectator/app/event/[code]/page.tsx b/web-spectator/app/event/[code]/page.tsx new file mode 100644 index 0000000..c070685 --- /dev/null +++ b/web-spectator/app/event/[code]/page.tsx @@ -0,0 +1,150 @@ +/** + * Ranking ao vivo de competição — /event/[code]. + * + * Mostra o leaderboard agregado de todos os pilotos do evento: posição, + * nome, kart#, melhor volta, gap pro líder, última volta, nº de voltas. + * Atualiza em tempo real conforme cada piloto fecha volta. + * + * Ideal num tablet/TV no box pra galera acompanhar. Cada piloto roda o + * app, entra no código do evento, e suas voltas aparecem aqui. + */ + +'use client'; + +import { useEventRanking } from '@/lib/useEventRanking'; +import { fmtLap } from '@/lib/format'; +import type { EventRankingRow } from '@/lib/liveTypes'; + +export default function EventPage({ params }: { params: { code: string } }) { + const state = useEventRanking(params.code); + + if (state.kind === 'loading') return
; + if (state.kind === 'not-found') { + return ( +
+ ); + } + if (state.kind === 'error') return
; + + const { event, ranking } = state; + const leaderBest = ranking.find((r) => r.bestLapMs != null)?.bestLapMs ?? null; + + return ( +
+
+
+ 🏁 +
+

+ {event.name ?? 'Competição'} +

+
+ {event.trackName ?? '—'} · CÓDIGO {event.code} · {ranking.length} piloto(s) +
+
+
+
+ +
+ {ranking.length === 0 ? ( +
+ Aguardando pilotos entrarem na competição… +
+ No app: ativar "Competição" → entrar com o código{' '} + {event.code} +
+
+ ) : ( +
+ {/* Cabeçalho da tabela */} +
+ # + PILOTO + MELHOR + GAP + VLT +
+ {ranking.map((row, i) => ( + + ))} +
+ )} +
+
+ ); +} + +function RankingRow({ + row, + position, + leaderBest, +}: { + row: EventRankingRow; + position: number; + leaderBest: number | null; +}) { + const gap = + row.bestLapMs != null && leaderBest != null && row.bestLapMs > leaderBest + ? row.bestLapMs - leaderBest + : null; + const isLeader = position === 1 && row.bestLapMs != null; + + return ( +
+ + {position} + +
+
+ {row.pilotName} + {row.kartNumber && ( + #{row.kartNumber} + )} +
+
+ última {row.lastLapMs != null ? fmtLap(row.lastLapMs) : '—'} +
+
+ + {row.bestLapMs != null ? fmtLap(row.bestLapMs) : '—'} + + + {gap != null ? `+${(gap / 1000).toFixed(3)}` : isLeader ? '—' : ''} + + + {row.lapCount} + +
+ ); +} + +function Center({ title, text }: { title?: string; text: string }) { + return ( +
+
+ {title &&

{title}

} +

{text}

+
+
+ ); +} diff --git a/web-spectator/app/page.tsx b/web-spectator/app/page.tsx index 9a316de..3433bff 100644 --- a/web-spectator/app/page.tsx +++ b/web-spectator/app/page.tsx @@ -2,27 +2,47 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { createEvent } from '@/lib/createEvent'; /** - * Tela inicial — duas modalidades: - * - Espectador: vê traçado + tempos. Read-only. Pra família, amigos, fãs. - * - Equipe (Box): painel tático com pontos a melhorar, diagnóstico, - * mensagens curtas pro piloto. Quem tá no box do piloto. + * Tela inicial — três modalidades: + * - Espectador: vê traçado + tempos. Read-only. + * - Equipe (Box): painel tático + mensagens pro piloto. + * - Competição: ranking ao vivo de evento multi-piloto (ver ou criar). */ -type Role = 'spectator' | 'team'; +type Role = 'spectator' | 'team' | 'event'; export default function HomePage() { const router = useRouter(); const [code, setCode] = useState(''); const [role, setRole] = useState('spectator'); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); const handleGo = (e: React.FormEvent) => { e.preventDefault(); const clean = code.trim().toUpperCase().replace(/[^A-Z0-9-]/g, ''); if (clean.length < 4) return; - router.push(role === 'team' ? `/team/${clean}` : `/live/${clean}`); + if (role === 'team') router.push(`/team/${clean}`); + else if (role === 'event') router.push(`/event/${clean}`); + else router.push(`/live/${clean}`); }; + const handleCreateEvent = async () => { + setCreating(true); + setCreateError(null); + const result = await createEvent({ name: 'Competição' }); + if (result.ok) { + router.push(`/event/${result.code}`); + } else { + setCreateError(result.error); + setCreating(false); + } + }; + + const ctaLabel = + role === 'team' ? 'Entrar como equipe' : role === 'event' ? 'Ver ranking' : 'Acompanhar'; + return (
@@ -33,7 +53,7 @@ export default function HomePage() {

-
+
setRole('spectator')} @@ -44,7 +64,13 @@ export default function HomePage() { active={role === 'team'} onClick={() => setRole('team')} title="Equipe" - sub="Box do piloto" + sub="Box" + /> + setRole('event')} + title="Competição" + sub="Ranking" />
@@ -53,7 +79,7 @@ export default function HomePage() { type="text" value={code} onChange={(e) => setCode(e.target.value)} - placeholder="LIVE-X9K2P" + placeholder={role === 'event' ? 'RACE-X9K2P' : 'LIVE-X9K2P'} autoComplete="off" spellCheck={false} className="w-full bg-surface border border-border rounded-xl px-4 py-4 text-center text-2xl font-mono tracking-widest uppercase placeholder:text-textMuted text-textPrimary outline-none focus:border-primary" @@ -63,10 +89,25 @@ export default function HomePage() { disabled={code.trim().length < 4} className="w-full bg-primary text-bg font-extrabold py-4 rounded-xl disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 transition" > - {role === 'team' ? 'Entrar como equipe' : 'Acompanhar'} + {ctaLabel} + {role === 'event' && ( +
+ + {createError && ( +

{createError}

+ )} +
+ )} +

Ou escaneie o QR code que aparece no app do piloto com a câmera do seu aparelho — abre essa página direto. diff --git a/web-spectator/lib/createEvent.ts b/web-spectator/lib/createEvent.ts new file mode 100644 index 0000000..6923676 --- /dev/null +++ b/web-spectator/lib/createEvent.ts @@ -0,0 +1,43 @@ +/** + * Cria um evento de competição pelo navegador (organizador que não corre). + * Gera código único e insere em `events`. Retorna o código pra compartilhar + * com os pilotos (que entram pelo app). + */ + +import { getSupabase } from './supabase'; + +const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // sem 0/O/1/I/L + +function generateCode(len = 6): string { + let out = ''; + for (let i = 0; i < len; i++) { + out += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]; + } + return out; +} + +export async function createEvent(opts: { + name: string | null; + trackName?: string | null; +}): Promise<{ ok: true; code: string } | { ok: false; error: string }> { + try { + const supabase = getSupabase(); + for (let attempt = 0; attempt < 5; attempt++) { + const code = generateCode(); + const { error } = await supabase.from('events').insert({ + code, + name: opts.name, + track_name: opts.trackName ?? null, + created_by: 'web', + }); + if (!error) return { ok: true, code }; + // 23505 = unique violation → tenta outro código + if ((error as any).code !== '23505') { + return { ok: false, error: error.message }; + } + } + return { ok: false, error: 'Não consegui gerar código único.' }; + } catch (e: any) { + return { ok: false, error: e?.message ?? 'Erro desconhecido' }; + } +} diff --git a/web-spectator/lib/liveTypes.ts b/web-spectator/lib/liveTypes.ts index a7565b1..c4b4779 100644 --- a/web-spectator/lib/liveTypes.ts +++ b/web-spectator/lib/liveTypes.ts @@ -54,3 +54,20 @@ export type LiveMessage = { ackedAt: number | null; sentBy: string | null; }; + +export type EventInfo = { + id: string; + code: string; + name: string | null; + trackId: string | null; + trackName: string | null; +}; + +export type EventRankingRow = { + pilotName: string; + kartNumber: string | null; + bestLapMs: number | null; + lastLapMs: number | null; + lapCount: number; + lastLapAt: number | null; +}; diff --git a/web-spectator/lib/useEventRanking.ts b/web-spectator/lib/useEventRanking.ts new file mode 100644 index 0000000..fec6668 --- /dev/null +++ b/web-spectator/lib/useEventRanking.ts @@ -0,0 +1,193 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { RealtimeChannel } from '@supabase/supabase-js'; +import { getSupabase } from './supabase'; +import { EventInfo, EventRankingRow } from './liveTypes'; + +/** + * Hook do ranking de competição. Carrega o evento pelo código, agrega o + * ranking (melhor volta por piloto) e assina realtime — a cada volta nova + * de QUALQUER piloto do evento, recarrega o ranking (debounced). + * + * Agregação espelha a do app (src/lib/liveSession.loadEventRanking): + * - junta live_sessions do evento + pilots + live_laps + * - por piloto: best = min duração, last = mais recente, count + * - ordena por melhor volta asc (líder primeiro), sem-volta no fim + */ + +export type EventState = + | { kind: 'loading' } + | { kind: 'not-found' } + | { kind: 'error'; message: string } + | { kind: 'ready'; event: EventInfo; ranking: EventRankingRow[] }; + +function mapEvent(row: any): EventInfo { + return { + id: row.id, + code: row.code, + name: row.name ?? null, + trackId: row.track_id ?? null, + trackName: row.track_name ?? null, + }; +} + +async function aggregateRanking( + supabase: ReturnType, + eventId: string +): Promise { + const { data: sessRows } = await supabase + .from('live_sessions') + .select('id, pilots(display_name, kart_number)') + .eq('event_id', eventId); + if (!sessRows || sessRows.length === 0) return []; + + const { data: lapRows } = await supabase + .from('live_laps') + .select('live_session_id, duration_ms, finished_at') + .eq('event_id', eventId) + .order('finished_at', { ascending: true }); + + const pilotBySession = new Map(); + for (const s of sessRows as any[]) { + pilotBySession.set(s.id, { + name: s.pilots?.display_name ?? 'Piloto', + kart: s.pilots?.kart_number ?? null, + }); + } + + const byPilot = new Map(); + for (const lap of (lapRows ?? []) as any[]) { + const pilot = pilotBySession.get(lap.live_session_id); + if (!pilot) continue; + const key = `${pilot.name}#${pilot.kart ?? ''}`; + const t = new Date(lap.finished_at).getTime(); + const existing = byPilot.get(key); + if (!existing) { + byPilot.set(key, { + pilotName: pilot.name, + kartNumber: pilot.kart, + bestLapMs: lap.duration_ms, + lastLapMs: lap.duration_ms, + lapCount: 1, + lastLapAt: t, + }); + } else { + existing.lapCount += 1; + if (lap.duration_ms < (existing.bestLapMs ?? Infinity)) { + existing.bestLapMs = lap.duration_ms; + } + existing.lastLapMs = lap.duration_ms; + existing.lastLapAt = t; + } + } + + for (const [, pilot] of pilotBySession) { + const key = `${pilot.name}#${pilot.kart ?? ''}`; + if (!byPilot.has(key)) { + byPilot.set(key, { + pilotName: pilot.name, + kartNumber: pilot.kart, + bestLapMs: null, + lastLapMs: null, + lapCount: 0, + lastLapAt: null, + }); + } + } + + return Array.from(byPilot.values()).sort((a, b) => { + if (a.bestLapMs === null && b.bestLapMs === null) return 0; + if (a.bestLapMs === null) return 1; + if (b.bestLapMs === null) return -1; + return a.bestLapMs - b.bestLapMs; + }); +} + +export function useEventRanking(code: string | null): EventState { + const [state, setState] = useState({ kind: 'loading' }); + // Debounce do reload — várias voltas podem chegar quase juntas. + const reloadTimer = useRef | null>(null); + + useEffect(() => { + if (!code) return; + let cancelled = false; + let channel: RealtimeChannel | null = null; + + (async () => { + let supabase; + try { + supabase = getSupabase(); + } catch (err: any) { + if (!cancelled) setState({ kind: 'error', message: err.message }); + return; + } + + const { data: evRow } = await supabase + .from('events') + .select('*') + .eq('code', code.toUpperCase()) + .maybeSingle(); + if (cancelled) return; + if (!evRow) { + setState({ kind: 'not-found' }); + return; + } + const event = mapEvent(evRow); + + const ranking = await aggregateRanking(supabase, event.id); + if (cancelled) return; + setState({ kind: 'ready', event, ranking }); + + // Realtime: nova volta de qualquer piloto do evento → reload debounced + const reload = () => { + if (reloadTimer.current) clearTimeout(reloadTimer.current); + reloadTimer.current = setTimeout(async () => { + const fresh = await aggregateRanking(supabase!, event.id); + if (!cancelled) { + setState((prev) => + prev.kind === 'ready' ? { ...prev, ranking: fresh } : prev + ); + } + }, 600); + }; + + channel = supabase + .channel(`event:${event.id}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'live_laps', + filter: `event_id=eq.${event.id}`, + }, + reload + ) + // Também recarrega quando um piloto novo entra (nova session no evento) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'live_sessions', + filter: `event_id=eq.${event.id}`, + }, + reload + ) + .subscribe(); + })(); + + return () => { + cancelled = true; + if (reloadTimer.current) clearTimeout(reloadTimer.current); + if (channel) { + try { + channel.unsubscribe(); + } catch {} + } + }; + }, [code]); + + return state; +} From ee7f6511b5d563f902234d82ae2a764759eae187 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 14:09:52 -0300 Subject: [PATCH 13/20] =?UTF-8?q?feat(competition):=20wave=204=20=E2=80=94?= =?UTF-8?q?=20ranking=20in-app=20+=20completa=20o=20modo=20competi=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/ranking/[code].tsx (novo): leaderboard nativo da competição. Resolve código → evento → ranking agregado, assina realtime (subscribeEventLaps) e recarrega debounced a cada volta nova. Líder destacado, gap pro líder, última volta, contagem. Pro piloto ver posição entre voltas no box. - app/recording.tsx: CompetitionPanel (estado "na competição") ganha botão "Ver ranking ao vivo" → /ranking/[code]. Modo Competição completo (waves 1-4): 1. Backend (events + APIs + ranking agregado) 2. App: criar/entrar evento na idle, voltas publicam com event_id 3. Web: /event/[code] ranking + criar competição na home 4. App: tela de ranking nativa Fluxo: organizador cria (app ou web) → pilotos entram pelo código → cada um corre → ranking ao vivo agrega melhor volta de todos, no app e na web. --- app/ranking/[code].tsx | 205 +++++++++++++++++++++++++++++++++++++++++ app/recording.tsx | 20 ++++ 2 files changed, 225 insertions(+) create mode 100644 app/ranking/[code].tsx diff --git a/app/ranking/[code].tsx b/app/ranking/[code].tsx new file mode 100644 index 0000000..1ae7fb5 --- /dev/null +++ b/app/ranking/[code].tsx @@ -0,0 +1,205 @@ +/** + * Ranking da competição in-app — o piloto vê sua posição entre voltas + * (no box, num momento de respiro). Mesma fonte de dados do web + * /event/[code], mas nativo. + * + * Resolve o código → evento → ranking agregado, e assina realtime pra + * atualizar a cada volta nova de qualquer piloto. + */ + +import { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + findEventByCode, + loadEventRanking, + subscribeEventLaps, + type EventInfo, + type EventRankingRow, +} from '../../src/lib/liveSession'; +import { colors, spacing, typography } from '../../src/theme'; + +function fmtLap(ms: number): string { + const totalS = ms / 1000; + const m = Math.floor(totalS / 60); + const s = totalS - m * 60; + return `${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`; +} + +export default function RankingScreen() { + const { code } = useLocalSearchParams<{ code: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const [event, setEvent] = useState(null); + const [ranking, setRanking] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const reloadTimer = useRef | null>(null); + + useEffect(() => { + if (!code) return; + let cancelled = false; + let unsubscribe: (() => void) | null = null; + + (async () => { + const ev = await findEventByCode(code); + if (cancelled) return; + if (!ev) { + setError(`Competição "${code}" não encontrada.`); + setLoading(false); + return; + } + setEvent(ev); + const rows = await loadEventRanking(ev.id); + if (cancelled) return; + setRanking(rows); + setLoading(false); + + // Realtime: nova volta → reload debounced + unsubscribe = subscribeEventLaps(ev.id, () => { + if (reloadTimer.current) clearTimeout(reloadTimer.current); + reloadTimer.current = setTimeout(async () => { + const fresh = await loadEventRanking(ev.id); + if (!cancelled) setRanking(fresh); + }, 600); + }); + })(); + + return () => { + cancelled = true; + if (reloadTimer.current) clearTimeout(reloadTimer.current); + unsubscribe?.(); + }; + }, [code]); + + const leaderBest = ranking.find((r) => r.bestLapMs != null)?.bestLapMs ?? null; + + return ( + + + router.back()} hitSlop={12} style={s.backBtn}> + + + + 🏁 RANKING + + {event?.code ?? code} · {ranking.length} piloto(s) + + + + + + {loading ? ( + + + Carregando ranking… + + ) : error ? ( + + {error} + + ) : ranking.length === 0 ? ( + + Aguardando pilotos fecharem voltas… + + ) : ( + `${r.pilotName}-${i}`} + contentContainerStyle={{ + paddingHorizontal: spacing.l, + paddingBottom: insets.bottom + spacing.xl, + }} + renderItem={({ item, index }) => { + const gap = + item.bestLapMs != null && leaderBest != null && item.bestLapMs > leaderBest + ? item.bestLapMs - leaderBest + : null; + const isLeader = index === 0 && item.bestLapMs != null; + return ( + + + {index + 1} + + + + {item.pilotName} + {item.kartNumber ? ` #${item.kartNumber}` : ''} + + + última {item.lastLapMs != null ? fmtLap(item.lastLapMs) : '—'} ·{' '} + {item.lapCount} vlt + + + + + {item.bestLapMs != null ? fmtLap(item.bestLapMs) : '—'} + + {gap != null && ( + + +{(gap / 1000).toFixed(3)} + + )} + + + ); + }} + /> + )} + + ); +} + +const s = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.l, + paddingBottom: spacing.m, + }, + backBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }, + backTxt: { color: colors.textPrimary, fontSize: 30, fontWeight: '300' }, + title: { color: colors.textPrimary, fontSize: 13, fontWeight: '900', letterSpacing: 1.5 }, + sub: { color: colors.textMuted, fontSize: 11, marginTop: 2, ...typography.mono }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl }, + loadingTxt: { color: colors.textSecondary, marginTop: spacing.m, fontSize: 14 }, + errorTxt: { color: colors.textSecondary, fontSize: 14, textAlign: 'center' }, + emptyTxt: { color: colors.textMuted, fontSize: 14, textAlign: 'center' }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.m, + paddingVertical: 12, + paddingHorizontal: spacing.m, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 14, + marginBottom: spacing.s, + }, + rowLeader: { + backgroundColor: colors.primary + '14', + borderColor: colors.primary + '66', + }, + pos: { + color: colors.textSecondary, + fontSize: 18, + fontWeight: '900', + width: 28, + textAlign: 'center', + }, + pilot: { color: colors.textPrimary, fontSize: 15, fontWeight: '800' }, + last: { color: colors.textMuted, fontSize: 11, marginTop: 2 }, + best: { color: colors.textPrimary, fontSize: 16, fontWeight: '900', letterSpacing: -0.3 }, + gap: { color: colors.danger, fontSize: 11, fontWeight: '700', marginTop: 2 }, +}); diff --git a/app/recording.tsx b/app/recording.tsx index de3be24..bcbbf37 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -1317,6 +1317,7 @@ function CompetitionPanel({ onJoin: (code: string) => Promise | void; }) { const [code, setCode] = useState(''); + const router = useRouter(); if (event) { return ( @@ -1329,6 +1330,12 @@ function CompetitionPanel({ Suas voltas contam pro ranking. Acompanhe em{'\n'} copilot-mu-eight.vercel.app/event/{event.code} + router.push(`/ranking/${event.code}` as any)} + style={({ pressed }) => [s.compRankBtn, pressed && { opacity: 0.7 }]} + > + Ver ranking ao vivo + ); } @@ -1906,6 +1913,19 @@ const s = StyleSheet.create({ marginTop: spacing.s, lineHeight: 16, }, + compRankBtn: { + marginTop: spacing.m, + borderWidth: 1, + borderColor: colors.accentCyan, + borderRadius: radius.m, + paddingVertical: 10, + alignItems: 'center', + }, + compRankTxt: { + color: colors.accentCyan, + fontSize: 13, + fontWeight: '800', + }, // Placeholder enquanto não há delta (1ª volta). Mesma altura do pill // pra não deslocar o layout quando o delta aparecer. From 16d1ab487a8b5c18e440c08ccc2ef0faf4ca8373 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Fri, 29 May 2026 20:34:54 -0300 Subject: [PATCH 14/20] =?UTF-8?q?feat(competition):=20posi=C3=A7=C3=A3o=20?= =?UTF-8?q?ao=20vivo=20+=20mapa=20com=20karts=20na=20pista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camada extra sobre o ranking de tempos: agora a /event/[code] mostra também a ORDEM REAL DE CORRIDA (P1/P2/P3 na pista agora) + mapa SVG com cada kart no ponto onde está. Funciona mesmo SE ninguém fez reconhecimento — o 1º piloto a fechar volta no evento define a referência (auto), todos os karts são projetados nela daí pra frente. == Schema (supabase/schema.sql) == - events ganha reference_samples_json + reference_duration_ms + reference_set_at (referência geográfica fixada na 1ª volta). - live_samples ganha event_id (denormalizado pra realtime filtrado por evento — pega samples de todos os pilotos num canal só). - Índice idx_live_samples_event. == App == - src/lib/liveSession.ts: setEventReferenceIfEmpty (UPDATE atômico com WHERE reference_set_at IS NULL — só o 1º piloto ganha; demais no-op). publishSample agora aceita eventId. - app/recording.tsx: publishSample manda live.eventId. Após publishLap no evento, tenta gravar essa volta como referência (decimada a ~500 pontos pra ~15KB JSON, fire-and-forget). == Web == - web-spectator/lib/trackProgress.ts (novo): compileReference (projeção ENU local + cumulativa + bbox) + projectProgress (projeção do GPS na polyline, retorna progresso 0..1). - web-spectator/lib/useEventRanking.ts: carrega reference do evento, subscreve live_samples WHERE event_id, mantém mapa latestBySession, computa positions ordenadas por (lapNumber desc, progress desc). Throttle de 250ms nas atualizações de posição (samples a ~3.3Hz×N pilotos = barulho). - web-spectator/app/event/[code]/page.tsx: nova seção lado-a-lado com TrackMap (SVG da pista + kart por piloto colorido) e LivePositionList (ordem na pista + nº volta + progresso %). Limitações honestas: - Precisão do GPS ~3-5m: lado a lado por centímetros não distingue. - Posição aparece só DEPOIS da 1ª volta fechar (define a referência). - Sem referência: ordena só por nº de voltas (karts na mesma volta ficam empatados). --- app/recording.tsx | 21 ++- src/lib/liveSession.ts | 49 ++++++- supabase/schema.sql | 10 ++ web-spectator/app/event/[code]/page.tsx | 162 +++++++++++++++++++++++- web-spectator/lib/trackProgress.ts | 109 ++++++++++++++++ web-spectator/lib/useEventRanking.ts | 159 ++++++++++++++++++++++- 6 files changed, 498 insertions(+), 12 deletions(-) create mode 100644 web-spectator/lib/trackProgress.ts diff --git a/app/recording.tsx b/app/recording.tsx index bcbbf37..ba841d9 100644 --- a/app/recording.tsx +++ b/app/recording.tsx @@ -50,6 +50,7 @@ import { endLiveSession, createEvent, findEventByCode, + setEventReferenceIfEmpty, EventInfo, LiveMessage, LiveSessionInfo, @@ -380,7 +381,9 @@ export default function Recording() { s3Ms: info.currentSectors.s3Ms, altitude: s.altitude ?? null, altitudeAccuracy: s.altitudeAccuracy ?? null, - }).catch(() => { + }, + // event_id denormalizado pra realtime filtrado por evento na web + live.eventId).catch(() => { /* engole — não pode quebrar gravação se realtime falhar */ }); } @@ -427,7 +430,21 @@ export default function Recording() { live.eventId ).catch(() => {}); } - }, [live, info.lapsCompleted, info.bestLapMs, info.lastClosedLap, info.lastClosedLapSectors]); + + // Se está num evento, tenta gravar essa volta como REFERÊNCIA do + // evento. Update atômico (WHERE reference_set_at IS NULL) — só o + // PRIMEIRO piloto que fechar volta ganha; demais são no-op. A partir + // daí todos os karts são projetados nessa polyline pra posição ao + // vivo no ranking da web. + if (live.eventId && liveSamples.length > 10 && ms != null) { + const refSamples = liveSamples.map((s) => ({ + lat: s.lat, + lng: s.lng, + t: s.t, + })); + setEventReferenceIfEmpty(live.eventId, refSamples, ms).catch(() => {}); + } + }, [live, info.lapsCompleted, info.bestLapMs, info.lastClosedLap, info.lastClosedLapSectors, liveSamples]); // Assina canal realtime da live session pra receber mensagens da equipe. // Roda só enquanto `live` está ativa — desmonta + remonta se ativar/desativar. diff --git a/src/lib/liveSession.ts b/src/lib/liveSession.ts index 857b889..493da68 100644 --- a/src/lib/liveSession.ts +++ b/src/lib/liveSession.ts @@ -239,6 +239,48 @@ export async function findEventByCode(code: string): Promise { return data ? mapEventRow(data) : null; } +/** + * Tenta gravar uma volta como REFERÊNCIA geográfica do evento. Update + * atômico — só grava se reference_set_at AINDA é null (o primeiro + * piloto que fechar uma volta no evento ganha). Demais updates viram + * no-op (não sobrescrevem). + * + * A partir daí, todos os karts são projetados nessa polyline pra + * posição ao vivo (progresso na volta) tanto no app quanto na web. + * + * Retorna true se ESSE piloto fixou a referência (foi o primeiro), + * false se já existia uma. + */ +export async function setEventReferenceIfEmpty( + eventId: string, + samples: Array<{ lat: number; lng: number; t: number }>, + durationMs: number +): Promise { + const supabase = getSupabase(); + if (!supabase) return false; + if (samples.length < 10) return false; // muito pouco — não vira referência + try { + // Decimação leve antes de gravar (cap em ~500 pontos = ~15KB JSON) + const step = Math.max(1, Math.floor(samples.length / 500)); + const decimated = samples.filter((_, i) => i % step === 0); + const { data, error } = await supabase + .from('events') + .update({ + reference_samples_json: JSON.stringify(decimated), + reference_duration_ms: durationMs, + reference_set_at: new Date().toISOString(), + }) + .eq('id', eventId) + .is('reference_set_at', null) + .select('id'); + if (error) return false; + // data não-vazio = update aconteceu = fui o primeiro + return Array.isArray(data) && data.length > 0; + } catch { + return false; + } +} + /** * Carrega o ranking agregado do evento. Junta todas as live_sessions do * evento + suas voltas + nome do piloto, e agrega por piloto: @@ -361,11 +403,16 @@ export async function endLiveSession(code: string): Promise { .eq('code', code); } -export async function publishSample(sessionId: string, sample: LiveSample): Promise { +export async function publishSample( + sessionId: string, + sample: LiveSample, + eventId?: string | null +): Promise { const supabase = getSupabase(); if (!supabase) return; const { error } = await supabase.from('live_samples').insert({ live_session_id: sessionId, + event_id: eventId ?? null, t: new Date(sample.t).toISOString(), lat: sample.lat, lng: sample.lng, diff --git a/supabase/schema.sql b/supabase/schema.sql index 1a3ef4d..15963a6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -123,9 +123,19 @@ create index if not exists idx_events_code on events(code); alter table live_sessions add column if not exists event_id uuid references events(id) on delete set null; alter table live_laps add column if not exists event_id uuid; +alter table live_samples add column if not exists event_id uuid; + +-- Referência geográfica do evento — primeiro piloto que fechar uma volta +-- envia a dele aqui, e todos os karts passam a ser projetados nessa polyline +-- pra cálculo de posição ao vivo (progresso na volta). Update atômico: +-- WHERE reference_set_at IS NULL → só o primeiro ganha; demais ficam no-op. +alter table events add column if not exists reference_samples_json text; +alter table events add column if not exists reference_duration_ms int; +alter table events add column if not exists reference_set_at timestamptz; create index if not exists idx_live_laps_event on live_laps(event_id, finished_at); create index if not exists idx_live_sessions_event on live_sessions(event_id); +create index if not exists idx_live_samples_event on live_samples(event_id, t desc); -- ===================== -- Mensagens da equipe pro piloto (Team→Pilot) diff --git a/web-spectator/app/event/[code]/page.tsx b/web-spectator/app/event/[code]/page.tsx index c070685..926eddc 100644 --- a/web-spectator/app/event/[code]/page.tsx +++ b/web-spectator/app/event/[code]/page.tsx @@ -11,9 +11,11 @@ 'use client'; -import { useEventRanking } from '@/lib/useEventRanking'; +import { useMemo } from 'react'; +import { useEventRanking, type LivePosition } from '@/lib/useEventRanking'; import { fmtLap } from '@/lib/format'; import type { EventRankingRow } from '@/lib/liveTypes'; +import { projectProgress, type CompiledReference } from '@/lib/trackProgress'; export default function EventPage({ params }: { params: { code: string } }) { const state = useEventRanking(params.code); @@ -29,7 +31,7 @@ export default function EventPage({ params }: { params: { code: string } }) { } if (state.kind === 'error') return

; - const { event, ranking } = state; + const { event, ranking, reference, positions } = state; const leaderBest = ranking.find((r) => r.bestLapMs != null)?.bestLapMs ?? null; return ( @@ -49,6 +51,17 @@ export default function EventPage({ params }: { params: { code: string } }) {
+ {/* Mapa ao vivo + posição de corrida — só aparece quando algum + * piloto já fechou volta (define a referência do evento). */} + {reference && positions.length > 0 && ( +
+
+ + +
+
+ )} + {ranking.length === 0 ? (
Aguardando pilotos entrarem na competição… @@ -138,6 +151,151 @@ function RankingRow({ ); } +/** + * Mapa SVG da pista com cada kart no ponto onde ele está agora. + * - Polyline da referência do evento + * - Bolinha colorida por piloto na lat/lng atual + * - Lado a lado com a lista de posição ao vivo + */ +function TrackMap({ + reference, + positions, +}: { + reference: CompiledReference; + positions: LivePosition[]; +}) { + const W = 480; + const H = 360; + const PAD = 20; + const { bbox } = reference; + const dx = Math.max(1, bbox.maxX - bbox.minX); + const dy = Math.max(1, bbox.maxY - bbox.minY); + const scale = Math.min((W - PAD * 2) / dx, (H - PAD * 2) / dy); + const offX = (W - dx * scale) / 2 - bbox.minX * scale; + const offY = (H - dy * scale) / 2 - bbox.minY * scale; + // Y do SVG cresce pra baixo; queremos norte pra cima → inverte + const tx = (x: number) => offX + x * scale; + const ty = (y: number) => H - (offY + y * scale); + + const path = useMemo(() => { + return reference.points.reduce( + (acc, p, i) => acc + (i === 0 ? `M ${tx(p.x).toFixed(1)} ${ty(p.y).toFixed(1)}` : ` L ${tx(p.x).toFixed(1)} ${ty(p.y).toFixed(1)}`), + '' + ); + }, [reference]); + + // Projeta cada piloto pra coords da tela + const karts = positions.map((p, i) => { + const proj = projectProgress({ lat: p.lat, lng: p.lng }, reference); + return { + ...p, + sx: tx(proj.x), + sy: ty(proj.y), + color: kartColor(i), + label: p.kartNumber ?? p.pilotName.slice(0, 2).toUpperCase(), + }; + }); + + return ( +
+
+ POSIÇÃO AO VIVO +
+ + {/* Pista */} + + + {/* Karts */} + {karts.map((k) => ( + + + + + {k.label} + + + ))} + +
+ ); +} + +function LivePositionList({ positions, hasRef }: { positions: LivePosition[]; hasRef: boolean }) { + return ( +
+
+ ORDEM NA PISTA +
+
+ {positions.map((p, i) => ( +
+
+ {i + 1} +
+
+
{p.pilotName}
+
+ volta {p.lapNumber + 1} + {hasRef && p.progress != null && ` · ${Math.round(p.progress * 100)}%`} +
+
+ {p.kartNumber && ( + + #{p.kartNumber} + + )} +
+ ))} +
+
+ ); +} + +/** Paleta estável de cores por índice — kart 1 sempre amarelo, kart 2 azul, etc. */ +function kartColor(idx: number): string { + const palette = ['#FFEB3B', '#00BCD4', '#FF4757', '#9D5BFF', '#00FF88', '#FF9800', '#E91E63', '#3DDCFF']; + return palette[idx % palette.length]; +} + function Center({ title, text }: { title?: string; text: string }) { return (
diff --git a/web-spectator/lib/trackProgress.ts b/web-spectator/lib/trackProgress.ts new file mode 100644 index 0000000..1928798 --- /dev/null +++ b/web-spectator/lib/trackProgress.ts @@ -0,0 +1,109 @@ +/** + * Cálculo de "progresso ao longo da pista" pra posição ao vivo do evento. + * + * Dada uma referência (polyline) e um ponto GPS, projeta o ponto na + * polyline (segmento mais próximo) e retorna o progresso normalizado 0..1. + * + * Combinado com lap_number, ordena os karts: P1 = mais voltas + mais + * progresso na volta atual. + * + * Cálculos em ENU local (metros) — projeção esférica desnecessária pra + * pistas <10km. + */ + +export type RefPoint = { lat: number; lng: number }; + +export type CompiledReference = { + /** Pontos em metros locais (ENU), origem = ref[0]. */ + points: Array<{ x: number; y: number }>; + /** Distância cumulativa em metros até cada ponto. cum[0] = 0. */ + cum: number[]; + /** Comprimento total da polyline (m). */ + totalLength: number; + /** Origem usada na projeção local. */ + origin: RefPoint; + /** Bbox em metros locais (pra normalização do mapa). */ + bbox: { minX: number; maxX: number; minY: number; maxY: number }; +}; + +const DEG = Math.PI / 180; + +function makeProjector(origin: RefPoint) { + const lat0 = origin.lat * DEG; + const mPerDegLat = 111132.92 - 559.82 * Math.cos(2 * lat0) + 1.175 * Math.cos(4 * lat0); + const mPerDegLng = 111412.84 * Math.cos(lat0) - 93.5 * Math.cos(3 * lat0); + return (p: RefPoint) => ({ + x: (p.lng - origin.lng) * mPerDegLng, + y: (p.lat - origin.lat) * mPerDegLat, + }); +} + +/** Pré-processa a referência: projeção + cumulativa + bbox. */ +export function compileReference(samples: RefPoint[]): CompiledReference | null { + if (samples.length < 2) return null; + const origin = samples[0]; + const project = makeProjector(origin); + const points = samples.map(project); + const cum: number[] = [0]; + let minX = points[0].x, maxX = points[0].x, minY = points[0].y, maxY = points[0].y; + for (let i = 1; i < points.length; i++) { + const dx = points[i].x - points[i - 1].x; + const dy = points[i].y - points[i - 1].y; + cum.push(cum[i - 1] + Math.hypot(dx, dy)); + if (points[i].x < minX) minX = points[i].x; + if (points[i].x > maxX) maxX = points[i].x; + if (points[i].y < minY) minY = points[i].y; + if (points[i].y > maxY) maxY = points[i].y; + } + return { + points, + cum, + totalLength: cum[cum.length - 1], + origin, + bbox: { minX, maxX, minY, maxY }, + }; +} + +/** + * Projeta um ponto GPS na polyline e retorna progresso normalizado (0..1). + * Algoritmo: testa todos os segmentos (N pequeno, ~500), encontra o mais + * próximo, calcula a posição relativa no segmento, soma cumulativa. + */ +export function projectProgress(p: RefPoint, ref: CompiledReference): { + progress: number; + x: number; + y: number; + matchDistance: number; +} { + const proj = makeProjector(ref.origin); + const xy = proj(p); + let best = { segIdx: 0, t: 0, dist: Infinity }; + for (let i = 0; i < ref.points.length - 1; i++) { + const a = ref.points[i]; + const b = ref.points[i + 1]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const segLen2 = dx * dx + dy * dy; + if (segLen2 < 1) continue; + // t = quão longe no segmento [0..1] + let t = ((xy.x - a.x) * dx + (xy.y - a.y) * dy) / segLen2; + t = Math.max(0, Math.min(1, t)); + const px = a.x + t * dx; + const py = a.y + t * dy; + const ddx = xy.x - px; + const ddy = xy.y - py; + const d2 = ddx * ddx + ddy * ddy; + if (d2 < best.dist) { + best = { segIdx: i, t, dist: d2 }; + } + } + const segStart = ref.cum[best.segIdx]; + const segEnd = ref.cum[best.segIdx + 1]; + const distAlong = segStart + best.t * (segEnd - segStart); + return { + progress: ref.totalLength > 0 ? distAlong / ref.totalLength : 0, + x: xy.x, + y: xy.y, + matchDistance: Math.sqrt(best.dist), + }; +} diff --git a/web-spectator/lib/useEventRanking.ts b/web-spectator/lib/useEventRanking.ts index fec6668..bbcbd6f 100644 --- a/web-spectator/lib/useEventRanking.ts +++ b/web-spectator/lib/useEventRanking.ts @@ -4,6 +4,22 @@ import { useEffect, useRef, useState } from 'react'; import { RealtimeChannel } from '@supabase/supabase-js'; import { getSupabase } from './supabase'; import { EventInfo, EventRankingRow } from './liveTypes'; +import { CompiledReference, compileReference, projectProgress } from './trackProgress'; + +/** Posição de corrida ao vivo de um piloto. */ +export type LivePosition = { + pilotName: string; + kartNumber: string | null; + sessionId: string; + lapNumber: number; + /** Latitude/longitude da última posição reportada. */ + lat: number; + lng: number; + /** Progresso 0..1 na volta atual (precisa de referência). null se sem ref. */ + progress: number | null; + /** Timestamp do último sample. */ + lastT: number; +}; /** * Hook do ranking de competição. Carrega o evento pelo código, agrega o @@ -20,7 +36,15 @@ export type EventState = | { kind: 'loading' } | { kind: 'not-found' } | { kind: 'error'; message: string } - | { kind: 'ready'; event: EventInfo; ranking: EventRankingRow[] }; + | { + kind: 'ready'; + event: EventInfo; + ranking: EventRankingRow[]; + /** Polyline da referência do evento (origem em primeira posição). null se ninguém fechou volta ainda. */ + reference: CompiledReference | null; + /** Posições ao vivo, ordenadas P1 → último. */ + positions: LivePosition[]; + }; function mapEvent(row: any): EventInfo { return { @@ -137,10 +161,95 @@ export function useEventRanking(code: string | null): EventState { const ranking = await aggregateRanking(supabase, event.id); if (cancelled) return; - setState({ kind: 'ready', event, ranking }); - // Realtime: nova volta de qualquer piloto do evento → reload debounced - const reload = () => { + // Compila a referência do evento, se já tiver sido fixada por + // algum piloto (1º que fechou volta). Polyline pra projeção + + // visualização do mapa. + let reference: CompiledReference | null = null; + try { + const refRaw = evRow.reference_samples_json; + if (refRaw) { + const parsed = JSON.parse(refRaw); + if (Array.isArray(parsed) && parsed.length >= 2) { + reference = compileReference(parsed); + } + } + } catch { + /* ignora ref corrompida */ + } + + setState({ kind: 'ready', event, ranking, reference, positions: [] }); + + // Mapa interno: latest sample por sessão (key: live_session_id) + const latestBySession = new Map(); + // Mapa: sessionId → piloto + const pilotBySession = new Map(); + const { data: sessForPositions } = await supabase + .from('live_sessions') + .select('id, pilots(display_name, kart_number)') + .eq('event_id', event.id); + for (const s of (sessForPositions ?? []) as any[]) { + pilotBySession.set(s.id, { + name: s.pilots?.display_name ?? 'Piloto', + kart: s.pilots?.kart_number ?? null, + }); + } + + // Carrega o último sample de cada sessão como bootstrap das posições + const { data: lastSamples } = await supabase + .from('live_samples') + .select('live_session_id, lat, lng, t, lap_number') + .eq('event_id', event.id) + .order('t', { ascending: false }) + .limit(200); // mais que suficiente pra pegar 1 por piloto recente + for (const sm of (lastSamples ?? []) as any[]) { + if (!latestBySession.has(sm.live_session_id)) { + latestBySession.set(sm.live_session_id, { + lat: sm.lat, + lng: sm.lng, + t: new Date(sm.t).getTime(), + lap: sm.lap_number ?? 0, + }); + } + } + + const buildPositions = (): LivePosition[] => { + const out: LivePosition[] = []; + for (const [sessionId, sm] of latestBySession) { + const pilot = pilotBySession.get(sessionId); + if (!pilot) continue; + let progress: number | null = null; + if (reference) { + progress = projectProgress({ lat: sm.lat, lng: sm.lng }, reference).progress; + } + out.push({ + pilotName: pilot.name, + kartNumber: pilot.kart, + sessionId, + lapNumber: sm.lap, + lat: sm.lat, + lng: sm.lng, + progress, + lastT: sm.t, + }); + } + // Posição = ordena por (voltas desc, progresso desc). Sem ref, + // só desempata por voltas; karts na mesma volta ficam empatados. + return out.sort((a, b) => { + if (a.lapNumber !== b.lapNumber) return b.lapNumber - a.lapNumber; + if (a.progress === null && b.progress === null) return 0; + if (a.progress === null) return 1; + if (b.progress === null) return -1; + return b.progress - a.progress; + }); + }; + + setState((prev) => + prev.kind === 'ready' ? { ...prev, positions: buildPositions() } : prev + ); + + // Debounce do reload do ranking — várias voltas podem chegar quase juntas. + const reloadRanking = () => { if (reloadTimer.current) clearTimeout(reloadTimer.current); reloadTimer.current = setTimeout(async () => { const fresh = await aggregateRanking(supabase!, event.id); @@ -152,8 +261,23 @@ export function useEventRanking(code: string | null): EventState { }, 600); }; + // Throttle das atualizações de posição (samples chegam a 3.3Hz × N pilotos) + let positionUpdateTimer: ReturnType | null = null; + const schedulePositionUpdate = () => { + if (positionUpdateTimer) return; + positionUpdateTimer = setTimeout(() => { + positionUpdateTimer = null; + if (!cancelled) { + setState((prev) => + prev.kind === 'ready' ? { ...prev, positions: buildPositions() } : prev + ); + } + }, 250); + }; + channel = supabase .channel(`event:${event.id}`) + // Nova volta → recarrega ranking + atualiza positions .on( 'postgres_changes', { @@ -162,9 +286,9 @@ export function useEventRanking(code: string | null): EventState { table: 'live_laps', filter: `event_id=eq.${event.id}`, }, - reload + reloadRanking ) - // Também recarrega quando um piloto novo entra (nova session no evento) + // Piloto novo entrando no evento (session ganha event_id) .on( 'postgres_changes', { @@ -173,7 +297,28 @@ export function useEventRanking(code: string | null): EventState { table: 'live_sessions', filter: `event_id=eq.${event.id}`, }, - reload + reloadRanking + ) + // Cada sample atualiza posição do kart (throttled) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'live_samples', + filter: `event_id=eq.${event.id}`, + }, + (payload) => { + const row = payload.new as any; + if (!row || !row.live_session_id) return; + latestBySession.set(row.live_session_id, { + lat: row.lat, + lng: row.lng, + t: new Date(row.t).getTime(), + lap: row.lap_number ?? 0, + }); + schedulePositionUpdate(); + } ) .subscribe(); })(); From a10457b48601708634779db5870445af4e7c23b6 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Tue, 2 Jun 2026 13:43:03 -0300 Subject: [PATCH 15/20] chore: renomeia app de Copilot pra Cockpit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mudança só visível ao usuário. Identificadores internos (bundleId, slug EAS, domínio Vercel) ficam estáveis pra não quebrar instalações existentes nem o pipeline de build. - app.config.js: name 'Copilot' → 'Cockpit'; permissões iOS de "O KartLap" pra "O Cockpit" (NSLocationWhenInUse, NSLocationAlwaysAndWhenInUse, NSMotionUsage + expo-location). - src/hooks/useLapRecorder.ts: foregroundService.notificationTitle 'Copilot gravando' → 'Cockpit gravando'. - src/components/celebrations/PbUnlocked.tsx: mensagem de share PB. - web-spectator/app/layout.tsx: Cockpit Live. - web-spectator/app/page.tsx:

Cockpit Live

. Comentários doc /** */ e tags de console.warn mantidos (não visíveis ao usuário). Bundle ID com.cortextech.copilot mantido — quem já tem APK instalado continua funcionando. --- app.config.js | 10 +++++----- src/components/celebrations/PbUnlocked.tsx | 2 +- src/hooks/useLapRecorder.ts | 2 +- web-spectator/app/layout.tsx | 2 +- web-spectator/app/page.tsx | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app.config.js b/app.config.js index 5f93ea6..3f95290 100644 --- a/app.config.js +++ b/app.config.js @@ -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', @@ -35,11 +35,11 @@ module.exports = { infoPlist: { 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.', @@ -76,7 +76,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, diff --git a/src/components/celebrations/PbUnlocked.tsx b/src/components/celebrations/PbUnlocked.tsx index 2289db2..572eb4e 100644 --- a/src/components/celebrations/PbUnlocked.tsx +++ b/src/components/celebrations/PbUnlocked.tsx @@ -161,7 +161,7 @@ export function PbUnlocked({ onPress={async () => { try { await Share.share({ - message: `Acabei de bater ${fmtLap(durationMs)} no Copilot! 🏁`, + message: `Acabei de bater ${fmtLap(durationMs)} no Cockpit! 🏁`, }); } catch { /* silencioso */ diff --git a/src/hooks/useLapRecorder.ts b/src/hooks/useLapRecorder.ts index ffa6229..e41e246 100644 --- a/src/hooks/useLapRecorder.ts +++ b/src/hooks/useLapRecorder.ts @@ -408,7 +408,7 @@ export function useLapRecorder(options?: LapRecorderOptions) { distanceInterval: 0, showsBackgroundLocationIndicator: true, foregroundService: { - notificationTitle: 'Copilot gravando', + notificationTitle: 'Cockpit gravando', notificationBody: 'Gravando trajetória da pista', notificationColor: '#00ff88', }, diff --git a/web-spectator/app/layout.tsx b/web-spectator/app/layout.tsx index 0e6990e..62c787d 100644 --- a/web-spectator/app/layout.tsx +++ b/web-spectator/app/layout.tsx @@ -2,7 +2,7 @@ import './globals.css'; import type { Metadata, Viewport } from 'next'; export const metadata: Metadata = { - title: 'Copilot Live', + title: 'Cockpit Live', description: 'Acompanhe o piloto em tempo real.', }; diff --git a/web-spectator/app/page.tsx b/web-spectator/app/page.tsx index 3433bff..cc25ad0 100644 --- a/web-spectator/app/page.tsx +++ b/web-spectator/app/page.tsx @@ -47,7 +47,7 @@ export default function HomePage() {
-

Copilot Live

+

Cockpit Live

Acompanhe um piloto em tempo real. Cole o código que o app dele te passou.

From 716d3be1d6f4bff2e142d264d321bf6f45369f13 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Sun, 7 Jun 2026 14:14:41 -0300 Subject: [PATCH 16/20] fix(new-session): tela ficava carregando pra sempre se custom_tracks falhasse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sintoma: usuário tocava "Nova sessão" → tela ficava no spinner pra sempre. Causa: load() usava Promise.all sem catch. Se a query listCustomTracks (nova, da migration v4) lançasse erro — por tabela não existir ainda em algum device (migration falhada) ou qualquer outro motivo —, o await explodia e setLoading(false) nunca rodava. Fix em duas camadas: - src/storage/db.ts: listCustomTracks agora retorna [] em vez de lançar quando a tabela não existe ou a query falha. Pista custom só some da UI; resto da app continua. - app/new-session.tsx: load() com try/finally — setLoading(false) sempre dispara mesmo se tudo falhar. Cada fonte do Promise.all tem .catch individual com fallback (Map vazio, null, []) — falha de uma não derruba as outras. --- app/new-session.tsx | 30 ++++++++++++++++++------------ src/storage/db.ts | 40 +++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/app/new-session.tsx b/app/new-session.tsx index 6c06ceb..3d2c81b 100644 --- a/app/new-session.tsx +++ b/app/new-session.tsx @@ -73,18 +73,24 @@ export default function NewSession() { const [loading, setLoading] = useState(true); const load = useCallback(async () => { - const [grouped, profile, customTracks] = await Promise.all([ - listAllLayoutsGrouped(), - getProfile(), - listCustomTracks(), - ]); - // Re-hidrata cache de custom tracks toda vez que entra na tela — pega - // pistas criadas em outras sessões/devices e a recém-criada ao voltar - // do new-track. - setCustomTracksCache(customTracks); - setLayoutsByTrack(grouped); - setHomeTrackId(profile?.homeTrackId ?? null); - setLoading(false); + // Cada fonte independente: se uma falhar (ex: custom_tracks table ainda + // não existe na migration), as outras carregam normal. Antes era um + // Promise.all sem catch — um erro deixava a tela em "carregando" pra + // sempre. + try { + const [grouped, profile, customTracks] = await Promise.all([ + listAllLayoutsGrouped().catch(() => new Map()), + getProfile().catch(() => null), + listCustomTracks().catch(() => []), + ]); + setCustomTracksCache(customTracks); + setLayoutsByTrack(grouped); + setHomeTrackId(profile?.homeTrackId ?? null); + } finally { + // Sempre sai do loading, mesmo se tudo falhar — usuário vê lista + // (possivelmente sem custom tracks) em vez de spinner eterno. + setLoading(false); + } }, []); useFocusEffect(useCallback(() => { load(); }, [load])); diff --git a/src/storage/db.ts b/src/storage/db.ts index 330f9d0..8a44cdf 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -371,24 +371,30 @@ export type NewCustomTrack = { lengthM?: number | null; }; -/** Lista pistas custom criadas pelo usuário (ordem: mais recente primeiro). */ +/** Lista pistas custom criadas pelo usuário (ordem: mais recente primeiro). + * Resiliente: retorna [] se a tabela não existe (migration ainda não + * rodou em algum device antigo) ou se a query falha por qualquer motivo. */ export async function listCustomTracks(): Promise { - const d = await db(); - const rows = await d.getAllAsync( - `SELECT id, name, short_name, city, state, lat, lng, length_m - FROM custom_tracks - ORDER BY created_at DESC` - ); - return rows.map((r) => ({ - id: r.id, - name: r.name, - shortName: r.short_name, - city: r.city ?? '', - state: r.state ?? '', - lat: r.lat, - lng: r.lng, - lengthM: r.length_m ?? 0, - })); + try { + const d = await db(); + const rows = await d.getAllAsync( + `SELECT id, name, short_name, city, state, lat, lng, length_m + FROM custom_tracks + ORDER BY created_at DESC` + ); + return rows.map((r) => ({ + id: r.id, + name: r.name, + shortName: r.short_name, + city: r.city ?? '', + state: r.state ?? '', + lat: r.lat, + lng: r.lng, + lengthM: r.length_m ?? 0, + })); + } catch { + return []; + } } /** Cria uma pista custom. Retorna o TrackRef pronto pra usar. */ From 8edd6e6554c6cce51c76663b32d40c02fc2ec05a Mon Sep 17 00:00:00 2001 From: cardos0s Date: Wed, 10 Jun 2026 10:21:29 -0300 Subject: [PATCH 17/20] fix(session): tela de resultados ficava carregando pra sempre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sintoma: usuário tocava em uma sessão na lista pra ver os resultados → tela ficava em spinner pra sempre. Causa: mesmo padrão da new-session — o useEffect fazia vários await encadeados (getSession, getLapsForSession, getLayout, getDefaultLayoutForTrack) sem try/catch. Qualquer um lançando deixava setLoading(false) na linha 250 nunca rodar. Cenários que disparam o bug: - Sessão antiga apontando pra layoutId/trackId que não existe mais. - DB com row corrompida (campo JSON inválido). - Qualquer falha transitória do SQLite. Fix: try/finally em volta do bloco inteiro garante setLoading(false), e cada await individual tem .catch com fallback (null ou []) — uma fonte falhar não derruba as outras, e a tela renderiza o estado vazio em vez de travar. --- app/session/[id].tsx | 94 +++++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 7de2625..026496f 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -201,53 +201,59 @@ function SessionScreenInner() { (async () => { if (!id) return; setLoading(true); - const ses = await getSession(id); - const lapsRaw = await getLapsForSession(id); - - // Prioriza o layout explicitamente gravado na sessão; se nada vier - // (sessões antigas pré-layout, ou sem trackId), cai pro default da pista. - let ref: TrackLayout | null = null; - if (ses?.layoutId) { - ref = await getLayout(ses.layoutId); - } - if (!ref && ses?.trackId) { - ref = await getDefaultLayoutForTrack(ses.trackId); - } - - // Pipeline de cada volta: limpa por accuracy, depois detecta e repara - // timestamps degenerados. O reparo usa durationMs/startedAt salvos no - // banco (que vieram do lapDetector quando os t ainda eram válidos) pra - // sintetizar tempos linearmente espaçados. Sem isso, voltas antigas - // gravadas com loc.timestamp=0 mostravam "Confiança baixa 20/20" e - // todos os setores zerados. - let anyRepaired = false; - const cleanedLaps = lapsRaw.map((l) => { - const cleaned = cleanSamples(l.samples, 10); - const { samples: repairedSamples, repaired } = repairDegenerateTimestamps( - cleaned, - l.durationMs, - l.startedAt, - ); - if (repaired) anyRepaired = true; - return { ...l, samples: repairedSamples }; - }); + // try/finally garante que setLoading(false) sempre dispara, mesmo se + // qualquer await abaixo lançar. Sem isso a tela ficava em "carregando" + // pra sempre quando uma sessão antiga tinha layout/track inválido. + try { + const ses = await getSession(id).catch(() => null); + const lapsRaw = await getLapsForSession(id).catch(() => []); + + // Prioriza o layout explicitamente gravado na sessão; se nada vier + // (sessões antigas pré-layout, ou sem trackId), cai pro default da pista. + let ref: TrackLayout | null = null; + if (ses?.layoutId) { + ref = await getLayout(ses.layoutId).catch(() => null); + } + if (!ref && ses?.trackId) { + ref = await getDefaultLayoutForTrack(ses.trackId).catch(() => null); + } - if (ref && ref.samples.length >= 2) { - const { samples: repairedRefSamples, repaired } = repairDegenerateTimestamps( - ref.samples, - ref.durationMs, - ); - if (repaired) { - anyRepaired = true; - ref = { ...ref, samples: repairedRefSamples }; + // Pipeline de cada volta: limpa por accuracy, depois detecta e repara + // timestamps degenerados. O reparo usa durationMs/startedAt salvos no + // banco (que vieram do lapDetector quando os t ainda eram válidos) pra + // sintetizar tempos linearmente espaçados. Sem isso, voltas antigas + // gravadas com loc.timestamp=0 mostravam "Confiança baixa 20/20" e + // todos os setores zerados. + let anyRepaired = false; + const cleanedLaps = lapsRaw.map((l) => { + const cleaned = cleanSamples(l.samples, 10); + const { samples: repairedSamples, repaired } = repairDegenerateTimestamps( + cleaned, + l.durationMs, + l.startedAt, + ); + if (repaired) anyRepaired = true; + return { ...l, samples: repairedSamples }; + }); + + if (ref && ref.samples.length >= 2) { + const { samples: repairedRefSamples, repaired } = repairDegenerateTimestamps( + ref.samples, + ref.durationMs, + ); + if (repaired) { + anyRepaired = true; + ref = { ...ref, samples: repairedRefSamples }; + } } - } - setSession(ses); - setLaps(cleanedLaps); - setReference(ref); - setApproxTimestamps(anyRepaired); - setLoading(false); + setSession(ses); + setLaps(cleanedLaps); + setReference(ref); + setApproxTimestamps(anyRepaired); + } finally { + setLoading(false); + } })(); }, [id]); From 1917cd546d5017ef6b62a82ace71dc905a8659b8 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Wed, 10 Jun 2026 10:58:20 -0300 Subject: [PATCH 18/20] fix: 5 telas adicionais ficavam carregando pra sempre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesmo bug-pattern de new-session/session: useEffect com setLoading(true) + awaits sem try/finally. Qualquer await rejeitando deixava o spinner eternamente. Telas afetadas: - app/leaderboard.tsx: fetchLeaderboard sem catch. - app/challenges.tsx: refreshTodayChallenges + Promise.all sem catch. - app/career.tsx: Promise.all sem catch (3 fontes). - app/profile-edit.tsx: getProfile().then sem .catch. - app/(tabs)/insights.tsx: computeSmartInsights sem catch. Fix em duas camadas: 1. try/finally garante setLoading(false) mesmo em erro. 2. .catch por fonte com fallback ([], null, defaults) — falha de uma não derruba as outras; tela renderiza estado vazio em vez de travar. Code review completo identificou também: - Cap em useLapRecorder buffers (risco real, refactor não-trivial, defer). - publishSample loop em recording.tsx (REFUTADO — já decima 3x). - LIMIT em queries de laps (volume típico não justifica, defer). - RLS supabase using(true) — defer pra refactor de auth. --- app/(tabs)/insights.tsx | 11 ++++++++--- app/career.tsx | 11 +++++++---- app/challenges.tsx | 21 ++++++++++++--------- app/leaderboard.tsx | 11 ++++++++--- app/profile-edit.tsx | 12 ++++++++---- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/app/(tabs)/insights.tsx b/app/(tabs)/insights.tsx index 06b4f5d..8d1a2b2 100644 --- a/app/(tabs)/insights.tsx +++ b/app/(tabs)/insights.tsx @@ -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])); diff --git a/app/career.tsx b/app/career.tsx index 5143ecc..5954eb6 100644 --- a/app/career.tsx +++ b/app/career.tsx @@ -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); @@ -100,7 +101,9 @@ export default function CareerScreen() { achievements: unlocked.length, totalAchievements: ACHIEVEMENTS.length, }); - setLoading(false); + } finally { + setLoading(false); + } // Trigger anim reveal.value = 0; diff --git a/app/challenges.tsx b/app/challenges.tsx index 48283bd..30a936d 100644 --- a/app/challenges.tsx +++ b/app/challenges.tsx @@ -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])); diff --git a/app/leaderboard.tsx b/app/leaderboard.tsx index 9861183..a46e190 100644 --- a/app/leaderboard.tsx +++ b/app/leaderboard.tsx @@ -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, diff --git a/app/profile-edit.tsx b/app/profile-edit.tsx index 5afb413..e605053 100644 --- a/app/profile-edit.tsx +++ b/app/profile-edit.tsx @@ -33,10 +33,14 @@ export default function ProfileEdit() { }); useEffect(() => { - getProfile().then((p) => { - if (p) setForm(p); - setLoading(false); - }); + getProfile() + .then((p) => { + if (p) setForm(p); + }) + .catch(() => { + // Sem perfil — form fica com defaults, user pode editar mesmo assim + }) + .finally(() => setLoading(false)); }, []); const update = useCallback((key: K, value: PilotProfile[K]) => { From 16b4f149b272577d31d303e7ec44b80c77e7932b Mon Sep 17 00:00:00 2001 From: cardos0s Date: Mon, 15 Jun 2026 22:22:35 -0300 Subject: [PATCH 19/20] =?UTF-8?q?chore:=20bundle=20ID=20com.cortextech.cop?= =?UTF-8?q?ilot=20=E2=86=92=20com.cortextech.cockpit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes de publicar no App Store. Bundle ID com nome antigo (copilot) ficaria pra sempre — agora alinha com a marca Cockpit. Impacto: - iOS sai de cara com bundle correto (nunca publicado, sem impacto). - Android: usuarios do APK atual perdem dados locais ao instalar proxima versao (bundle diferente = storage diferente). Hoje sao basicamente eu + 1-2 testers; aceitavel. - App Store Connect: usar este bundle ao criar o app. - Apple Developer: registrar com.cortextech.cockpit nos Identifiers. --- app.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app.config.js b/app.config.js index 3f95290..a056a3a 100644 --- a/app.config.js +++ b/app.config.js @@ -31,7 +31,7 @@ module.exports = { newArchEnabled: true, ios: { supportsTablet: false, - package: 'com.cortextech.copilot', + package: 'com.cortextech.cockpit', infoPlist: { UIBackgroundModes: ['location', 'location'], NSLocationWhenInUseUsageDescription: @@ -44,10 +44,10 @@ module.exports = { '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', From dfa5f518a96a8abd4a3d38cd5c83c991e4864b12 Mon Sep 17 00:00:00 2001 From: cardos0s Date: Mon, 15 Jun 2026 23:03:12 -0300 Subject: [PATCH 20/20] chore(ios): ITSAppUsesNonExemptEncryption=false no Info.plist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App usa apenas HTTPS via system TLS e Keychain do iOS — nenhuma crypto custom ou lib extra. Declarando isso no Info.plist evita o dialog de Export Compliance Documentation a cada build futuro. Standard exempt under §740.17(b)(1) of the EAR. --- app.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app.config.js b/app.config.js index a056a3a..e6b07fb 100644 --- a/app.config.js +++ b/app.config.js @@ -33,6 +33,10 @@ module.exports = { supportsTablet: false, 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 Cockpit usa sua localização para gravar a trajetória na pista.',