장단콩 보드게임 & 미니어처 게임 클럽

통계 페이지 설계

날짜: 2026-06-13
스택: Jekyll (GitHub Pages) + Firebase Firestore + Chart.js (CDN) + Vanilla JS
접근 권한: isAdmin: true 계정만


1. 전체 아키텍처

Firestore
├── posts/{postId}           ← 기존 (변경 없음)
├── users/{uid}              ← 기존 (변경 없음)
├── stats/global             ← 신규: 전체 누적 통계 단일 문서
└── monthly_stats/{YYYY-MM}  ← 신규: 월별 스냅샷 문서

Jekyll
└── /stats/                  ← 신규 페이지 (isAdmin만 접근)

JS
├── assets/js/stats.js       ← 신규: 통계 페이지 전용
├── write.js                 ← 수정: 게시글 등록 시 stats 업데이트
└── post.js                  ← 수정: 참석 신청/취소 시 stats 업데이트

데이터 흐름:


2. Firestore 데이터 구조

stats/global

{
  updatedAt: timestamp,
  members: {
    "{uid}": {
      nickname: string,
      attendCount: number,   // 참석 신청 누적 (취소 시 감소, 0 미만 불가)
      postCount: number      // 일정 등록 누적
    }
  }
}

monthly_stats/{YYYY-MM}

{
  savedAt: timestamp,
  savedBy: string,           // 저장한 운영자 nickname
  members: {
    "{uid}": {
      nickname: string,
      attendCount: number,
      postCount: number
    }
  }
}

설계 결정:


3. Firestore 보안 규칙 추가

match /stats/{docId} {
  allow read: if isAdmin();
  allow write: if isAdmin();
}

match /monthly_stats/{docId} {
  allow read: if isAdmin();
  allow write: if isAdmin();
}

4. 통계 페이지 UI (/stats/)

상단 — 기간 필터

차트 섹션 (Chart.js, 상위 10위만 표시)

차트 1 — 참석 횟수 랭킹 (가로 막대 차트)

차트 2 — 일정 등록 횟수 랭킹 (가로 막대 차트)

차트 3 — 월별 일정 수 추이 (세로 막대 차트)

재미있는 부문 (텍스트 카드, 상위 1인)

부문 설명
🏆 개근왕 attendCount 1위
📅 일정 메이커 postCount 1위
⚡ 만석 달성왕 본인이 등록한 일정 중 만석(attendees.length == maxAttendees) 달성 횟수 1위
🎯 참석률왕 본인이 등록한 일정의 만석 달성률(만석 일정 수 / 전체 등록 일정 수) 1위, 최소 1개 이상 등록한 사람 중

만석 달성왕/참석률왕은 posts 컬렉션 실시간 집계. 기간 필터와 무관하게 항상 전체 기간 기준으로 표시.

하단 — 운영자 전용 액션

버튼 동작
이번 달 통계 저장 stats/globalmonthly_stats/YYYY-MM으로 복사, 이미 있으면 덮어씀
통계 초기화 posts 전체 순회 → attendees 배열 기반으로 stats/global 재구성

과거 월별 스냅샷 목록


5. write.js / post.js 수정 사항

write.js — 게시글 등록 후

// type === "event" 등록 시에만 postCount +1
await updateDoc(doc(db, "stats", "global"), {
  [`members.${user.uid}.nickname`]: currentUserData.nickname,
  [`members.${user.uid}.postCount`]: increment(1)
});

post.js — 참석 신청 시

await updateDoc(doc(db, "stats", "global"), {
  [`members.${currentUser.uid}.nickname`]: currentUserData.nickname,
  [`members.${currentUser.uid}.attendCount`]: increment(1)
});

post.js — 참석 취소 시

// attendCount가 0보다 클 때만 감소
const statsSnap = await getDoc(doc(db, "stats", "global"));
const current = statsSnap.data()?.members?.[currentUser.uid]?.attendCount || 0;
if (current > 0) {
  await updateDoc(doc(db, "stats", "global"), {
    [`members.${currentUser.uid}.attendCount`]: increment(-1)
  });
}