Refactor stats service for KBO-aligned weeks and add documentation.

- KBO 리그의 경기 일정 특성을 반영하여 주간 통계 산출 기준을 기존 ISO 주차 방식에서 화요일~월요일 주기로 변경했습니다.
- `tuesdayOf` 유틸리티를 추가하여 특정 날짜가 속한 주의 시작일(화요일)을 기준으로 주간 데이터를 판정하고 집계하도록 개선했습니다.
- 서비스 내의 모든 주요 함수에 JSDoc 주석을 추가하여 각 로직의 역할과 매개변수에 대한 상세한 설명을 보완했습니다.
- 코드 포맷팅을 정리하고 불필요한 ISO 주차 계산 로직을 제거하여 통계 산출 파이프라인을 최적화했습니다.
This commit is contained in:
윤정민 2026-04-21 17:00:33 +09:00
parent cbf2bdd18d
commit 06c6de4660

View File

@ -1,190 +1,269 @@
import { rtdb } from "../firebase.js"; import {rtdb} from "../firebase.js";
import { HttpError } from "../middleware/errors.js"; import {HttpError} from "../middleware/errors.js";
import { computeLevel } from "../constants/levels.js"; import {computeLevel} from "../constants/levels.js";
import { getAll, getDay } from "../repositories/voteHistoryRepository.js"; import {getAll, getDay} from "../repositories/voteHistoryRepository.js";
import type { StatsResponse, VoteHistoryDoc } from "../types/panit.js"; import type {StatsResponse, VoteHistoryDoc} from "../types/panit.js";
import { import {
parseDateString, parseDateString,
toDateString, toDateString,
startOfDayKst, startOfDayKst,
todayKst, todayKst,
type DateString, type DateString,
} from "../types/dateString.js"; } from "../types/dateString.js";
type Period = type Period =
| "current" | "current"
| { kind: "year"; year: number } | { kind: "year"; year: number }
| { kind: "month"; year: number; month: number } | { kind: "month"; year: number; month: number }
| { kind: "week"; year: number; week: number }; | { kind: "week"; tuesday: DateString };
/**
* `Period` .
* .
*
* - `"current"`
* - `"2026"`
* - `"2026-04"`
* - `"2026-04-23"` (~)
*
* @param p -
* @throws {HttpError} 400
*/
function parsePeriod(p: string | undefined): Period { function parsePeriod(p: string | undefined): Period {
if (!p || p === "current") return "current"; if (!p || p === "current") return "current";
const mYear = /^(\d{4})$/.exec(p); const mYear = /^(\d{4})$/.exec(p);
if (mYear) return { kind: "year", year: Number(mYear[1]) }; if (mYear) return {kind: "year", year: Number(mYear[1])};
const mMonth = /^(\d{4})-(\d{2})$/.exec(p); const mMonth = /^(\d{4})-(\d{2})$/.exec(p);
if (mMonth) return { kind: "month", year: Number(mMonth[1]), month: Number(mMonth[2]) }; if (mMonth) return {kind: "month", year: Number(mMonth[1]), month: Number(mMonth[2])};
const mWeek = /^(\d{4})-W(\d{1,2})$/.exec(p); const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p);
if (mWeek) return { kind: "week", year: Number(mWeek[1]), week: Number(mWeek[2]) }; if (mDate) return {kind: "week", tuesday: tuesdayOf(parseDateString(p))};
throw new HttpError(400, `invalid period: ${p}`); throw new HttpError(400, `invalid period: ${p}`);
} }
/**
* `YYYY-MM-DD` ·· .
*
* @param date - KST
*/
function parseYmd(date: DateString): { y: number; m: number; d: number } { function parseYmd(date: DateString): { y: number; m: number; d: number } {
const [y, m, d] = date.split("-").map(Number); const [y, m, d] = date.split("-").map(Number);
return { y, m, d }; return {y, m, d};
} }
/** ISO week of a KST calendar date. 달력 계산만 하므로 UTC 연산은 안전. */ /** 해당 날짜가 속한 화~월 주의 화요일을 반환한다. */
function isoWeek(date: DateString): { year: number; week: number } { function tuesdayOf(date: DateString): DateString {
const { y, m, d } = parseYmd(date); const {y, m, d} = parseYmd(date);
const t = new Date(Date.UTC(y, m - 1, d)); const dow = new Date(y, m - 1, d).getDay() || 7; // 일=7, 월=1, 화=2, ...
const day = t.getUTCDay() || 7; const offset = (dow - 2 + 7) % 7;
t.setUTCDate(t.getUTCDate() + 4 - day); return toDateString(new Date(y, m - 1, d - offset));
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 };
} }
/**
* .
*
* @param dateStr -
* @param period - . `"current"` `true`.
*/
function matchesPeriod(dateStr: DateString, period: Period): boolean { function matchesPeriod(dateStr: DateString, period: Period): boolean {
if (period === "current") return true; if (period === "current") return true;
const { y, m } = parseYmd(dateStr); const {y, m} = parseYmd(dateStr);
if (period.kind === "year") return y === period.year; if (period.kind === "year") return y === period.year;
if (period.kind === "month") return y === period.year && m === period.month; if (period.kind === "month") return y === period.year && m === period.month;
const iw = isoWeek(dateStr); return tuesdayOf(dateStr) === period.tuesday;
return iw.year === period.year && iw.week === period.week;
} }
/**
* .
*
* @param entries -
*/
function aggregate(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): { function aggregate(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): {
total: number; total: number;
correct: number; correct: number;
} { } {
let total = 0; let total = 0;
let correct = 0; let correct = 0;
for (const { doc } of entries) { for (const {doc} of entries) {
for (const v of doc.data) { for (const v of doc.data) {
total += 1; total += 1;
if (v.result) correct += 1; if (v.result) correct += 1;
} }
} }
return { total, correct }; return {total, correct};
} }
/**
* . 0 .
*
* @param correct -
* @param total -
*/
function rate(correct: number, total: number): number { 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 { function computeStreak(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): number {
if (entries.length === 0) return 0; if (entries.length === 0) return 0;
const sorted = [...entries].sort((a, b) => (a.date < b.date ? 1 : -1)); const sorted = [...entries].sort((a, b) => (a.date < b.date ? 1 : -1));
const todayMs = startOfDayKst(todayKst()).getTime(); const todayMs = startOfDayKst(todayKst()).getTime();
let streak = 0; let streak = 0;
for (const { date } of sorted) { for (const {date} of sorted) {
const ms = startOfDayKst(date).getTime(); const ms = startOfDayKst(date).getTime();
const diff = Math.round((todayMs - ms) / 86400000); const diff = Math.round((todayMs - ms) / 86400000);
if (diff === streak || (streak === 0 && diff <= 1)) { if (diff === streak || (streak === 0 && diff <= 1)) {
streak += 1; streak += 1;
} else { } else {
break; break;
} }
} }
return streak; return streak;
} }
/**
* (~) 7 .
*
* - `true`
* - `false`
* - `null`
*
* @param entries -
* @returns 7
*/
function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): Array<boolean | null> { function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): Array<boolean | null> {
const byDate = new Map(entries.map((e) => [e.date, e.doc] as const)); const byDate = new Map(entries.map((e) => [e.date, e.doc] as const));
const today = todayKst(); const today = todayKst();
const todayMs = startOfDayKst(today).getTime(); const todayMs = startOfDayKst(today).getTime();
const { y, m, d } = parseYmd(today); const {y, m, d} = parseYmd(today);
const dow = new Date(Date.UTC(y, m - 1, d)).getUTCDay() || 7; const dow = new Date(y, m - 1, d).getDay() || 7;
const mondayMs = todayMs - (dow - 1) * 86400000;
const results: Array<boolean | null> = []; const offset = (dow - 2 + 7) % 7;
for (let i = 0; i < 7; i++) { // 화요일을 기준으로 하기 위해 일요일 + 2 를 하고, 음수 방지를 위해 7을 더한다.
const key = toDateString(new Date(mondayMs + i * 86400000)); // 그러면 이번주 화요일 기준으로 몇일이 지났는지 계산 가능
const doc = byDate.get(key);
if (!doc || doc.data.length === 0) { const tuesdayMs = todayMs - offset * 86400000;
results.push(null);
} else { const results: Array<boolean | null> = [];
const anyCorrect = doc.data.some((v) => v.result === true); for (let i = 0; i < 7; i++) {
const allWrong = doc.data.every((v) => v.result === false); const key = toDateString(new Date(tuesdayMs + i * 86400000));
results.push(anyCorrect ? true : allWrong ? false : null); const doc = byDate.get(key);
} if (!doc || doc.data.length === 0) {
} results.push(null);
return results; } 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<StatsResponse> { async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
const all = await getAll(uid); const all = await getAll(uid);
const overall = aggregate(all); const overall = aggregate(all);
const today = todayKst(); const today = todayKst();
const { y: nowYear, m: nowMonth } = parseYmd(today); const {y: nowYear, m: nowMonth} = parseYmd(today);
const iw = isoWeek(today); const thisTuesday = tuesdayOf(today);
const seasonEntries = all.filter((e) => matchesPeriod(e.date, { kind: "year", year: nowYear })); const seasonEntries = all.filter((e) => matchesPeriod(e.date, {kind: "year", year: nowYear}));
const season = aggregate(seasonEntries); const season = aggregate(seasonEntries);
const monthlyEntries = all.filter((e) => const monthlyEntries = all.filter((e) =>
matchesPeriod(e.date, { kind: "month", year: nowYear, month: nowMonth }) matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth})
); );
const monthly = aggregate(monthlyEntries); const monthly = aggregate(monthlyEntries);
const weeklyEntries = all.filter((e) => const weeklyEntries = all.filter((e) =>
matchesPeriod(e.date, { kind: "week", year: iw.year, week: iw.week }) matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday})
); );
const weekly = aggregate(weeklyEntries); const weekly = aggregate(weeklyEntries);
const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period)); const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period));
const periodAgg = aggregate(periodEntries); const periodAgg = aggregate(periodEntries);
const streakDays = computeStreak(all); const streakDays = computeStreak(all);
const weeklyResults = weeklyResultsOf(all); const weeklyResults = weeklyResultsOf(all);
const { level, progress } = computeLevel(periodAgg.total); const {level, progress} = computeLevel(periodAgg.total);
return { return {
streakDays, streakDays,
weeklyResults, weeklyResults,
winRates: { winRates: {
overall: rate(overall.correct, overall.total), overall: rate(overall.correct, overall.total),
weekly: rate(weekly.correct, weekly.total), weekly: rate(weekly.correct, weekly.total),
monthly: rate(monthly.correct, monthly.total), monthly: rate(monthly.correct, monthly.total),
season: rate(season.correct, season.total), season: rate(season.correct, season.total),
}, },
totalPredictions: periodAgg.total, totalPredictions: periodAgg.total,
totalCorrect: periodAgg.correct, totalCorrect: periodAgg.correct,
weeklyPredictions: weekly.total, weeklyPredictions: weekly.total,
currentLevel: level, currentLevel: level,
progress, progress,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
} }
/** 유저별 통계 캐시의 RTDB 경로를 반환한다. */
function cachePath(uid: string, key: string): string { 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<StatsResponse> { export async function getStats(uid: string, periodParam?: string): Promise<StatsResponse> {
const period = parsePeriod(periodParam); const period = parsePeriod(periodParam);
const key = periodParam && periodParam !== "current" ? periodParam : "current"; const key = periodParam && periodParam !== "current" ? periodParam : "current";
const cached = await rtdb.ref(cachePath(uid, key)).get(); const cached = await rtdb.ref(cachePath(uid, key)).get();
if (cached.exists()) return cached.val() as StatsResponse; if (cached.exists()) return cached.val() as StatsResponse;
const stats = await computeStats(uid, period); const stats = await computeStats(uid, period);
await rtdb.ref(cachePath(uid, key)).set(stats); await rtdb.ref(cachePath(uid, key)).set(stats);
return stats; return stats;
} }
/**
* .
* .
*
* @param uid - ID
*/
export async function invalidateStats(uid: string): Promise<void> { export async function invalidateStats(uid: string): Promise<void> {
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<VoteHistoryDoc> { export async function getHistory(uid: string, date: string): Promise<VoteHistoryDoc> {
let parsed; let parsed;
try { try {
parsed = parseDateString(date); parsed = parseDateString(date);
} catch (err) { } catch (err) {
throw new HttpError(400, (err as Error).message); throw new HttpError(400, (err as Error).message);
} }
const doc = await getDay(uid, parsed); const doc = await getDay(uid, parsed);
return doc ?? { data: [] }; return doc ?? {data: []};
} }