라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응

This commit is contained in:
Sn-Kinos
2026-10-05 22:38:42 +09:00
parent 000c058097
commit 4bb92c2ee7
44 changed files with 727 additions and 78 deletions
@@ -0,0 +1,58 @@
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());
},
);