import {rtdb} from "../firebase.js"; import {HttpError} from "../middleware/errors.js"; import {computeLevel} from "../constants/levels.js"; import {tierOf} from "../constants/tiers.js"; import {getAll, getDay} from "../repositories/voteHistoryRepository.js"; import {getUser} from "../repositories/userRepository.js"; import {hasMissedGameDayBetween} from "./judgmentService.js"; import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit.js"; import { addDaysKst, parseDateString, toDateString, startOfDayKst, todayKst, tuesdayOf as tuesdayOfUtil, type DateString, } from "../types/dateString.js"; type Period = | "current" | { kind: "year"; year: number } | { kind: "month"; year: number; month: number } | { kind: "week"; tuesday: DateString }; /** * 기간 문자열을 `Period` 객체로 파싱한다. * 문자열 길이로 종류를 구분한다. * * - `"current"` 또는 미지정 → 전체 * - `"2026"` → 연도 * - `"2026-04"` → 월 * - `"2026-04-23"` → 해당 날짜가 속한 주 (화~월) * * @param p - 기간 문자열 * @throws {HttpError} 400 — 형식이 맞지 않을 때 */ function parsePeriod(p: string | undefined): Period { if (!p || p === "current") return "current"; const mYear = /^(\d{4})$/.exec(p); if (mYear) return {kind: "year", year: Number(mYear[1])}; const mMonth = /^(\d{4})-(\d{2})$/.exec(p); if (mMonth) return {kind: "month", year: Number(mMonth[1]), month: Number(mMonth[2])}; const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p); if (mDate) return {kind: "week", tuesday: tuesdayOf(parseDateString(p))}; throw new HttpError(400, `invalid period: ${p}`); } /** * `YYYY-MM-DD` 문자열을 연·월·일 숫자로 분해한다. * * @param date - KST 날짜 문자열 */ function parseYmd(date: DateString): { y: number; m: number; d: number } { const [y, m, d] = date.split("-").map(Number); return {y, m, d}; } /** 해당 날짜가 속한 화~월 주의 화요일을 반환한다. */ const tuesdayOf = tuesdayOfUtil; /** * 날짜가 주어진 기간에 속하는지 판정한다. * * @param dateStr - 판정 대상 날짜 * @param period - 비교 기간. `"current"`이면 항상 `true`. */ function matchesPeriod(dateStr: DateString, period: Period): boolean { if (period === "current") return true; const {y, m} = parseYmd(dateStr); if (period.kind === "year") return y === period.year; if (period.kind === "month") return y === period.year && m === period.month; return tuesdayOf(dateStr) === period.tuesday; } /** * 투표 이력 항목들의 전체 예측 수와 적중 수를 집계한다. * * @param entries - 날짜별 투표 이력 배열 */ function aggregate(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): { total: number; correct: number; } { let total = 0; let correct = 0; for (const {doc} of entries) { for (const v of doc.data) { total += 1; if (v.result) correct += 1; } } return {total, correct}; } /** * 적중률을 계산한다. 예측이 없으면 0을 반환한다. * * @param correct - 적중 수 * @param total - 전체 예측 수 */ function rate(correct: number, total: number): number { return total === 0 ? 0 : correct / total; } /** * 오늘부터 연속으로 예측에 참여한 일수를 계산한다. * 오늘 아직 예측하지 않았더라도 어제까지 연속이면 카운트한다. * * @param entries - 전체 투표 이력 */ function computeStreak(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): number { if (entries.length === 0) return 0; const sorted = [...entries].sort((a, b) => (a.date < b.date ? 1 : -1)); const todayMs = startOfDayKst(todayKst()).getTime(); let streak = 0; for (const {date} of sorted) { const ms = startOfDayKst(date).getTime(); const diff = Math.round((todayMs - ms) / 86400000); if (diff === streak || (streak === 0 && diff <= 1)) { streak += 1; } else { break; } } return streak; } /** * 이번 주(화~월) 7일간의 일별 판정 결과를 배열로 반환한다. * 저장된 `VoteHistoryDoc.judgment`(perfect/success/fail/skip)을 그대로 노출하며, * 예측 이력이 없거나 판정 전이면 `null`을 반환한다. * * @param entries - 전체 투표 이력 * @returns 화요일부터 월요일 순서의 7개 원소 배열 */ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): Array { const byDate = new Map(entries.map((e) => [e.date, e.doc] as const)); const today = todayKst(); const todayMs = startOfDayKst(today).getTime(); const {y, m, d} = parseYmd(today); const dow = new Date(y, m - 1, d).getDay() || 7; const offset = (dow - 2 + 7) % 7; // 화요일을 기준으로 하기 위해 일요일 + 2 를 하고, 음수 방지를 위해 7을 더한다. // 그러면 이번주 화요일 기준으로 몇일이 지났는지 계산 가능 const tuesdayMs = todayMs - offset * 86400000; const results: Array = []; for (let i = 0; i < 7; i++) { const key = toDateString(new Date(tuesdayMs + i * 86400000)); const doc = byDate.get(key); results.push(doc?.judgment ?? null); } return results; } /** * 유저의 예측 통계를 산출한다. * 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 레벨을 포함한다. * * @param uid - 유저 ID * @param period - 레벨·예측 수 집계에 사용할 기간 */ async function computeStats(uid: string, period: Period): Promise { const [all, user] = await Promise.all([getAll(uid), getUser(uid)]); const overall = aggregate(all); const today = todayKst(); const {y: nowYear, m: nowMonth} = parseYmd(today); const thisTuesday = tuesdayOf(today); const seasonEntries = all.filter((e) => matchesPeriod(e.date, {kind: "year", year: nowYear})); const season = aggregate(seasonEntries); const monthlyEntries = all.filter((e) => matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth}) ); const monthly = aggregate(monthlyEntries); const weeklyEntries = all.filter((e) => matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday}) ); const weekly = aggregate(weeklyEntries); const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period)); const periodAgg = aggregate(periodEntries); // 결석으로 streak이 끊겼는지 lazy 보정. // 1) `lastJudgedDate`가 어제 이후면 정상. // 2) archive 대기 윈도우 보호: 어제분 `userVotes`가 아직 RTDB에 남아 있으면 archive가 // 안 돌았을 뿐이지 활성 유저이므로 결석 아님. // 3) `(lastJudged, today)` 구간에 실제 경기일이 한 번이라도 있었으면 그날 결석한 것이므로 끊김. // KBO 월요일/올스타브레이크 등 휴장일은 자동 무시(`thresholdsFor === "skip"`). // user doc은 갱신하지 않는다 — 다음 판정 시 `applyDailyJudgmentTx`가 정리한다. const lastJudged = user?.lastJudgedDate; const yesterday = addDaysKst(today, -1); let streakBroken = false; if (lastJudged != null && lastJudged < yesterday) { const pending = await rtdb.ref(`/userVotes/${uid}/${yesterday}`).get(); if (!pending.exists()) { streakBroken = await hasMissedGameDayBetween(lastJudged, today); } } const storedStreak = streakBroken ? 0 : user?.currentStreak; const streakDays = storedStreak ?? computeStreak(all); const highestStreak = user?.highestStreak ?? streakDays; const tierPoints = user?.tierPoints ?? 0; const tickets = user?.tickets ?? {dailyAllKill: 0, weeklyMaster: 0}; const weeklyResults = weeklyResultsOf(all); const {level, progress} = computeLevel(periodAgg.total); return { streakDays, highestStreak, weeklyResults, winRates: { overall: rate(overall.correct, overall.total), weekly: rate(weekly.correct, weekly.total), monthly: rate(monthly.correct, monthly.total), season: rate(season.correct, season.total), }, totalPredictions: periodAgg.total, totalCorrect: periodAgg.correct, weeklyPredictions: weekly.total, currentLevel: level, progress, tier: tierOf(tierPoints), tierPoints, tickets, updatedAt: Date.now(), forDate: today, }; } /** 유저별 통계 캐시의 RTDB 경로를 반환한다. */ function cachePath(uid: string, key: string): string { return `/cache/stats/${uid}/${key}`; } /** * 유저의 예측 통계를 조회한다. RTDB 캐시가 있으면 캐시를 반환하고, * 없으면 새로 산출 후 캐시에 저장한다. * * @param uid - 유저 ID * @param periodParam - 기간 문자열 (`"2026"`, `"2026-04"`, `"2026-04-23"` 등). 생략 시 전체. */ export async function getStats(uid: string, periodParam?: string): Promise { const period = parsePeriod(periodParam); const key = periodParam && periodParam !== "current" ? periodParam : "current"; // 캐시는 산출 시점의 KST 날짜(`forDate`)와 함께 저장된다. // 날짜가 바뀌면 streak/weeklyResults 기준이 달라지므로 무효화하고 재계산한다. const today = todayKst(); const cached = await rtdb.ref(cachePath(uid, key)).get(); if (cached.exists()) { const val = cached.val() as StatsResponse; if (val.forDate === today) return val; } const stats = await computeStats(uid, period); await rtdb.ref(cachePath(uid, key)).set(stats); return stats; } /** * 유저의 통계 캐시를 모두 삭제한다. * 경기 결과 확정 등으로 통계가 변경되었을 때 호출한다. * * @param uid - 유저 ID */ export async function invalidateStats(uid: string): Promise { await rtdb.ref(`/cache/stats/${uid}`).remove(); } /** * 특정 날짜의 유저 투표 이력을 조회한다. * * @param uid - 유저 ID * @param date - 조회할 날짜 (`YYYY-MM-DD`) * @throws {HttpError} 400 — 날짜 형식이 올바르지 않을 때 */ export async function getHistory(uid: string, date: string): Promise { let parsed; try { parsed = parseDateString(date); } catch (err) { throw new HttpError(400, (err as Error).message); } const doc = await getDay(uid, parsed); return doc ?? {data: []}; }