尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
App de check-ins, desafios e ranking para as aulas de forró do Espaço Livre, inspirado no GymRats. Projeto social, custo de infraestrutura R$ 0. As aulas acontecem a semana toda — forró de segunda a segunda! 🎶 Foi à aula → tira a foto na hora → interage com a turma → sobe no ranking. 💃🕺查看完整 README ↗
App de check-ins, desafios e ranking para as aulas de forró do Espaço Livre, inspirado no GymRats. Projeto social, custo de infraestrutura R$ 0. As aulas acontecem a semana toda — forró de segunda a segunda! 🎶
Foi à aula → tira a foto na hora → interage com a turma → sobe no ranking. 💃🕺
nome;telefone;turma;papel)Sem configurar nada, o app roda em modo demo: dados de exemplo no
localStorage, login fictício, tudo funciona (inclusive como organizador).
Dica: cadastre-se com o telefone 11 97777-1234 para ver o vínculo
automático de turma pela lista de chamada.
npm install
npm run dev # http://localhost:5173
A câmera exige HTTPS (ou localhost) e permissão do navegador.
supabase/schema.sql
e execute. Isso cria as tabelas, RLS, triggers (cadastro por telefone,
turma automática), o bucket de fotos e o realtime.supabase/emails/README.md — SMTP próprio,
URLs de retorno, limite de envio e os textos personalizados. Sem isso o
"esqueci minha senha" diz que enviou e o e-mail não chega a ninguém: o
servidor embutido do Supabase só entrega para membros da organização, e
no máximo umas duas mensagens por hora..env:cp .env.example .env
# preencha VITE_SUPABASE_URL e VITE_SUPABASE_ANON_KEY
insert into public.alunos_cadastrados (nome, telefone, turma, papel_danca)
values ('Seu Nome', '11 91234-5678', 'Avançado', 'Condutor(a)');
update public.roles set papel = 'organizador'
where user_id = (
select id from auth.users where email = '[email protected]'
);
nome;telefone;turma;papel
Ana Xote;11 98888-0003;Avançado;Condutor
Ana Xote;11 98888-0003;Intermediário;Conduzido
Pedro Baião;11 98888-0004;Iniciante 02;Conduzido
Esqueci a senha: o aluno resolve sozinho pelo link na tela de entrada, desde que o passo 3 esteja feito. A exceção são as contas anteriores à migração 013, que nasceram sem e-mail: elas precisam cadastrar um no perfil primeiro. Enquanto não cadastram, só o organizador destrava (Authentication
Users > usuário > Reset password).
vercel.json já cuida das rotas do SPA).VITE_SUPABASE_URL e
VITE_SUPABASE_ANON_KEY.O free tier pausa o projeto após 7 dias sem atividade. O workflow
.github/workflows/keepalive.yml faz um
ping semanal — basta criar os secrets SUPABASE_URL e SUPABASE_ANON_KEY
no repositório do GitHub (Settings > Secrets and variables > Actions).
npx web-push generate-vapid-keysVITE_VAPID_PUBLIC_KEY (no .env e na Vercel) —
com ela definida, aparece o botão "Ativar lembretes" no perfil.supabase/functions/send-push:supabase functions deploy send-push
supabase secrets set VAPID_PUBLIC_KEY=... VAPID_PRIVATE_KEY=...
iPhone: push e câmera no iOS funcionam melhor com o PWA instalado na tela inicial (Compartilhar > Adicionar à Tela de Início). Mantenha o grupo do WhatsApp como canal paralelo de lembretes.
| Limite | Estratégia já implementada |
|---|---|
| 1 GB de storage | Compressão no cliente com teto de bytes — 90 KB por foto, 20 KB por avatar (src/lib/image.ts), o que dá ~12 mil fotos. Avatar antigo e foto de check-in excluído são apagados do bucket na hora. Limpeza automática toda semana (Edge Function limpar-fotos + pg_cron): arquiva fotos de +4 meses (mantém a presença, pula os favoritos) e recolhe órfãos. A 60 check-ins/dia isso segura o acervo em ~550 MB. Conferir: supabase/retencao.sql |
| 5 GB egress/mês | Fotos cacheadas no service worker (CacheFirst, 30 dias) |
| 500 MB de banco | Check-ins são linhas pequenas — irrelevante nessa escala |
| Pausa por inatividade | Workflow de keep-alive semanal |
| 50k usuários ativos | Folga enorme para 200–1.000 alunos |
src/
lib/ tipos, datas/streak/janelas, telefone, imagem, CSV
badges.ts distintivos (derivados dos dados)
api.ts interface única da camada de dados
supabaseApi.ts produção (auth, Postgres, storage, realtime)
demoApi.ts modo demonstração (localStorage, dados de exemplo)
context/ AuthContext (sessão/perfil/papel), ToastContext
components/ Layout, CameraCapture, CheckinCard, ChallengeForm…
pages/ Login, Feed, Checkin, Desafios, Ranking, Perfil, Painel
sw.ts service worker (precache, cache de fotos, push)
supabase/
schema.sql esquema completo: tabelas + RLS + triggers + storage
migracoes/ mudanças para bancos JÁ no ar (`npm run migrar`)
retencao.sql limpeza de fotos antigas (rodar semestralmente)
functions/send-push edge function do lembrete
Banco já em produção? Não rode o
schema.sqlde novo — ele recria políticas que já existem e dá erro. Use os arquivos desupabase/migracoes/na ordem numérica:npm run migrarroda os que faltam (veja o DEPLOY.md).
diaDaNoite/limitesDaNoite, HORA_VIRADA_NOITE) — em
espaço livre a galera fica de madrugada, e quem chegou às 23h e quem
chegou à 1h estavam juntos. Com o corte na meia-noite eles caíam em
datas diferentes e não podiam se marcar. No banco, noite_do_checkin
faz o mesmo (migração 018), e converte para o fuso local ANTES de
tirar a data: criado_em::date usava UTC, então em Itajubá qualquer
check-in a partir das 21h locais já virava "amanhã" e a marcação
falhava para quase toda a aula. Só as duplas usam essa regra — aula e
agenda continuam em dia de calendário, e desafios já tinham a própria
noção de janela que cruza a meia-noite.parceirosPossiveis(data) lista quem posso marcar num dia — e devolve
lista vazia se EU não tiver check-in nesse dia, mesmo que a outra
pessoa tenha. marcarDupla/marcar_dupla (SQL) já recusavam a
gravação nesse caso, mas o botão continuava aparecendo em três lugares
(grade pós-check-in, perfil, feed) — convidando para uma ação que
ia falhar ao confirmar em vez de nunca ser oferecida. Um bug de UX, não
de integridade: nenhuma dupla inválida chegava a ser gravada.challenge_id: um check-in vale automaticamente para
todos os desafios em que o aluno está inscrito cuja janela (período +
dia da semana + horário) bate com o momento da foto. Uma foto por aula,
sem burocracia.challenge_janelas —
no máximo uma janela por dia (dia_semana, hora_inicio, hora_fim).
O projeto funciona a semana toda, com espaços diferentes tendo horários
de início diferentes, então segunda pode abrir às 18h e quarta às 20h
dentro do mesmo desafio.a<10 dígitos>@alunos.forrodesegunda.app) que não recebe mensagem —
então o "esqueci minha senha" do Supabase, que envia para o e-mail da
conta, não tinha para onde ir. Agora quem se cadastra informa um
e-mail real, que vira o e-mail da conta e portanto o identificador do
login. O telefone continua sendo a chave da lista de chamada (é ele
que libera o cadastro e traz nome e turmas), só deixou de identificar
o login.
Contas anteriores seguem no e-mail sintético e por isso continuam
entrando pelo número: aquele endereço é calculado a partir dele, sem
consulta nenhuma. Essa é a razão de a rampa ser barata — e de não
existir uma função , que teria de ser pública (quem
vai entrar não tem sessão) e viraria um jeito de descobrir o e-mail de
qualquer aluno pelo número.| Comando | O quê |
|---|---|
npm run dev | servidor de desenvolvimento |
npm run build | typecheck + build de produção (gera o PWA em dist/) |
npm run preview | serve o build localmente (para testar o service worker) |
npm run icons | regenera os ícones do PWA a partir do logo |
Conquistas/medalhas, bônus por trazer amigo (validado pelo organizador), importação da lista de chamada por CSV, galeria de vídeos de passos, avisos do organizador, modo offline completo.
telefone → e-mailalunos_cadastrados só é lida por handle_new_user, na criação da
conta — daí a lista não controlar acesso e não fazer sentido guardar
nela quem já entrou. A importação de CSV é a matrícula do semestre:
para quem já tem conta ela troca profile_turmas direto (antes o CSV
só escrevia na chamada, e re-importar um veterano não fazia nada); para
quem não tem, troca a linha da chamada. As turmas do arquivo
substituem as atuais pessoa a pessoa — quem não está no arquivo não é
tocado, então dá para importar uma turma por vez.encerrarSemestre() esvazia
profile_turmas de todo mundo; depois as planilhas repovoam. Sem esse
passo, quem terminou o curso e não volta em planilha nenhuma ficaria
na turma do semestre passado para sempre, já que a substituição é
pessoa a pessoa. Ficar sem turma é um estado normal e suportado: o
veterano continua com conta, pontos, check-ins e distintivos, só não
pertence a uma turma.suspende_desafios tira o dia da conta de todos os desafios — sem
isso, bastava aparecer no local numa noite sem forró para marcar
presença. Não é automático porque nem todo feriado fecha o espaço (pode
ser justo a noite do Forró na Rep), e a decisão vive no cancelamento,
não no desafio. Como a suspensão é checada sobre o dia em que a janela
abriu, uma janela que vira a noite cai inteira.PAGINA_FEED), com cursor por criado_em — não por
deslocamento, que repetiria ou pularia itens quando alguém postasse
no meio. O gargalo não era o tamanho: era o realtime, que refazia a
consulta inteira a cada curtida de qualquer pessoa, para todo mundo
com o app aberto. Agora a atualização busca só a primeira página e
funde com o que está na tela, o intervalo é de 3 s e nada é buscado
com o app em segundo plano. As fotos já eram loading="lazy" e ficam
em cache-first no service worker por 30 dias.IntersectionObserver, não botão. Uma <div>
vazia (sentinela) fica logo depois do último cartão só enquanto
temMais é verdadeiro; quando ela entra na tela, carregarMais()
busca a próxima página sozinho. rootMargin: '600px' busca ANTES de
chegar no fim de verdade, para a página seguinte já estar pronta
quando a rolagem chegar lá. O observer é recriado (useEffect com
[temMais, carregarMais] nas dependências) sempre que carregarMais
muda de identidade — ele é um useCallback que depende de feed,
então cada nova página resulta num observer novo com o cursor certo.<Link to="/publicacao/:id"> com miniatura — sem
isso, "Fulana reagiu à sua foto" não dizia qual. A rota busca o
check-in avulso (getCheckin, fora da paginação do feed) e reaproveita
o CheckinCard. Como o corpo da notificação já usa um <Link> para
a foto, o avatar por dentro NÃO é outro <Link> para o perfil — <a>
dentro de <a> quebra o HTML; quem quiser o perfil clica no autor já
dentro da publicação. "Dancei com" não referencia uma foto, então
continua sem link, só a resposta de confirmar/recusar.FavoritosGrid também
virou uma grade de <Link to="/publicacao/:id">, no lugar do
visualizador escuro que reimplementava reações, comentários e "tirar
dos favoritos" por conta própria (o CheckinCard já faz as três
coisas — o botão de estrela só aparece quando item.user_id é quem
está logado, então a leitura no perfil de outra pessoa continua sem
ele). Como a página é remontada ao voltar para o perfil, o onMudou
que recarregava a lista manualmente deixou de ter função — favoritosDe
parou de buscar reacoes/comentarios, que também não tinham mais
quem os lesse.criado_em), o aluno não consegue burlar pelo relógio.profile_turmas (aluno ↔ turma + papel na
dança), porque um aluno pode fazer várias turmas com papéis diferentes.
Só a organização escreve nessa tabela (RLS) — é isso que impede alguém de
se colocar no Avançado. Na lista de chamada, o mesmo telefone aparece uma
vez por turma. O vínculo usa o telefone normalizado (últimos 10 dígitos),
então +55 (11) 91234-5678 e 11912345678 casam.src/lib/badges.ts) são
puramente derivados de turmas/check-ins/eventos, sem armazenar nada. Os
personalizados são o oposto — um catálogo (distintivos) que a
organização cria e concede manualmente (distintivos_concedidos), sem
autoconcessão possível (RLS só deixa organizador escrever). Existiu um
distintivo automático de "Campeão(ã) de desafio"; foi substituído pelo
sistema personalizado, que dá mais controle (top 3, top 5, qualquer
motivo) sem exigir 1º lugar exato.