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:
parent
8be33be10f
commit
6d78ef5c7a
@ -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 복원 규칙) ──
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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", {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user