Consolidate KST date helpers and fix tuesdayOf timezone leak

- dayOfWeekKst → dayOfWeek, addDaysKst → addDays: 결과가 타임존과 무관한 헬퍼라 이름에서 KST 제거(요일·달력 산술은 TZ 불변)
- tuesdayOf: 로컬 Date 생성(new Date(y,m-1,d-offset))을 제거하고 dayOfWeek·addDays 기반으로 재작성 — 런타임 TZ에 따라 하루 어긋나던 잠재 버그 수정(UTC+14에서 검증)
- 호출처(attendance·dailyArchive·chat·judgment·stats 및 테스트) 일괄 갱신
This commit is contained in:
윤정민 2026-06-27 00:20:35 +09:00
parent 8be33be10f
commit 6d78ef5c7a
8 changed files with 36 additions and 36 deletions

View File

@ -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<ReserveOu
/** KST 자정 리셋 시각(다음 날 00:00 KST, ISO 8601). */
export function kstResetAt(date: DateString): string {
return `${addDaysKst(date, 1)}T00:00:00+09:00`;
return `${addDays(date, 1)}T00:00:00+09:00`;
}
// ── 차감 복원(§3.1 복원 규칙) ──

View File

@ -14,7 +14,7 @@ import { deleteUserVoteGame } from "../repositories/voteRepository";
import { processGameEndWithGame } from "../services/gameResultService";
import type { RankSnapshot, VoteHistoryDoc } from "../types/panit";
import {
dayOfWeekKst,
dayOfWeek,
daysAgoKst,
type DateString,
} from "../types/dateString";
@ -155,7 +155,7 @@ export async function runDailyArchive(
}
// 일요일(dow=0) 아카이브/판정이 끝난 시점에 주간 마스터 티켓 지급 판단.
if (dayOfWeekKst(date) === 0) {
if (dayOfWeek(date) === 0) {
for (const uid of judgedUids) {
try {
await judgeWeekIfNeeded(uid, date);

View File

@ -11,7 +11,7 @@ import {
type PointAward,
} from "../types/panit";
import {
dayOfWeekKst,
dayOfWeek,
isMonthString,
lastDayOfMonth,
monthOf,
@ -190,7 +190,7 @@ export async function checkIn(
// 2) weekly_bonus: 출석일이 일요일 + 그 주 월~토 모두 출석
if (
dayOfWeekKst(today) === 0 &&
dayOfWeek(today) === 0 &&
isPriorSixDaysAttended(newDays, todayDay)
) {
runningBalance += WEEKLY_BONUS_AMOUNT;

View File

@ -14,7 +14,7 @@ import type { PlayerRecord } from "../kbo/player/common";
import type { TeamRank } from "../kbo/team-rank";
import type { ScheduleGame } from "../types/kbo";
import { TeamCode, type AttendanceMonthDoc, type VoteHistoryDoc } from "../types/panit";
import { addDaysKst, type DateString } from "../types/dateString";
import { addDays, type DateString } from "../types/dateString";
/**
* . "🔍 순위 조회" .
@ -81,10 +81,10 @@ export function resolveToolDate(raw: unknown, today: DateString): DateString | n
if (typeof raw !== "string" || raw.trim() === "") return today;
const s = raw.trim().toLowerCase();
if (s === "today" || s === "오늘") return today;
if (s === "yesterday" || s === "어제") return addDaysKst(today, -1);
if (s === "그저께" || s === "그제" || s === "엊그제") return addDaysKst(today, -2);
if (s === "tomorrow" || s === "내일") return addDaysKst(today, 1);
if (s === "모레") return addDaysKst(today, 2);
if (s === "yesterday" || s === "어제") return addDays(today, -1);
if (s === "그저께" || s === "그제" || s === "엊그제") return addDays(today, -2);
if (s === "tomorrow" || s === "내일") return addDays(today, 1);
if (s === "모레") return addDays(today, 2);
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s as DateString;
return null;
}
@ -113,7 +113,7 @@ export function resolveRequestedDate(
if (typeof off === "number" && Number.isFinite(off)) {
const n = Math.trunc(off);
if (Math.abs(n) > MAX_DAY_OFFSET) return null;
return addDaysKst(today, n);
return addDays(today, n);
}
return hasDateStr ? null : today;
}

View File

@ -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<boolean> {
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;

View File

@ -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<StatsResponse>
// 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();

View File

@ -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));
}

View File

@ -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", {