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

시스템 창립기념일 일정 자동 생성 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 시스템이 매년 5월 23일에 참가 가능한 장단콩 클럽 기념일 게임 일정을 자동 생성하고, 게시판에 시스템 작성자로 표시한다.

Architecture: Cloud Scheduler가 매일 자정(Asia/Seoul)에 Firestore의 고정 문서 ID를 확인해, 5월 23일 이후 누락된 해당 연도 일정만 생성한다. 클라이언트는 기념일 일정을 일반 일정처럼 표시하되, 통계 재계산과 삭제는 isAnniversary를 기준으로 작성자 등록 횟수만 제외한다.

Tech Stack: Firebase Functions v2 Scheduler, Firebase Admin Firestore, Firebase Web SDK, Node.js built-in test runner, ES modules.

Global Constraints


File Structure

Task 1: 자동 생성 규칙과 Cloud Scheduler

Files:

Interfaces:

const { test } = require("node:test");
const assert = require("node:assert");
const { shouldCreateAnniversary, anniversaryPostId, buildAnniversaryPost } = require("./anniversary.js");

test("shouldCreateAnniversary: 5월 22일 전에는 false이고 5월 23일부터 true", () => {
  assert.strictEqual(shouldCreateAnniversary({ year: 2026, month: 5, day: 22 }), false);
  assert.strictEqual(shouldCreateAnniversary({ year: 2026, month: 5, day: 23 }), true);
  assert.strictEqual(shouldCreateAnniversary({ year: 2026, month: 7, day: 27 }), true);
});

test("buildAnniversaryPost: 시스템 작성자·20명 정원·3주년 제목을 만든다", () => {
  const eventDate = new Date("2026-05-22T15:00:00.000Z");
  assert.deepStrictEqual(buildAnniversaryPost(2026, eventDate), {
    type: "event", isAnniversary: true, authorUid: "system", authorName: "시스템",
    title: "장단콩 클럽 3주년 기념일 🎉", content: "", eventDate,
    maxAttendees: 20, attendees: [], games: []
  });
  assert.strictEqual(anniversaryPostId(2026), "anniversary-2026");
});

Run: node --test functions/anniversary.test.js

Expected: FAIL with Cannot find module './anniversary.js'.

const FOUNDATION_YEAR = 2023;
const ANNIVERSARY_MONTH = 5;
const ANNIVERSARY_DAY = 23;

function shouldCreateAnniversary({ month, day }) {
  return month > ANNIVERSARY_MONTH || (month === ANNIVERSARY_MONTH && day >= ANNIVERSARY_DAY);
}

function anniversaryPostId(year) { return `anniversary-${year}`; }

function buildAnniversaryPost(year, eventDate) {
  return {
    type: "event", isAnniversary: true, authorUid: "system", authorName: "시스템",
    title: `장단콩 클럽 ${year - FOUNDATION_YEAR}주년 기념일 🎉`, content: "", eventDate,
    maxAttendees: 20, attendees: [], games: []
  };
}

Implement getSeoulDateParts with Intl.DateTimeFormat(..., { timeZone: "Asia/Seoul" }).formatToParts(now); it must return numeric year, month, and day values.

Run: node --test functions/anniversary.test.js

Expected: PASS with 2 tests.

Import onSchedule from firebase-functions/v2/scheduler, Timestamp from firebase-admin/firestore, and helpers from ./anniversary.js. Add this function to functions/index.js:

exports.ensureAnniversarySchedule = onSchedule(
  { schedule: "0 0 * * *", timeZone: "Asia/Seoul" },
  async () => {
    const parts = getSeoulDateParts(new Date());
    if (!shouldCreateAnniversary(parts)) return;
    const db = getFirestore();
    const ref = db.collection("posts").doc(anniversaryPostId(parts.year));
    const eventDate = Timestamp.fromDate(new Date(Date.UTC(parts.year, 4, 23, 14, 59)));
    await db.runTransaction(async (tx) => {
      if ((await tx.get(ref)).exists) return;
      tx.create(ref, {
        ...buildAnniversaryPost(parts.year, eventDate),
        createdAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp()
      });
    });
  }
);

