From 9b701f8e4f73be5a45293a77daa680bd412a159f Mon Sep 17 00:00:00 2001 From: Davi Roque Date: Thu, 2 Jul 2026 03:16:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(data):=20ler=20do=20Supabase=20no=20site?= =?UTF-8?q?=20p=C3=BAblico=20(fecha=20o=20circuito=20admin=E2=86=92banco?= =?UTF-8?q?=E2=86=92home)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes, getGrupos()/getTurmas() liam SEMPRE de data/seed.json, mesmo com Supabase configurado. Consequência: o admin gravava notas no banco, mas a home pública continuava mostrando o seed congelado — o revalidatePath("/") das server actions não tinha efeito visível. A função getGruposFromSupabase() citada no comentário nunca existiu. Mudanças: - Implementa a leitura do Supabase via nested select (turmas→disciplinas→notas) em uma única query, sem N+1. - Extrai um buildGrupo() compartilhado entre seed e Supabase: as duas fontes produzem exatamente o mesmo shape de Grupo (cores, stats, aprovação). - getGrupos/getGrupoById/getTurmas/getTurma agora são async e roteiam: Supabase em produção, seed.json em demo. - Fallback defensivo: se a query falhar ou vier vazia, cai no seed e loga o erro — a página nunca quebra (mantém a reprodutibilidade do PRODUCT.md). - React.cache() no fetch: as 5 páginas/componentes compartilham uma ida ao banco por render. - IDs de nota (bigserial) viram String(id), casando com update/deleteNotaAction. - Adiciona await nas chamadas nos 5 server components consumidores. Verificado: tsc --noEmit limpo, npm run build OK, smoke test runtime (home 200 com aprovações renderizadas, /admin 307→login). --- app/admin/(protected)/importar/page.tsx | 2 +- app/admin/(protected)/notas/page.tsx | 2 +- app/admin/(protected)/page.tsx | 4 +- app/admin/(protected)/turmas/page.tsx | 4 +- app/page.tsx | 2 +- lib/data.ts | 169 ++++++++++++++++++++---- 6 files changed, 148 insertions(+), 35 deletions(-) diff --git a/app/admin/(protected)/importar/page.tsx b/app/admin/(protected)/importar/page.tsx index 094bb0e..ec4b044 100644 --- a/app/admin/(protected)/importar/page.tsx +++ b/app/admin/(protected)/importar/page.tsx @@ -4,7 +4,7 @@ import { requireAdmin } from "@/lib/admin-auth"; export default async function ImportarPage() { await requireAdmin(); - const grupos = getGrupos(5); + const grupos = await getGrupos(5); return (
diff --git a/app/admin/(protected)/notas/page.tsx b/app/admin/(protected)/notas/page.tsx index 95cc2db..d552f29 100644 --- a/app/admin/(protected)/notas/page.tsx +++ b/app/admin/(protected)/notas/page.tsx @@ -7,7 +7,7 @@ type SearchParams = { [k: string]: string | string[] | undefined }; export default async function NotasPage({ searchParams }: { searchParams: Promise }) { await requireAdmin(); const params = await searchParams; - const grupos = getGrupos(5); + const grupos = await getGrupos(5); const discId = typeof params.disc === "string" ? params.disc : grupos[0]?.id; const selected = grupos.find((g) => g.id === discId) ?? grupos[0]; diff --git a/app/admin/(protected)/page.tsx b/app/admin/(protected)/page.tsx index 1d08740..285b5d4 100644 --- a/app/admin/(protected)/page.tsx +++ b/app/admin/(protected)/page.tsx @@ -5,8 +5,8 @@ import Link from "next/link"; export default async function AdminOverview() { await requireAdmin(); - const grupos = getGrupos(5); - const turmas = getTurmas(); + const grupos = await getGrupos(5); + const turmas = await getTurmas(); const totalNotas = grupos.reduce((a, g) => a + g.notas.length, 0); const aprovacaoMedia = grupos.reduce((a, g) => a + g.approval, 0) / grupos.length; diff --git a/app/admin/(protected)/turmas/page.tsx b/app/admin/(protected)/turmas/page.tsx index 6d72fe8..2a6e9b6 100644 --- a/app/admin/(protected)/turmas/page.tsx +++ b/app/admin/(protected)/turmas/page.tsx @@ -4,8 +4,8 @@ import { NovaTurmaForm, NovaDisciplinaForm } from "./Forms"; export default async function TurmasPage() { await requireAdmin(); - const turmas = getTurmas(); - const grupos = getGrupos(5); + const turmas = await getTurmas(); + const grupos = await getGrupos(5); return (
diff --git a/app/page.tsx b/app/page.tsx index 4bc0faa..267820f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,7 +7,7 @@ import { AcademicArticleJsonLd } from "@/components/Seo/AcademicArticleJsonLd"; import { PrintHeader } from "@/components/PrintHeader"; export default async function HomePage() { - const grupos = getGrupos(5.0); + const grupos = await getGrupos(5.0); const meta = getMetadata(); const autores = getAutores(); diff --git a/lib/data.ts b/lib/data.ts index 09c981e..3a5f964 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -1,16 +1,14 @@ +import { cache } from "react"; import seed from "@/data/seed.json"; -import type { Grupo, NotaRow, SeedData, Turma, Disciplina } from "./types"; +import type { Grupo, NotaRow, SeedData, Turma } from "./types"; import { approvalRate, describe } from "./stats"; import { isSupabaseEnabled } from "./supabase"; +import { createSupabaseServer } from "./supabase-server"; const isDemo = !isSupabaseEnabled; // Brand chart palette — matches the OKLCH tokens in globals.css // VGA → teal · X → primary navy · Y → purple -// Nota: Cálculo I usa a cor herdada do token editorial --accent (mesmo -// valor hoje: oklch(70% 0.18 35)). Duplicamos aqui pra não importar globals.css; -// se um dia o accent editorial divergir do accent da disciplina, basta -// atualizar este map sem mexer no design system. const DISCIPLINA_COLORS: Record = { "Cálculo I": "oklch(70% 0.18 35)", VGA: "oklch(64% 0.13 185)", @@ -35,45 +33,160 @@ export function getAutores() { return typedSeed.autores; } -export function getTurmas(): Turma[] { - return typedSeed.turmas; -} +/* ---------------- Shared builder ---------------- */ -export function getTurma(id: string): Turma | undefined { - return typedSeed.turmas.find((t) => t.id === id); +/** Monta um Grupo a partir de metadados de turma/disciplina + notas brutas. + * `notasRaw` carrega o id real da linha (Supabase) ou um id determinístico + * (seed) para o admin operar. */ +function buildGrupo( + turmaId: string, + turmaNome: string, + discId: string, + discNome: string, + discCodigo: string, + notasRaw: NotaRow[], + cutoff: number, +): Grupo { + const notas = notasRaw.map((r) => r.nota_final); + return { + id: `${turmaId}__${discId}`, + short: `${turmaNome.replace("Turma ", "")} · ${discCodigo === "VGA" ? "VGA" : "Cálc. I"}`, + label: `${turmaNome} · ${discNome}`, + turmaColor: TURMA_COLORS[turmaId] ?? "oklch(35% 0.08 250)", + disciplinaColor: DISCIPLINA_COLORS[discCodigo] ?? "oklch(70% 0.18 35)", + notas, + notasRaw, + stats: describe(notas), + approval: approvalRate(notas, cutoff), + }; } -export function getGrupos(cutoff = 5.0): Grupo[] { +/* ---------------- Seed (demo) source ---------------- */ + +function getGruposFromSeed(cutoff: number): Grupo[] { const grupos: Grupo[] = []; typedSeed.turmas.forEach((turma) => { turma.disciplinas.forEach((disc) => { - const stats = describe(disc.notas); const notasRaw: NotaRow[] = disc.notas.map((n, i) => ({ // ID determinístico: estável entre reloads, suficiente para o admin - // operar no client. Em prod, este campo é sobrescrito pelo `id` - // real do Supabase quando `getGruposFromSupabase()` for implementado. + // operar no client em modo demo (mutações são no-op no servidor). id: `seed-${turma.id}-${disc.id}-${i}`, aluno_id: `aluno_${i + 1}`, nota_final: n, })); - grupos.push({ - id: `${turma.id}__${disc.id}`, - short: `${turma.nome.replace("Turma ", "")} · ${disc.codigo === "VGA" ? "VGA" : "Cálc. I"}`, - label: `${turma.nome} · ${disc.nome}`, - turmaColor: TURMA_COLORS[turma.id] ?? "oklch(35% 0.08 250)", - disciplinaColor: DISCIPLINA_COLORS[disc.codigo] ?? "oklch(70% 0.18 35)", - notas: disc.notas, - notasRaw, - stats, - approval: approvalRate(disc.notas, cutoff), - }); + grupos.push( + buildGrupo(turma.id, turma.nome, disc.id, disc.codigo, disc.nome, notasRaw, cutoff), + ); }); }); return grupos; } -export function getGrupoById(id: string, cutoff = 5.0): Grupo | undefined { - return getGrupos(cutoff).find((g) => g.id === id); +function getTurmasFromSeed(): Turma[] { + return typedSeed.turmas; +} + +/* ---------------- Supabase (produção) source ---------------- */ + +/** Formato retornado pelo nested select do Supabase. */ +type DiscRow = { + id: string; + nome: string; + codigo: string; + turma_id: string; + notas: { id: number; aluno_id: string; nota_final: number }[]; +}; +type TurmaRow = { + id: string; + nome: string; + ano: number | null; + descricao: string | null; + disciplinas: DiscRow[]; +}; + +/** Busca turmas → disciplinas → notas em uma única query aninhada. + * `cache()` deduplica a chamada dentro do mesmo render (várias + * páginas/componentes podem pedir os grupos sem refetch). */ +const fetchTurmasRows = cache(async (): Promise => { + const supabase = await createSupabaseServer(); + if (!supabase) return null; + const { data, error } = await supabase + .from("turmas") + .select( + `id, nome, ano, descricao, + disciplinas ( id, nome, codigo, turma_id, + notas ( id, aluno_id, nota_final ) )`, + ) + .order("id", { ascending: true }); + if (error) { + console.error("[data] Supabase fetch falhou, caindo pro seed:", error.message); + return null; + } + return (data as TurmaRow[]) ?? null; +}); + +function rowsToGrupos(rows: TurmaRow[], cutoff: number): Grupo[] { + const grupos: Grupo[] = []; + rows.forEach((turma) => { + (turma.disciplinas ?? []).forEach((disc) => { + const notasRaw: NotaRow[] = (disc.notas ?? []).map((n) => ({ + id: String(n.id), // bigserial → string (o admin sempre lida com string) + aluno_id: n.aluno_id, + nota_final: Number(n.nota_final), + })); + grupos.push( + buildGrupo(turma.id, turma.nome, disc.id, disc.nome, disc.codigo, notasRaw, cutoff), + ); + }); + }); + return grupos; +} + +function rowsToTurmas(rows: TurmaRow[]): Turma[] { + return rows.map((t) => ({ + id: t.id, + nome: t.nome, + ano: t.ano, + descricao: t.descricao ?? "", + disciplinas: (t.disciplinas ?? []).map((d) => ({ + id: d.id, + turma_id: d.turma_id, + nome: d.nome, + codigo: d.codigo, + cor: DISCIPLINA_COLORS[d.codigo] ?? "#ff6b3d", + notas: (d.notas ?? []).map((n) => Number(n.nota_final)), + })), + })); +} + +/* ---------------- API pública (async) ---------------- + * Em produção lê do Supabase; em demo (ou se a query falhar/vier vazia) + * cai no seed.json. Isso fecha o circuito admin → banco → site público: + * as mutações do admin passam a refletir na home após revalidatePath("/"). */ + +export async function getGrupos(cutoff = 5.0): Promise { + if (isSupabaseEnabled) { + const rows = await fetchTurmasRows(); + if (rows && rows.length > 0) return rowsToGrupos(rows, cutoff); + // rows null (erro) ou vazio → fallback seed pra página nunca quebrar. + } + return getGruposFromSeed(cutoff); +} + +export async function getGrupoById(id: string, cutoff = 5.0): Promise { + return (await getGrupos(cutoff)).find((g) => g.id === id); +} + +export async function getTurmas(): Promise { + if (isSupabaseEnabled) { + const rows = await fetchTurmasRows(); + if (rows && rows.length > 0) return rowsToTurmas(rows); + } + return getTurmasFromSeed(); +} + +export async function getTurma(id: string): Promise { + return (await getTurmas()).find((t) => t.id === id); } -export type { Turma, Disciplina, NotaRow }; +export type { Turma, Disciplina, NotaRow } from "./types";