윤정민 ffafca1338 Unify prediction ranking on baseball tier codes
- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가
- 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거
- 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강
- 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈
- statsService 테스트를 새 티어 코드·필드 구성으로 갱신
2026-07-23 14:13:19 +09:00

231 lines
7.0 KiB
TypeScript

import type { Timestamp } from "firebase-admin/firestore";
import type { DateString } from "./dateString";
export type Provider = "google" | "apple" | "anonymous";
export type DailyJudgment = "perfect" | "success" | "fail" | "skip";
/** 야구 테마 티어 코드 — 벤치워머(BW) → 유망주(PR) → 주전(ST) → 올스타(AS) → MVP. */
export type TierName = "BW" | "PR" | "ST" | "AS" | "MVP";
export type GameStatus = "scheduled" | "live" | "completed" | "cancelled";
/**
* 무승부 예측의 예약 코드. 실제 팀 코드(TeamCode)와 겹치지 않는 센티넬로,
* `selectedTeamCode`/`VoteEntry.team`에 그대로 저장·반환된다.
*/
export const DRAW_TEAM_CODE = "DRAW";
/** 투표 진영 — 홈 승리 / 원정 승리 / 무승부. counts 키(`{side}Count`)와 1:1. */
export type VoteSide = "home" | "away" | "draw";
export enum TeamCode {
KT = "KT",
NC = "NC",
SK = "SK",
LG = "LG",
HT = "HT",
LT = "LT",
HH = "HH",
OB = "OB",
SS = "SS",
WO = "WO",
}
export enum KnowledgeLevel {
Beginner = "beginner",
Casual = "casual",
Expert = "expert",
}
export { PointLedgerType } from "./points";
import type { PointLedgerType } from "./points";
export enum AttendanceResult {
CheckedIn = "checkedIn",
AlreadyCheckedIn = "alreadyCheckedIn",
}
export enum NotificationKey {
Attendance = "attendance",
}
export type NotificationsMap = Partial<Record<NotificationKey, boolean>>;
/**
* 단일 재화(`balance`) 변동을 기록하는 ledger row.
* `balanceAfter`가 단일 진실 원천 — 현재 잔액은 가장 최신 row의 `balanceAfter`다.
* 같은 트랜잭션 내 여러 row가 동시 작성될 때는 `seq`로 안정 정렬한다.
*/
export interface AttendanceMonthDoc {
/** 정렬된 1~말일 배열. */
days: number[];
lastCheckedInAt: Timestamp;
lastIdempotencyKey?: string;
lastResult?: AttendanceCheckInResult;
}
export interface AttendanceStateDoc {
lastAttendanceDate: DateString;
currentAttendanceStreak: number;
streakCycleStart: DateString;
highestAttendanceStreak: number;
updatedAt: Timestamp;
}
export interface PointAward {
type: PointLedgerType;
amount: number;
}
export interface AttendanceCheckInResult {
result: AttendanceResult;
serverNow: Timestamp;
attendedDays: number[];
totalCount: number;
pointsAwarded: PointAward[];
balanceAfter: number;
attendanceStreak: number;
}
export interface AttendanceMonth {
month: string;
attendedDays: number[];
totalCount: number;
balance: number;
}
export interface User {
displayName: string;
email: string;
photoUrl?: string;
provider: Provider;
favoriteTeamCode?: TeamCode;
knowledgeLevel: KnowledgeLevel;
createdAt: Timestamp;
notifications?: NotificationsMap;
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
fcmToken?: string;
/**
* 계정 활성 여부. 탈퇴 시 삭제 대신 false(비활성화)로 전환된다 — 문서·기록은
* 유예 기간 동안 보존되고, 랭킹 쿼리(`active == true` 필터)에서만 제외된다.
* 신규 가입 시 true로 생성되며, 필드가 없는 문서는 랭킹 쿼리에 잡히지 않으므로
* 기존 유저는 백필이 필요하다.
*/
active?: boolean;
/** 비활성화 시각. 유예 기간(30일) 경과 시 `accountPurge` 배치가 영구 파기한다. */
deactivatedAt?: Timestamp;
/**
* 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일.
*
* 주의: 이 필드는 lazy 보정된다. 결석으로 streak이 끊겼더라도
* 다음 판정이 실행되기 전까지는 옛날 값이 그대로 남아 있을 수 있다.
*
* 따라서 외부에서 이 값을 직접 읽어 노출하면 안 되며, 반드시
* `getStats`(statsService)를 통해 보정된 `streakDays`를 사용한다.
*
* 쓰기 권한은 `applyDailyJudgmentTx`만 갖는다.
*/
currentStreak?: number;
highestStreak?: number;
/**
* 적중 누적 포인트(티어·스코어보드 축). 쓰기는 `applyDailyJudgmentTx`가 원칙.
*
* ⚠️ 불변식: 이 값을 변경하는 **모든 경로**(백필·어드민 보정 포함)는 마지막에
* `precomputeScoreboardCache`를 호출해야 한다. 스코어보드 리스트는 사전계산
* 스냅샷, 내 순위는 라이브 계산이라 재계산 없이 값만 바꾸면 둘이 어긋난다.
*/
tierPoints?: number;
/**
* 마지막으로 일일 판정이 적용된 KST 날짜.
* `currentStreak`의 신선도를 가늠하는 기준이며,
* 결석 감지(`hasMissedGameDayBetween`)의 시작점으로도 쓰인다.
*/
lastJudgedDate?: DateString;
rankSnapshot?: RankSnapshot;
}
/**
* 클라이언트에 노출되는 유저 프로필.
*
* `User`에서 streak/티어/랭킹 스냅샷 등 보정이 필요하거나 내부 전용인
* 필드를 제외한 형태. 해당 데이터는 `StatsResponse`(GET /stats)로 서빙된다.
*
* `lastJudgedDate`는 stale 위험이 없는(판정 트랜잭션과 함께 갱신되는) 사실값이라
* 노출한다. 클라의 "어제 예측 리캡" 등에서 마지막으로 판정된 날짜를 필요로 한다.
*/
export interface UserProfile {
displayName: string;
email: string;
photoUrl?: string;
provider: Provider;
favoriteTeamCode?: TeamCode;
knowledgeLevel: KnowledgeLevel;
createdAt: Timestamp;
lastJudgedDate?: DateString;
}
export interface RankSnapshot {
/** 스냅샷 생성 날짜 (KST). */
date: DateString;
/** 전체 스코어보드에서의 rank (동점자 동일 rank). */
overall: number;
/** 해당 팀 내 rank. 스냅샷 당시 `teamCode`와 함께만 유효. */
team?: number;
/** 스냅샷 생성 시점의 `favoriteTeamCode`. 이후 팀 변경 시 team delta 무효화용. */
teamCode?: TeamCode;
}
export interface Game {
time: Timestamp;
stadium: string;
status: GameStatus;
homeTeamCode: string;
awayTeamCode: string;
winningTeamCode?: string;
/** 경기가 cancelled일 때의 사유 텍스트 (예: "우천취소", "그라운드 사정"). */
cancelReason?: string;
}
export interface VoteEntry {
team: string;
result?: boolean;
/** 경기 취소로 무효 처리된 투표 — 참여 흔적만 남고 판정·집계에서 제외된다. */
cancelled?: boolean;
}
export interface VoteHistoryDoc {
/** `result` 없이 `cancelled: true`인 항목은 취소 경기 무효표 — 판정·집계 제외. */
data: Array<{ gameId: string; team: string; result?: boolean; cancelled?: boolean }>;
judgment?: DailyJudgment;
correctCount?: number;
completedCount?: number;
streakAfter?: number;
rewardSettledAt?: Timestamp;
rewardTotal?: number;
}
export interface StatsResponse {
streakDays: number;
highestStreak: number;
weeklyResults: Array<DailyJudgment | null>;
winRates: {
overall: number;
weekly: number;
monthly: number;
season: number;
};
totalPredictions: number;
totalCorrect: number;
weeklyPredictions: number;
tier: TierName;
tierPoints: number;
updatedAt: number;
forDate: DateString;
}