Run: cd functions && node --test *.test.js

Expected: PASS with no failing test.

git add functions/index.js functions/anniversary.js functions/anniversary.test.js
git commit -m "feat: schedule anniversary event creation"

Task 2: 표시와 시스템 작성자 처리

Files:

Interfaces:

import assert from "node:assert";
import { test } from "node:test";
import { displayEvents } from "./board-logic.js";

test("displayEvents: 창립기념일 일정을 게시판에 포함한다", () => {
  const anniversary = { id: "anniversary-2026", type: "event", isAnniversary: true };
  assert.deepStrictEqual(displayEvents([anniversary]), [anniversary]);
});

Run: node --test assets/js/board-logic.test.mjs

Expected: FAIL because the old implementation excludes isAnniversary.

// assets/js/board-logic.js
export function displayEvents(events) { return events; }

// assets/js/board.js
import { displayEvents } from "./board-logic.js";
const events = displayEvents(snap.docs.map(d => ({ id: d.id, ...d.data() })));

Do not filter on isAnniversary in loadEvents.

Replace author resolution with:

const authorDoc = await getUserDoc(postData.authorUid);
const authorName = postData.authorName || authorDoc?.nickname || "알 수 없음";

This preserves ordinary user posts while rendering automatic posts as 시스템.

Run: node --test assets/js/*.test.mjs

Expected: PASS with the new calendar test and all existing tests.

git add assets/js/board.js assets/js/board-logic.js assets/js/board-logic.test.mjs assets/js/post.js
git commit -m "feat: show system anniversary events"

Task 3: Exclude only registration counts from anniversary statistics

Files:

Interfaces:

import { shouldAdjustEventPostCount } from "./event-deletion-logic.js";

test("shouldAdjustEventPostCount: 창립기념일은 시스템 작성자여도 false", () => {
  assert.strictEqual(
    shouldAdjustEventPostCount({ type: "event", isAnniversary: true, authorUid: "system" }),
    false
  );
});

test("shouldAdjustEventPostCount: 일반 게임 일정은 true", () => {
  assert.strictEqual(shouldAdjustEventPostCount({ type: "event", authorUid: "u1" }), true);
});

Run: node --test assets/js/event-deletion-logic.test.mjs

Expected: FAIL because shouldAdjustEventPostCount is not exported.

export function shouldAdjustEventPostCount(post) {
  return post?.type === "event" && post.isAnniversary !== true && !!post.authorUid;
}

In deletePostWithEventStats, replace the event/author condition with shouldAdjustEventPostCount(latestPost).

Replace the rebuild loop body with logic that increments the author only when !post.isAnniversary, then always iterates (post.attendees || []) to increment each attendee’s attendCount. Initialize rebuilt[uid] with { nickname: nicknameMap[uid] || "", attendCount: 0, postCount: 0 } before every increment.

Run: node --test assets/js/event-deletion-logic.test.mjs && node --test assets/js/*.test.mjs

Expected: PASS; the anniversary case leaves postCount untouched.

git add assets/js/event-deletion-logic.js assets/js/event-deletion-logic.test.mjs assets/js/post.js assets/js/stats.js
git commit -m "fix: exclude anniversary events from post counts"

Task 4: Full verification and release

Files:

Interfaces:

Run: cd functions && node --test *.test.js && cd .. && node --test assets/js/*.test.mjs

Expected: PASS with zero failures.

Run: git diff --check && git status --short

Expected: no whitespace errors; only the intended files are modified or staged.

Run: firebase deploy --only functions followed by the repository’s normal GitHub Pages publish workflow.

Expected: Scheduler deployment reports ensureAnniversarySchedule with Asia/Seoul; the deployed board script no longer filters isAnniversary.

Open /board/, navigate to May of the current year, and confirm the 장단콩 클럽 N주년 기념일 🎉 row appears. Open its detail page to confirm 시스템, empty body, 20-person capacity, and a visible participant sign-up action.