criação do website
This commit is contained in:
340
docs/08-firebase-schema.md
Normal file
340
docs/08-firebase-schema.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# 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()
|
||||
})
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user