import { onSchedule } from 'firebase-functions/v2/scheduler'; import { logger } from 'firebase-functions'; import { FieldValue, getFirestore } from 'firebase-admin/firestore'; import { rankWithTies } from '../ledger/fpi.js'; /** * Weekly Ranking Snapshot — 별첨 B 「Ranking Snapshot — 주 1 회 Snapshot 영구 Archive」(B-20) · * 「Weekly Ranking — 주간 공식 Snapshot 은 영구 Archive」 · 9/16 EC06 「주간 Snapshot 유지」. * * - 모집단 · 순위는 FPI 랭킹과 같다(별첨 B 「FPI 랭킹」 — 정식 행성 중 최근 7일 유효 활동, 1·1·3 공동 순위). * - **영구 Archive** — `create` 라 한 주에 한 번만 생기고 다시 쓰지 않는다. 규칙도 클라이언트 쓰기를 막는다. * - Ranking Campaign Draft 자동 생성 · Admin 승인 게시는 Campaign 범위(3차)라 여기서 하지 않는다. * * 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A Q-A11): 스냅샷 시각은 자료에 없다. KST 월요일 00:10 * (일일 마감 00:00 뒤)으로 두고, 그 시점의 집계값을 지난주(월~일) 공식 순위로 남긴다. */ export function weekKeyOf(at: Date): string { // KST 기준 ISO 주차. 월요일 00:10 에 돌면 「방금 끝난 주」를 가리키도록 하루 전으로 계산한다. const kst = new Date(at.getTime() + 9 * 3_600_000 - 86_400_000); const day = (kst.getUTCDay() + 6) % 7; const thursday = new Date(Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate() - day + 3)); const firstThursday = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4)); const week = 1 + Math.round(((thursday.getTime() - firstThursday.getTime()) / 86_400_000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7); return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; } export async function snapshotWeeklyRanking(now = new Date()): Promise<{ week: string; created: boolean }> { const db = getFirestore(); const week = weekKeyOf(now); const fandoms = await db.collection('fandoms').where('rankingEligible', '==', true) .select('name', 'fpiScore', 'fpiAxes', 'stage', 'level').get(); const planets = rankWithTies(fandoms.docs, (d) => Number(d.get('fpiScore') ?? 0), (d) => d.id) .map(({ item, rank }) => ({ fandomId: item.id, name: item.get('name') ?? item.id, rank, fpiScore: Number(item.get('fpiScore') ?? 0), fpiAxes: item.get('fpiAxes') ?? {}, stage: item.get('stage') ?? null, level: item.get('level') ?? null, })); try { await db.doc(`rankingSnapshots/${week}`).create({ week, kind: 'weekly_fpi', planets, population: planets.length, createdAt: FieldValue.serverTimestamp(), }); return { week, created: true }; } catch (error) { const code = (error as { code?: number | string }).code; if (code !== 6 && code !== 'already-exists') throw error; return { week, created: false }; } } export const fnWeeklyRankingSnapshot = onSchedule( { schedule: '10 0 * * 1', timeZone: 'Asia/Seoul' }, async () => { logger.info('[ranking] 주간 Snapshot', await snapshotWeeklyRanking()); }, );