diff --git a/src/repositories/chatRepository.ts b/src/repositories/chatRepository.ts index 7b469e7..09d09c0 100644 --- a/src/repositories/chatRepository.ts +++ b/src/repositories/chatRepository.ts @@ -15,7 +15,7 @@ import type { NavAction, } from "../types/chat"; import type { TeamCode } from "../types/panit"; -import { addDaysKst, type DateString } from "../types/dateString"; +import { addDays, type DateString } from "../types/dateString"; /** * AI 채팅 저장소(§4) — Firestore/RTDB 접근 전담. @@ -214,7 +214,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise MAX_DAY_OFFSET) return null; - return addDaysKst(today, n); + return addDays(today, n); } return hasDateStr ? null : today; } diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index ceca46a..daec012 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -15,7 +15,7 @@ import { thresholdsFor, } from "../constants/judgment"; import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit"; -import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString"; +import { addDays, tuesdayOf, type DateString } from "../types/dateString"; /** * `(lastJudged, upTo)` 구간(양 끝 제외)에 **실제 판정 가능한 경기일**(=`thresholdsFor`가 @@ -34,12 +34,12 @@ export async function hasMissedGameDayBetween( ): Promise { const fetch = gameCache ?? createGameDayCache(); const MAX_LOOKBACK = 14; - let cursor = addDaysKst(upTo, -1); + let cursor = addDays(upTo, -1); for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) { const games = await fetch.listByDate(cursor); const completed = games.filter((g) => g.status === "completed").length; if (thresholdsFor(completed) !== "skip") return true; - cursor = addDaysKst(cursor, -1); + cursor = addDays(cursor, -1); } // MAX_LOOKBACK 초과로 종료된 경우엔 안전을 위해 결석으로 본다. return cursor > lastJudged; @@ -84,7 +84,7 @@ export async function judgeDay( const lastJudgedPre = userPre?.lastJudgedDate; const streakBrokenIn = lastJudgedPre != null && - lastJudgedPre < addDaysKst(date, -1) && + lastJudgedPre < addDays(date, -1) && (await hasMissedGameDayBetween(lastJudgedPre, date, fetch)); const tx = await applyDailyJudgmentTx(uid, date, { @@ -132,7 +132,7 @@ export async function judgeWeekIfNeeded( const byDate = new Map(entries.map((e) => [e.date, e.doc] as const)); for (let i = 0; i < 6; i++) { - const d = addDaysKst(tuesday, i); + const d = addDays(tuesday, i); const doc = byDate.get(d); if (!doc || !doc.judgment) return; // 판정 누락 if (doc.judgment === "fail") return; diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 15fd9f8..3bafbac 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -7,7 +7,7 @@ import {getUser} from "../repositories/userRepository"; import {hasMissedGameDayBetween} from "./judgmentService"; import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit"; import { - addDaysKst, + addDays, parseDateString, toDateString, startOfDayKst, @@ -194,7 +194,7 @@ async function computeStats(uid: string, period: Period): Promise // KBO 월요일/올스타브레이크 등 휴장일은 자동 무시(`thresholdsFor === "skip"`). // user doc은 갱신하지 않는다 — 다음 판정 시 `applyDailyJudgmentTx`가 정리한다. const lastJudged = user?.lastJudgedDate; - const yesterday = addDaysKst(today, -1); + const yesterday = addDays(today, -1); let streakBroken = false; if (lastJudged != null && lastJudged < yesterday) { const pending = await rtdb.ref(`/userVotes/${uid}/${yesterday}`).get(); diff --git a/src/types/dateString.ts b/src/types/dateString.ts index f505e61..7147ab5 100644 --- a/src/types/dateString.ts +++ b/src/types/dateString.ts @@ -50,20 +50,20 @@ export function startOfDayKst(date: DateString): Date { /** 해당 KST 날짜가 속한 화~월 주의 화요일을 반환한다. */ export function tuesdayOf(date: DateString): DateString { - const [y, m, d] = date.split("-").map(Number); - const dow = new Date(y, m - 1, d).getDay() || 7; // 일=7, 월=1, 화=2 + // 요일 산정·날짜 가감 모두 TZ 독립 헬퍼로만 처리한다(로컬 Date 생성 금지). + const dow = dayOfWeek(date) || 7; // 일=7, 월=1, 화=2 const offset = (dow - 2 + 7) % 7; - return toDateString(new Date(y, m - 1, d - offset)); + return addDays(date, -offset); } -/** `date`의 요일 (일=0, 월=1, ... 토=6). KST 기준. */ -export function dayOfWeekKst(date: DateString): number { +/** `date`(달력 날짜)의 요일 (일=0, 월=1, … 토=6). 달력 날짜라 타임존 무관. */ +export function dayOfWeek(date: DateString): number { const [y, m, d] = date.split("-").map(Number); return new Date(y, m - 1, d).getDay(); } -/** `date`에 `delta` 일을 더한 KST 날짜를 반환. */ -export function addDaysKst(date: DateString, delta: number): DateString { +/** `date`에 `delta` 일을 더한 날짜(YYYY-MM-DD). 순수 달력 산술이라 타임존 무관. */ +export function addDays(date: DateString, delta: number): DateString { const base = startOfDayKst(date).getTime(); return toDateString(new Date(base + delta * 24 * 60 * 60 * 1000)); } diff --git a/tests/services/statsService.test.ts b/tests/services/statsService.test.ts index 3dc9752..e4564c8 100644 --- a/tests/services/statsService.test.ts +++ b/tests/services/statsService.test.ts @@ -3,7 +3,7 @@ import { Timestamp } from "firebase-admin/firestore"; import { firestore, rtdb } from "../../src/firebase"; import { getStats } from "../../src/services/statsService"; import { - addDaysKst, + addDays, todayKst, type DateString, } from "../../src/types/dateString"; @@ -70,8 +70,8 @@ describe("statsService.getStats — 결석 lazy 보정", () => { it("lastJudgedDate가 이틀 이상 이전이고 gap에 실제 경기일이 있으면 streak 0", async () => { const today = todayKst(); - const threeDaysAgo = addDaysKst(today, -3); - const twoDaysAgo = addDaysKst(today, -2); + const threeDaysAgo = addDays(today, -3); + const twoDaysAgo = addDays(today, -2); await seedUser({ currentStreak: 5, highestStreak: 7, @@ -94,7 +94,7 @@ describe("statsService.getStats — 결석 lazy 보정", () => { it("gap에 실제 경기일이 없으면(전부 휴장) streak 유지", async () => { const today = todayKst(); - const threeDaysAgo = addDaysKst(today, -3); + const threeDaysAgo = addDays(today, -3); await seedUser({ currentStreak: 5, highestStreak: 7, @@ -109,7 +109,7 @@ describe("statsService.getStats — 결석 lazy 보정", () => { it("lastJudgedDate가 어제(D-1)면 streak 유지", async () => { const today = todayKst(); - const yesterday = addDaysKst(today, -1); + const yesterday = addDays(today, -1); await seedUser({ currentStreak: 5, highestStreak: 5, @@ -123,8 +123,8 @@ describe("statsService.getStats — 결석 lazy 보정", () => { it("lastJudgedDate가 D-2여도 어제분 userVotes가 남아 있으면 archive 대기로 보고 streak 보호", async () => { const today = todayKst(); - const yesterday = addDaysKst(today, -1); - const twoDaysAgo = addDaysKst(today, -2); + const yesterday = addDays(today, -1); + const twoDaysAgo = addDays(today, -2); await seedUser({ currentStreak: 5, highestStreak: 5, @@ -142,8 +142,8 @@ describe("statsService.getStats — 결석 lazy 보정", () => { it("어제 경기가 있었는데 userVotes 비어 있으면(진짜 결석) streak 0", async () => { const today = todayKst(); - const yesterday = addDaysKst(today, -1); - const twoDaysAgo = addDaysKst(today, -2); + const yesterday = addDays(today, -1); + const twoDaysAgo = addDays(today, -2); await seedUser({ currentStreak: 5, highestStreak: 5, @@ -207,7 +207,7 @@ describe("statsService.getStats — 캐시 forDate 검증", () => { it("캐시 forDate가 어제면 무효화하고 재계산한다", async () => { const today = todayKst(); - const yesterday = addDaysKst(today, -1); + const yesterday = addDays(today, -1); await seedUser({ currentStreak: 3, lastJudgedDate: yesterday }); // 어제 날짜의 stale 캐시를 심어둔다. await seedCache("current", {