From 06c6de46603502af2e36b3e7ba89c733b14b3504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Tue, 21 Apr 2026 17:00:33 +0900 Subject: [PATCH] Refactor stats service for KBO-aligned weeks and add documentation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KBO 리그의 경기 일정 특성을 반영하여 주간 통계 산출 기준을 기존 ISO 주차 방식에서 화요일~월요일 주기로 변경했습니다. - `tuesdayOf` 유틸리티를 추가하여 특정 날짜가 속한 주의 시작일(화요일)을 기준으로 주간 데이터를 판정하고 집계하도록 개선했습니다. - 서비스 내의 모든 주요 함수에 JSDoc 주석을 추가하여 각 로직의 역할과 매개변수에 대한 상세한 설명을 보완했습니다. - 코드 포맷팅을 정리하고 불필요한 ISO 주차 계산 로직을 제거하여 통계 산출 파이프라인을 최적화했습니다. --- src/services/statsService.ts | 353 +++++++++++++++++++++-------------- 1 file changed, 216 insertions(+), 137 deletions(-) diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 2126c5f..b2f2acf 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -1,190 +1,269 @@ -import { rtdb } from "../firebase.js"; -import { HttpError } from "../middleware/errors.js"; -import { computeLevel } from "../constants/levels.js"; -import { getAll, getDay } from "../repositories/voteHistoryRepository.js"; -import type { StatsResponse, VoteHistoryDoc } from "../types/panit.js"; +import {rtdb} from "../firebase.js"; +import {HttpError} from "../middleware/errors.js"; +import {computeLevel} from "../constants/levels.js"; +import {getAll, getDay} from "../repositories/voteHistoryRepository.js"; +import type {StatsResponse, VoteHistoryDoc} from "../types/panit.js"; import { - parseDateString, - toDateString, - startOfDayKst, - todayKst, - type DateString, + parseDateString, + toDateString, + startOfDayKst, + todayKst, + type DateString, } from "../types/dateString.js"; type Period = - | "current" - | { kind: "year"; year: number } - | { kind: "month"; year: number; month: number } - | { kind: "week"; year: number; week: number }; + | "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 mWeek = /^(\d{4})-W(\d{1,2})$/.exec(p); - if (mWeek) return { kind: "week", year: Number(mWeek[1]), week: Number(mWeek[2]) }; - throw new HttpError(400, `invalid period: ${p}`); + 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 [y, m, d] = date.split("-").map(Number); + return {y, m, d}; } -/** ISO week of a KST calendar date. 달력 계산만 하므로 UTC 연산은 안전. */ -function isoWeek(date: DateString): { year: number; week: number } { - const { y, m, d } = parseYmd(date); - const t = new Date(Date.UTC(y, m - 1, d)); - const day = t.getUTCDay() || 7; - t.setUTCDate(t.getUTCDate() + 4 - day); - const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1)); - const week = Math.ceil((((t.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); - return { year: t.getUTCFullYear(), week }; +/** 해당 날짜가 속한 화~월 주의 화요일을 반환한다. */ +function tuesdayOf(date: DateString): DateString { + const {y, m, d} = parseYmd(date); + const dow = new Date(y, m - 1, d).getDay() || 7; // 일=7, 월=1, 화=2, ... + const offset = (dow - 2 + 7) % 7; + return toDateString(new Date(y, m - 1, d - offset)); } +/** + * 날짜가 주어진 기간에 속하는지 판정한다. + * + * @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; - const iw = isoWeek(dateStr); - return iw.year === period.year && iw.week === period.week; + 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; + 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 }; + 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; + 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; + 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일간의 일별 예측 결과를 배열로 반환한다. + * + * - `true` — 하나 이상 적중 + * - `false` — 전부 오답 + * - `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(Date.UTC(y, m - 1, d)).getUTCDay() || 7; - const mondayMs = todayMs - (dow - 1) * 86400000; + 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 results: Array = []; - for (let i = 0; i < 7; i++) { - const key = toDateString(new Date(mondayMs + i * 86400000)); - const doc = byDate.get(key); - if (!doc || doc.data.length === 0) { - results.push(null); - } else { - const anyCorrect = doc.data.some((v) => v.result === true); - const allWrong = doc.data.every((v) => v.result === false); - results.push(anyCorrect ? true : allWrong ? false : null); - } - } - return results; + 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); + if (!doc || doc.data.length === 0) { + results.push(null); + } else { + const anyCorrect = doc.data.some((v) => v.result === true); + const allWrong = doc.data.every((v) => v.result === false); + results.push(anyCorrect ? true : allWrong ? false : null); + } + } + return results; } +/** + * 유저의 예측 통계를 산출한다. + * 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 레벨을 포함한다. + * + * @param uid - 유저 ID + * @param period - 레벨·예측 수 집계에 사용할 기간 + */ async function computeStats(uid: string, period: Period): Promise { - const all = await getAll(uid); - const overall = aggregate(all); + const all = await getAll(uid); + const overall = aggregate(all); - const today = todayKst(); - const { y: nowYear, m: nowMonth } = parseYmd(today); - const iw = isoWeek(today); + 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 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 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", year: iw.year, week: iw.week }) - ); - const weekly = aggregate(weeklyEntries); + 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); + const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period)); + const periodAgg = aggregate(periodEntries); - const streakDays = computeStreak(all); - const weeklyResults = weeklyResultsOf(all); - const { level, progress } = computeLevel(periodAgg.total); + const streakDays = computeStreak(all); + const weeklyResults = weeklyResultsOf(all); + const {level, progress} = computeLevel(periodAgg.total); - return { - streakDays, - 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, - updatedAt: Date.now(), - }; + return { + streakDays, + 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, + updatedAt: Date.now(), + }; } +/** 유저별 통계 캐시의 RTDB 경로를 반환한다. */ function cachePath(uid: string, key: string): string { - return `/cache/stats/${uid}/${key}`; + 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"; + const period = parsePeriod(periodParam); + const key = periodParam && periodParam !== "current" ? periodParam : "current"; - const cached = await rtdb.ref(cachePath(uid, key)).get(); - if (cached.exists()) return cached.val() as StatsResponse; + const cached = await rtdb.ref(cachePath(uid, key)).get(); + if (cached.exists()) return cached.val() as StatsResponse; - const stats = await computeStats(uid, period); - await rtdb.ref(cachePath(uid, key)).set(stats); - return stats; + 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(); + 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: [] }; + 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: []}; }