Files
VdcScoreLive/docs/08-firebase-schema.md
2026-05-05 17:12:06 +01:00

7.6 KiB

08 — Firebase Schema (Firestore)

⚠️ CRÍTICO: Este schema deve ser compatível com o que a app cliente já lê. Antes de desenvolver, confirmar com o utilizador se este schema corresponde ao que já existe na Firebase.


Coleções Principais

firestore/
├── users/              # Utilizadores admin
├── clubs/              # Clubes da liga
├── players/            # Jogadores
├── seasons/            # Temporadas
├── rounds/             # Jornadas
├── games/              # Jogos
│   └── {gameId}/
│       └── events/     # Eventos do jogo (subcollection)
└── standings/          # Classificação

Schema Detalhado

users/{userId}

type User = {
  uid: string;           // Firebase Auth UID
  email: string;
  displayName: string;
  role: "admin" | "viewer";
  createdAt: Timestamp;
}

clubs/{clubId}

type Club = {
  id: string;             // auto-generated
  name: string;           // "FC Porto"
  shortName: string;      // "FCP"
  city: string;           // "Porto"
  stadium: string;        // "Estádio do Dragão"
  primaryColor: string;   // "#004899" (hex)
  secondaryColor: string; // "#FFFFFF" (hex)
  crestUrl: string;       // Firebase Storage URL
  foundedYear: number;    // 1893
  active: boolean;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

players/{playerId}

type Player = {
  id: string;
  clubId: string;           // ref to clubs/{clubId}
  name: string;             // "Mehdi Taremi"
  shortName: string;        // "Taremi"
  nationality: string;      // "Iran"
  dateOfBirth: Timestamp;
  position: "GK" | "DEF" | "MID" | "FWD";
  jerseyNumber: number;
  photoUrl: string;         // Firebase Storage URL
  status: "active" | "injured" | "suspended" | "inactive";
  
  // Estatísticas (atualizadas a cada evento)
  stats: {
    goals: number;
    assists: number;
    yellowCards: number;
    redCards: number;
    gamesPlayed: number;
    minutesPlayed: number;
  };
  
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

seasons/{seasonId}

type Season = {
  id: string;
  name: string;          // "2024/2025"
  startDate: Timestamp;
  endDate: Timestamp;
  active: boolean;       // apenas uma temporada ativa de cada vez
  totalRounds: number;   // 34
  currentRound: number;  // 15
}

rounds/{roundId}

type Round = {
  id: string;
  seasonId: string;
  number: number;        // 15
  name: string;          // "Jornada 15"
  startDate: Timestamp;
  endDate: Timestamp;
  status: "upcoming" | "active" | "completed";
}

games/{gameId}

type Game = {
  id: string;
  seasonId: string;
  roundId: string;
  roundNumber: number;   // desnormalizado para queries mais simples
  
  homeClubId: string;
  awayClubId: string;
  
  // Desnormalizado para evitar joins no cliente
  homeClubName: string;
  awayClubName: string;
  homeClubShortName: string;
  awayClubShortName: string;
  homeClubCrestUrl: string;
  awayClubCrestUrl: string;
  
  scheduledAt: Timestamp;  // data e hora agendada
  startedAt: Timestamp | null;
  endedAt: Timestamp | null;
  
  status: "scheduled" | "live" | "halftime" | "finished" | "postponed" | "cancelled";
  
  score: {
    home: number;    // 0
    away: number;    // 0
  };
  
  currentMinute: number | null;  // minuto atual do jogo (se live)
  
  venue: string;     // nome do estádio
  
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

games/{gameId}/events/{eventId} (subcollection)

type GameEvent = {
  id: string;
  gameId: string;
  
  type: "goal" | "yellow_card" | "red_card" | "substitution" | "penalty_missed" | "own_goal";
  
  minute: number;           // 23
  extraTime: number | null; // minutos de compensação (ex: 90+3 → minute:90, extraTime:3)
  
  // Para golos e cartões
  playerId: string | null;
  playerName: string | null;  // desnormalizado
  clubId: string;
  
  // Específico de golos
  goalType: "normal" | "penalty" | "free_kick" | "header" | null;
  assistPlayerId: string | null;
  assistPlayerName: string | null;
  
  // Específico de substituições
  playerOutId: string | null;
  playerInId: string | null;
  playerOutName: string | null;
  playerInName: string | null;
  
  createdAt: Timestamp;
  createdBy: string;  // userId do admin que registou
}

standings/{seasonId}

type Standings = {
  seasonId: string;
  updatedAt: Timestamp;
  
  table: StandingsEntry[];  // ordenado por pontos
}

type StandingsEntry = {
  position: number;
  clubId: string;
  clubName: string;           // desnormalizado
  clubShortName: string;
  clubCrestUrl: string;
  
  played: number;
  won: number;
  drawn: number;
  lost: number;
  goalsFor: number;
  goalsAgainst: number;
  goalDifference: number;    // calculado: goalsFor - goalsAgainst
  points: number;            // calculado: won*3 + drawn*1
  
  // Últimos 5 jogos (para a app cliente mostrar forma)
  form: ("W" | "D" | "L")[];  // ["W", "W", "D", "L", "W"]
  
  // Override manual (admin pode forçar um valor)
  manualOverride: boolean;
}

Queries Mais Usadas

// Jogos de uma jornada
query(collection(db, "games"), 
  where("roundId", "==", roundId),
  orderBy("scheduledAt")
)

// Jogo live atual
query(collection(db, "games"),
  where("status", "==", "live"),
  limit(1)
)

// Eventos de um jogo (ordenados)
query(collection(db, "games", gameId, "events"),
  orderBy("minute"),
  orderBy("createdAt")
)

// Jogadores de um clube
query(collection(db, "players"),
  where("clubId", "==", clubId),
  where("status", "==", "active"),
  orderBy("jerseyNumber")
)

// Top artilheiros
query(collection(db, "players"),
  orderBy("stats.goals", "desc"),
  limit(20)
)

Índices Firestore Necessários

// firestore.indexes.json
{
  "indexes": [
    {
      "collectionGroup": "games",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "roundId", "order": "ASCENDING" },
        { "fieldPath": "scheduledAt", "order": "ASCENDING" }
      ]
    },
    {
      "collectionGroup": "players",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "clubId", "order": "ASCENDING" },
        { "fieldPath": "stats.goals", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "players",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "clubId", "order": "ASCENDING" },
        { "fieldPath": "jerseyNumber", "order": "ASCENDING" }
      ]
    }
  ]
}

Algoritmo de Recálculo de Standings

Chamado após cada jogo finalizado ou manualmente:

async function recalculateStandings(seasonId: string, db: Firestore) {
  // 1. Buscar todos os jogos terminados da temporada
  const games = await getDocs(query(
    collection(db, "games"),
    where("seasonId", "==", seasonId),
    where("status", "==", "finished")
  ))
  
  // 2. Inicializar stats por clube
  const stats: Record<string, StandingsEntry> = {}
  
  // 3. Iterar todos os jogos e acumular stats
  for (const game of games.docs) {
    const { homeClubId, awayClubId, score } = game.data()
    // ... calcular vitória/empate/derrota, golos, etc.
  }
  
  // 4. Ordenar: pontos DESC, goalDifference DESC, goalsFor DESC
  const table = Object.values(stats).sort((a, b) => 
    b.points - a.points || 
    b.goalDifference - a.goalDifference || 
    b.goalsFor - a.goalsFor
  ).map((entry, i) => ({ ...entry, position: i + 1 }))
  
  // 5. Atualizar documento de standings
  await setDoc(doc(db, "standings", seasonId), {
    seasonId,
    table,
    updatedAt: serverTimestamp()
  })
}