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,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: []};
}