Beta-ready: modo competição + rename Cockpit + 8 fixes de UI - #5
Open
cardos0s wants to merge 20 commits into
Open
Beta-ready: modo competição + rename Cockpit + 8 fixes de UI#5cardos0s wants to merge 20 commits into
cardos0s wants to merge 20 commits into
Conversation
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 <path>. 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.
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.
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
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).
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).
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-<ts>-<rand>"), 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).
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.
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.
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
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.
- 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).
- 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.
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).
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: <title>Cockpit Live</title>. - web-spectator/app/page.tsx: <h1>Cockpit Live</h1>. 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.
…falhasse 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.
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.
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.
✅ Deploy Preview for relaxed-torte-d1c1b9 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
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 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resumo
PR grande que consolida tudo do
fix/live-perfpra mergear no main e destravar o beta. São 16 commits agrupados em 4 frentes:🏁 Nova feature: Modo Competição (5 commits)
Eventos multi-piloto com ranking ao vivo + posição no mapa pra audiência.
4e49f61Schema + APIs (tabelaevents, FKs emlive_sessions/laps/samples)a38d64aUI no app: criar/entrar em evento via código de 6 chars6d7761aWeb/event/[code]com ranking ao vivoee7f651Ranking in-app + tela/ranking/[code]16d1ab4Posição ao vivo no mapa (primeira volta vira referência ENU, projeção por sample)🎨 Rebranding: Copilot → Cockpit (1 commit)
a10457bNome noapp.config.js, strings de permissão, weblayout.tsxepage.tsx, notificação de gravação. Bundle IDcom.cortextech.copilotmantido pra preservar dados locais dos testers.🛡️ Robustez de campo (3 commits)
0c6126fPista custom — destrava qualquer kartódromo fora da lista (migration SQLite v3→v4 +custom_trackstable)6603a57Auto-save anti-crash viaexpo-file-system(snapshot a cada 30s + ao fechar volta; recovery na inicialização)3dea91bBackup anônimo de sessões na nuvem pordevice_id(sem login)🐛 8 fixes críticos
1917cd55 telas com loading eterno (leaderboard, challenges, career, profile-edit, insights) — try/finally + .catch por fonte8edd6e6Tela de resultados (session/[id]) idem716d3beNew-session idem (custom_tracks throwing)8f8b899GPS quantizado a segundos → tempos redondos (42.000, 44.000). Fix priorizaDate.now()quandoloc.timestamp % 1000 === 09c227b3Painel da equipe: VOLTA crescia sem parar (mandavainfo.elapsedMsem vez decurrentLapElapsedMs); DELTA estático (mandava delta de PB em vez deliveDeltaMs)100f2fbFormato MM:SS.SSS padronizado em todo lugarb617ce2Painel da equipe não trava mais em sessões longas (cap em estado + decimação no render SVG)4365119Reverte batching que quebrou data flowMudanças no schema do Supabase
Já rodadas pelo dev no SQL editor:
events+ RLS + realtimeevent_idemlive_sessions,live_laps,live_samplesreference_samples_json,reference_duration_ms,reference_set_atemeventsevent_id(laps e samples)Idempotente — pode rodar n vezes sem efeito colateral.
Estatísticas
using (true)em todas as tabelas do Supabase — qualquer um com o código pode inserir dados. OK pra beta privado, BLOQUEIA release público.Test plan
copilot-mu-eight.vercel.appmostra Cockpit Live + modo competição25d79ab4instala como Cockpit e abre sem crash