Refactor stats service for KBO-aligned weeks and add documentation.
- KBO 리그의 경기 일정 특성을 반영하여 주간 통계 산출 기준을 기존 ISO 주차 방식에서 화요일~월요일 주기로 변경했습니다. - `tuesdayOf` 유틸리티를 추가하여 특정 날짜가 속한 주의 시작일(화요일)을 기준으로 주간 데이터를 판정하고 집계하도록 개선했습니다. - 서비스 내의 모든 주요 함수에 JSDoc 주석을 추가하여 각 로직의 역할과 매개변수에 대한 상세한 설명을 보완했습니다. - 코드 포맷팅을 정리하고 불필요한 ISO 주차 계산 로직을 제거하여 통계 산출 파이프라인을 최적화했습니다.
This commit is contained in:
parent
cbf2bdd18d
commit
06c6de4660
@ -1,8 +1,8 @@
|
||||
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,
|
||||
@ -15,69 +15,105 @@ type Period =
|
||||
| "current"
|
||||
| { kind: "year"; year: 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 {
|
||||
if (!p || p === "current") return "current";
|
||||
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);
|
||||
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]) };
|
||||
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 };
|
||||
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);
|
||||
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;
|
||||
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 {doc} of entries) {
|
||||
for (const v of doc.data) {
|
||||
total += 1;
|
||||
if (v.result) correct += 1;
|
||||
}
|
||||
}
|
||||
return { total, correct };
|
||||
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) {
|
||||
for (const {date} of sorted) {
|
||||
const ms = startOfDayKst(date).getTime();
|
||||
const diff = Math.round((todayMs - ms) / 86400000);
|
||||
if (diff === streak || (streak === 0 && diff <= 1)) {
|
||||
@ -89,17 +125,32 @@ function computeStreak(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>
|
||||
return streak;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 주(화~월) 7일간의 일별 예측 결과를 배열로 반환한다.
|
||||
*
|
||||
* - `true` — 하나 이상 적중
|
||||
* - `false` — 전부 오답
|
||||
* - `null` — 예측 없음 또는 결과 미확정
|
||||
*
|
||||
* @param entries - 전체 투표 이력
|
||||
* @returns 화요일부터 월요일 순서의 7개 원소 배열
|
||||
*/
|
||||
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 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 {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<boolean | null> = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const key = toDateString(new Date(mondayMs + i * 86400000));
|
||||
const key = toDateString(new Date(tuesdayMs + i * 86400000));
|
||||
const doc = byDate.get(key);
|
||||
if (!doc || doc.data.length === 0) {
|
||||
results.push(null);
|
||||
@ -112,24 +163,31 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저의 예측 통계를 산출한다.
|
||||
* 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 레벨을 포함한다.
|
||||
*
|
||||
* @param uid - 유저 ID
|
||||
* @param period - 레벨·예측 수 집계에 사용할 기간
|
||||
*/
|
||||
async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
|
||||
const all = await getAll(uid);
|
||||
const overall = aggregate(all);
|
||||
|
||||
const today = todayKst();
|
||||
const { y: nowYear, m: nowMonth } = parseYmd(today);
|
||||
const iw = isoWeek(today);
|
||||
const {y: nowYear, m: nowMonth} = parseYmd(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 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 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);
|
||||
|
||||
@ -138,7 +196,7 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
||||
|
||||
const streakDays = computeStreak(all);
|
||||
const weeklyResults = weeklyResultsOf(all);
|
||||
const { level, progress } = computeLevel(periodAgg.total);
|
||||
const {level, progress} = computeLevel(periodAgg.total);
|
||||
|
||||
return {
|
||||
streakDays,
|
||||
@ -158,10 +216,18 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
||||
};
|
||||
}
|
||||
|
||||
/** 유저별 통계 캐시의 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<StatsResponse> {
|
||||
const period = parsePeriod(periodParam);
|
||||
const key = periodParam && periodParam !== "current" ? periodParam : "current";
|
||||
@ -174,10 +240,23 @@ export async function getStats(uid: string, periodParam?: string): Promise<Stats
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저의 통계 캐시를 모두 삭제한다.
|
||||
* 경기 결과 확정 등으로 통계가 변경되었을 때 호출한다.
|
||||
*
|
||||
* @param uid - 유저 ID
|
||||
*/
|
||||
export async function invalidateStats(uid: string): Promise<void> {
|
||||
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> {
|
||||
let parsed;
|
||||
try {
|
||||
@ -186,5 +265,5 @@ export async function getHistory(uid: string, date: string): Promise<VoteHistory
|
||||
throw new HttpError(400, (err as Error).message);
|
||||
}
|
||||
const doc = await getDay(uid, parsed);
|
||||
return doc ?? { data: [] };
|
||||
return doc ?? {data: []};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user