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

341 lines
7.6 KiB
Markdown

# 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}`
```typescript
type User = {
uid: string; // Firebase Auth UID
email: string;
displayName: string;
role: "admin" | "viewer";
createdAt: Timestamp;
}
```
---
### `clubs/{clubId}`
```typescript
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}`
```typescript
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}`
```typescript
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}`
```typescript
type Round = {
id: string;
seasonId: string;
number: number; // 15
name: string; // "Jornada 15"
startDate: Timestamp;
endDate: Timestamp;
status: "upcoming" | "active" | "completed";
}
```
---
### `games/{gameId}`
```typescript
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)
```typescript
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}`
```typescript
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
```typescript
// 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:
```typescript
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()
})
}
```