Files
hackerthon-vote/docs/superpowers/plans/2026-04-27-hackathon-event-flow.md
th-kim0823 d70746cf6a docs: implementation plan — 해커톤 진행 앱 (19 tasks)
TDD bite-sized: deps → schema → helpers → seed → QR/URL → voter gate
→ show dispatcher → 3 stages → admin stage/topics/url → docs → e2e → smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:30:47 +09:00

42 KiB
Raw Blame History

Hackathon Event Flow 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: 투표 앱을 해커톤 전체 진행 앱으로 확장. 큰 화면 stage (intro → topics → vote) + 모바일 QR 투표 + 어드민 stage/topics 컨트롤.

Architecture: 기존 app.py (Streamlit) 확장. hackathon.jsontopics + settings.current_stage 키 추가. 큰 화면 default / 라우트가 current_stage 따라 dispatch. QR은 qrcode[pil], 진행률 폴링은 streamlit-autorefresh. 어드민 콘솔에 stage/topics 두 section 추가.

Tech Stack: Python 3.12, Streamlit, Docker. 신규 의존성: qrcode[pil], streamlit-autorefresh.

Spec: docs/superpowers/specs/2026-04-27-hackathon-event-flow-design.md


File Structure

파일 변경 종류 책임
requirements.txt modify 의존성 2개 추가
app.py modify Stage 라우트 + 헬퍼 + 신규 admin section. ~750 → ~1100 LOC.
assign_teams.py modify DEFAULT_TOPICS_SEED 상수 + ensure_topics_seeded() 호출
tests/e2e.py modify 4 시나리오 추가 (stage 시드, stage 전환, gate, topics atomic)
show-urls.sh modify show + vote URL 추가
README.md modify 새 흐름 설명

app.py는 크지만 단일 파일 패턴 유지 (기존 코드베이스 컨벤션). 함수 분리로 가독성 확보.


Task 1: 의존성 추가 + 컨테이너 재빌드 검증

Files:

  • Modify: requirements.txt

  • Step 1: requirements.txt 갱신

기존 파일 읽고 qrcode[pil], streamlit-autorefresh 추가.

streamlit
qrcode[pil]
streamlit-autorefresh
  • Step 2: 컨테이너 재빌드
docker compose down
docker compose up -d --build
  • Step 3: import 검증
docker exec hackathon-vote python3 -c "import qrcode; from streamlit_autorefresh import st_autorefresh; print('OK')"

Expected: OK 출력.

  • Step 4: 기존 라우트 회귀 확인
curl -s -o /dev/null -w "vote=%{http_code} admin=%{http_code} raw=%{http_code}\n" \
  http://localhost:8501/ \
  "http://localhost:8501/?mode=admin&token=mlops2026" \
  "http://localhost:8501/?mode=raw&token=mlops2026"

Expected: vote=200 admin=200 raw=200.

  • Step 5: Commit
git add requirements.txt
git commit -m "deps: qrcode[pil] + streamlit-autorefresh"

Task 2: _empty_state 확장 (스키마 갱신)

Files:

  • Modify: app.py_empty_state 함수

  • Test: tests/e2e.py — schema test

  • Step 1: 실패하는 테스트 추가

tests/e2e.py 끝에 추가:

def test_empty_state_has_topics_and_stage():
    from app import _empty_state
    s = _empty_state()
    assert s["settings"]["current_stage"] == "intro"
    assert s["topics"] == {"categories": []}

run("empty_state 신규 키", test_empty_state_has_topics_and_stage)

(run()은 기존 e2e.py의 헬퍼 — 같은 패턴 따름.)

  • Step 2: 테스트 실패 확인
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py

Expected: ❌ empty_state 신규 키 (assertion 실패).

  • Step 3: _empty_state 갱신

app.py_empty_state 함수를 다음으로 교체:

def _empty_state():
    return {
        "people": [],
        "settings": {"voting_open": True, "current_stage": "intro"},
        "titles": {},
        "tie_breaks": {},
        "votes": [],
        "topics": {"categories": []},
    }
  • Step 4: 테스트 통과 확인
docker cp app.py hackathon-vote:/app/app.py
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py

Expected: ✅ empty_state 신규 키 포함, 전체 통과.

  • Step 5: Commit
git add app.py tests/e2e.py
git commit -m "feat: _empty_state — current_stage + topics 키 추가"

Task 3: Stage 헬퍼 (get_stage, set_stage, can_accept_votes)

Files:

  • Modify: app.py — 신규 함수 3개

  • Test: tests/e2e.py

  • Step 1: 실패 테스트 작성

tests/e2e.py에 추가:

def test_stage_helpers():
    from app import get_stage, set_stage, can_accept_votes, load_data, save_data, _empty_state
    save_data(_empty_state())

    assert get_stage() == "intro"
    assert can_accept_votes(load_data()) is False  # intro → False

    set_stage("vote")
    d = load_data()
    assert d["settings"]["current_stage"] == "vote"
    assert d["settings"]["voting_open"] is True
    assert can_accept_votes(d) is True

    set_stage("topics")
    d = load_data()
    assert d["settings"]["current_stage"] == "topics"
    assert d["settings"]["voting_open"] is True  # 떠나도 voting_open 유지
    assert can_accept_votes(d) is False  # stage 다르면 False

    # 명시적 마감 후엔 vote 다시 들어와도 voting_open 자동 True (set_stage가 set)
    d["settings"]["voting_open"] = False
    save_data(d)
    set_stage("vote")
    assert can_accept_votes(load_data()) is True

run("stage 헬퍼", test_stage_helpers)
  • Step 2: 테스트 실패 확인
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py

Expected: ImportError 또는 AttributeError (함수 미정의).

  • Step 3: 헬퍼 추가 (app.py)

set_voting_open 함수 아래에 추가:

VALID_STAGES = ("intro", "topics", "vote")


def get_stage():
    return load_data().get("settings", {}).get("current_stage", "intro")


def set_stage(stage):
    if stage not in VALID_STAGES:
        raise ValueError(f"invalid stage: {stage!r}")

    def _fn(data):
        data["settings"]["current_stage"] = stage
        if stage == "vote":
            data["settings"]["voting_open"] = True
    update_data(_fn)


def can_accept_votes(data):
    s = data.get("settings", {})
    return s.get("current_stage") == "vote" and s.get("voting_open", False)
  • Step 4: 테스트 통과 확인
docker cp app.py hackathon-vote:/app/app.py
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py

Expected: ✅ stage 헬퍼 포함.

  • Step 5: Commit
git add app.py tests/e2e.py
git commit -m "feat: stage 헬퍼 (get_stage, set_stage, can_accept_votes)"

Task 4: Topics 헬퍼 (get_topics, update_topics)

Files:

  • Modify: app.py

  • Test: tests/e2e.py

  • Step 1: 실패 테스트 작성

def test_topics_helpers():
    from app import get_topics, update_topics, load_data, save_data, _empty_state
    save_data(_empty_state())

    assert get_topics() == []

    sample = [
        {"id": "T1", "title": "테스트", "tagline": "tl", "tone": "tn",
         "items": [f"item{i}" for i in range(10)]}
    ]
    update_topics(sample)
    assert get_topics() == sample

    # atomic 갱신 확인
    sample2 = [{"id": "T1", "title": "교체", "tagline": "tl", "tone": "tn",
                "items": [f"x{i}" for i in range(10)]}]
    update_topics(sample2)
    assert get_topics() == sample2

run("topics 헬퍼", test_topics_helpers)
  • Step 2: 실패 확인
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py

Expected: 함수 미정의 에러.

  • Step 3: 헬퍼 추가 (app.py)

set_voting_open 부근에:

def get_topics():
    return load_data().get("topics", {}).get("categories", [])


def update_topics(categories):
    def _fn(data):
        data.setdefault("topics", {})
        data["topics"]["categories"] = categories
    update_data(_fn)
  • Step 4: 통과 확인
docker cp app.py hackathon-vote:/app/app.py
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py
  • Step 5: Commit
git add app.py tests/e2e.py
git commit -m "feat: topics 헬퍼 (get_topics, update_topics)"

Task 5: Topics 시드 (assign_teams.py)

Files:

  • Modify: assign_teams.py

  • Test: tests/e2e.py

  • Step 1: 실패 테스트 작성

tests/e2e.py에 추가:

def test_topics_seeded_after_assign():
    from app import get_topics, _empty_state, save_data
    # 빈 상태로 reset
    save_data(_empty_state())
    assert get_topics() == []

    # assign_teams 의 ensure_topics_seeded 적용
    from assign_teams import ensure_topics_seeded
    from app import load_data
    data = load_data()
    ensure_topics_seeded(data)
    save_data(data)

    cats = get_topics()
    assert len(cats) == 4
    for c in cats:
        assert len(c["items"]) == 10
        assert c["id"] in ("T1", "T2", "T3", "T4")
        assert c["title"]
        assert c["tagline"]
        assert c["tone"]

run("topics 시드", test_topics_seeded_after_assign)
  • Step 2: 실패 확인

ensure_topics_seeded 미존재 → ImportError.

  • Step 3: assign_teams.py 갱신

파일 상단 (import 다음)에 상수 추가:

DEFAULT_TOPICS_SEED = [
    {
        "id": "T1",
        "title": "내 인생 시간 도둑 처단기",
        "tagline": "매일 짜증나는 반복 작업 하나를 2시간 안에 박살내기.",
        "tone": "실용 + 살짝 치트키",
        "items": [
            "Slack 멘션 자동 분류/요약기 — '진짜 날 부른 거' vs 'FYI' 분리",
            "Jira 티켓 1줄 자동 요약 + 다음 액션 제안기",
            "회의 캘린더 → 하루 시작 브리핑 (\"오늘 3개 있고 2개는 안 가도 됨\")",
            "PR 리뷰 우선순위 큐 (크기/긴급도/차단여부 기반)",
            "반복 쿼리/kubectl 명령 매크로 CLI — 자주 치는 10개를 1글자로",
            "온콜 노이즈 필터 — 진짜 볼 알람 vs 무시해도 되는 알람",
            "\"이번 주 내 활동 자동 요약\" — PR/티켓/리뷰 통합 리포트",
            "Grafana 자주 보는 패널 즐겨찾기 통합 뷰",
            "Slack 스레드 장문 요약기 — 놓친 채널 따라잡기용",
            "\"이 회의 들어가야 함?\" 분류기 — 캘린더 제목·참석자 기반 추천",
        ],
    },
    {
        "id": "T2",
        "title": "벼르던 사이드 프로젝트",
        "tagline": "평소 \"저거 하나 만들고 싶은데\" 하던 개인 토이를 2시간 안에 작은 완성품으로.",
        "tone": "몰입 + 작은 완결성",
        "items": [
            "내 PR/커밋 패턴 분석 개인 대시보드 (시간대/요일/사이즈 분포)",
            "팀 Wiki/Notion을 터미널에서 fzf 스타일로 검색하는 CLI",
            "사내 모델/데이터셋 메타데이터 검색 프로토타입",
            "git 히스토리 인터랙티브 시각화 뷰어",
            "\"오늘 내가 한 일\" 자동 일기 생성기 (커밋/PR/티켓 통합)",
            "PR 코멘트 감정/톤 분석으로 팀 리뷰 문화 리포트",
            "로컬 Kubernetes 리소스 관계 그래프 실시간 시각화",
            "사내 논문/테크 문서 RAG 검색 도구",
            "터미널에서 차트 포함된 마크다운 뷰어",
            "북마크/링크 자동 분류·태깅 개인 도구",
        ],
    },
    {
        "id": "T3",
        "title": "오버엔지니어링 선수권",
        "tagline": "평소 안 써본 무거운 기술 패턴을 일부러 작은 문제에 적용해 배우기.",
        "tone": "학습형 오버엔지니어링",
        "items": [
            "Todo 앱에 이벤트 소싱 + CQRS 제대로 적용",
            "간단한 계산기 서비스에 OpenTelemetry 풀 트레이싱 구축",
            "문서 검색 기능에 벡터 DB + 하이브리드 검색 (BM25 + 임베딩)",
            "파일 업로드에 S3 presigned URL + 체크섬 검증 + 재시도 로직 정식 설계",
            "팀 투표 기능을 Raft 합의 알고리즘으로 구현",
            "회의실 예약을 Kafka 이벤트 스트리밍 기반으로",
            "로컬 개발 환경을 완전한 K8s 매니페스트 (Deployment/Service/Ingress/HPA)로 재현",
            "LLM + RAG 기반 PR 자동 리뷰 봇 아키텍처 설계·구현",
            "멀티 에이전트 협업(프롬프트 2~3단계)으로 간단한 의사결정 시스템",
            "사이드카 패턴으로 로깅/메트릭/인증 분리 데모",
        ],
    },
    {
        "id": "T4",
        "title": "팀에게 주는 작은 선물",
        "tagline": "동료를 돕는 도구/봇/사이트. 특정인을 놀리는 게 아니라 팀 전체를 위한 것.",
        "tone": "실질적 도움 + 가벼운 온기",
        "items": [
            "배포 상태 집계·알림 봇 (성공/실패/롤백 요약)",
            "신입 한 주 서바이벌 가이드 자동 생성기 (온보딩 링크/문서 수집)",
            "팀 내부 용어/약어 사전 봇 — 신입/리서처 친화",
            "아침 브리핑 봇 — 오늘 회의/배포/만료 알람 한방",
            "점심 투표 1분 컷 봇 — 선택지 자동 생성 후 이모지 투표",
            "팀 반복 질문 FAQ 봇 — 같은 질문 반복되는 채널용",
            "온콜 교대 시 인수인계 자동 요약 생성기",
            "회의실 스마트 추천 — 인원/시간대/위치 기반",
            "사내 서비스 변경사항 요약 구독 봇",
            "\"이번 주 팀 지표 한 장\" 리포트 — 머지 PR, 해결 티켓, 배포 수",
        ],
    },
]


def ensure_topics_seeded(data):
    """topics 비어있으면 default 시드. 기존 있으면 보존."""
    cats = data.get("topics", {}).get("categories", [])
    if not cats:
        data.setdefault("topics", {})
        data["topics"]["categories"] = DEFAULT_TOPICS_SEED

main() 함수의 data["people"] = people_records 직후에 호출 한 줄 추가:

    data["people"] = people_records
    ensure_topics_seeded(data)  # 신규
  • Step 4: 통과 확인
docker cp assign_teams.py hackathon-vote:/app/assign_teams.py
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py
  • Step 5: Commit
git add assign_teams.py tests/e2e.py
git commit -m "feat: DEFAULT_TOPICS_SEED + ensure_topics_seeded"

Task 6: QR 헬퍼 (make_qr_png) + Vote URL resolver

Files:

  • Modify: app.py

  • Test: tests/e2e.py

  • Step 1: 실패 테스트

def test_make_qr_png():
    from app import make_qr_png
    png = make_qr_png("http://localhost:8501/?mode=vote")
    # PNG signature 8 bytes
    assert png[:8] == b"\x89PNG\r\n\x1a\n"
    assert len(png) > 100

def test_compute_vote_url_priority():
    import os
    from app import compute_vote_url, save_data, load_data, _empty_state
    save_data(_empty_state())

    # 1. settings.public_base_url 우선
    d = load_data()
    d["settings"]["public_base_url"] = "http://example.com:9000"
    save_data(d)
    assert compute_vote_url() == "http://example.com:9000/?mode=vote"

    # 2. env fallback
    d["settings"].pop("public_base_url")
    save_data(d)
    os.environ["PUBLIC_BASE_URL"] = "http://env-host:7777"
    assert compute_vote_url() == "http://env-host:7777/?mode=vote"
    os.environ.pop("PUBLIC_BASE_URL")

    # 3. localhost fallback (LAN 자동 감지 실패 또는 디폴트)
    url = compute_vote_url()
    assert url.endswith("/?mode=vote")
    assert url.startswith("http://")

run("QR PNG 생성", test_make_qr_png)
run("vote URL 우선순위", test_compute_vote_url_priority)
  • Step 2: 실패 확인

함수 미정의.

  • Step 3: app.py에 헬퍼 추가

import 추가 (파일 상단):

import socket
from io import BytesIO
import qrcode

함수 추가 (파일 어디든, 예: compute_winners 위):

def make_qr_png(url: str, box_size: int = 20) -> bytes:
    img = qrcode.make(url, box_size=box_size, border=2)
    buf = BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


def _detect_lan_ip() -> str:
    """LAN IP 자동 감지. 실패 시 'localhost'."""
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "localhost"


def compute_vote_url() -> str:
    data = load_data()
    base = (
        data.get("settings", {}).get("public_base_url")
        or os.environ.get("PUBLIC_BASE_URL")
        or f"http://{_detect_lan_ip()}:8501"
    )
    return f"{base.rstrip('/')}/?mode=vote"
  • Step 4: 통과 확인
docker cp app.py hackathon-vote:/app/app.py
docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py
  • Step 5: Commit
git add app.py tests/e2e.py
git commit -m "feat: QR PNG 생성 + vote URL resolver"

Task 7: Voter Stage Gate

Files:

  • Modify: app.pyrender_voter() 진입부

  • Step 1: 기존 render_voter 위치 파악

grep -n "def render_voter" app.py
  • Step 2: 진입 가드 추가

render_voter() 함수 본문 첫 줄에:

def render_voter():
    if not can_accept_votes(load_data()):
        st.title("🗳 해커톤 투표")
        st.info("⏳ 지금은 투표 시간이 아닙니다. 진행자가 투표 stage로 전환할 때까지 기다려주세요.")
        return
    # ... 기존 로직 그대로

기존 is_voting_open() 체크가 함수 안에 있다면 위 가드가 상위 — 중복은 그대로 둠 (수비 깊이).

  • Step 3: 수동 검증
docker cp app.py hackathon-vote:/app/app.py
docker compose restart vote
sleep 3
# stage가 intro인 상태에서 vote URL 진입
curl -s "http://localhost:8501/?mode=vote" | grep -c "투표 시간이 아닙니다" || true

WebSocket 안 거치면 텍스트 안 나올 수 있음 → 브라우저로 직접 확인. HTTP 200이면 OK:

curl -s -o /dev/null -w "vote=%{http_code}\n" "http://localhost:8501/?mode=vote"
  • Step 4: stage=vote 전환 후 정상 진입 확인

Python으로:

docker exec hackathon-vote python3 -c "
import os; os.environ['DATA_PATH']='/app/data/hackathon.json'
import sys; sys.path.insert(0,'/app')
from app import set_stage, can_accept_votes, load_data
set_stage('vote')
print('can_vote:', can_accept_votes(load_data()))
"

Expected: can_vote: True.

  • Step 5: Commit
git add app.py
git commit -m "feat: voter stage gate (current_stage=vote && voting_open)"

Task 8: render_show() Dispatcher + 큰 화면 CSS

Files:

  • Modify: app.py — 신규 함수 + main() 라우팅

  • Step 1: 큰 화면 CSS 상수 추가

CATEGORIES 상수 아래에:

SHOW_CSS = """
<style>
.show-stage-title { font-size: 64px; text-align: center; padding: 16px 0; font-weight: 800; }
.show-stage-sub { font-size: 24px; text-align: center; color: #888; padding-bottom: 24px; }
.show-team-card {
  font-size: 24px;
  padding: 18px 14px;
  border-radius: 14px;
  background: #1a1a2a;
  border: 1px solid #333;
  min-height: 220px;
}
.show-team-name { font-size: 32px; font-weight: 700; text-align: center; margin-bottom: 12px; color: #ffb84d; }
.show-team-member { font-size: 22px; line-height: 1.6; text-align: center; }
.show-info-box {
  font-size: 26px;
  padding: 22px;
  border-radius: 12px;
  background: #1f1f2f;
  margin-top: 24px;
}
.show-cat-card {
  border-radius: 14px;
  padding: 18px;
  min-height: 480px;
}
.show-cat-T1 { background: linear-gradient(135deg, #ffb84d, #ff8c00); color: #222; }
.show-cat-T2 { background: linear-gradient(135deg, #4dffd2, #2a8e7e); color: #1a1a1a; }
.show-cat-T3 { background: linear-gradient(135deg, #ff4d6d, #b83a55); color: white; }
.show-cat-T4 { background: linear-gradient(135deg, #a64dff, #6a2eaf); color: white; }
.show-cat-title { font-size: 32px; font-weight: 800; margin-bottom: 4px; }
.show-cat-tagline { font-size: 16px; font-style: italic; margin-bottom: 4px; }
.show-cat-tone { font-size: 14px; opacity: 0.85; margin-bottom: 12px; }
.show-cat-item { font-size: 17px; line-height: 1.45; padding: 4px 0; }
.show-vote-counter { font-size: 96px; text-align: center; font-weight: 900; padding: 16px 0; }
.show-vote-caption { font-size: 36px; text-align: center; color: #ccc; padding: 12px 0; }
</style>
"""
  • Step 2: dispatcher 함수 추가

render_voter 위 또는 아래에:

def render_show():
    data = load_data()
    st.markdown(SHOW_CSS, unsafe_allow_html=True)
    stage = data.get("settings", {}).get("current_stage", "intro")
    if stage == "topics":
        render_stage_topics(data)
    elif stage == "vote":
        render_stage_vote(data)
    else:
        render_stage_intro(data)


def render_stage_intro(data):
    st.markdown('<div class="show-stage-title">🚀 해커톤</div>', unsafe_allow_html=True)
    st.markdown('<div class="show-stage-sub">팀 편성</div>', unsafe_allow_html=True)
    st.info("Task 9에서 구현")  # placeholder, 다음 태스크에서 교체


def render_stage_topics(data):
    st.markdown('<div class="show-stage-title">💡 예시 주제</div>', unsafe_allow_html=True)
    st.info("Task 10에서 구현")


def render_stage_vote(data):
    st.markdown('<div class="show-stage-title">🗳 투표 시작</div>', unsafe_allow_html=True)
    st.info("Task 11에서 구현")
  • Step 3: main() 라우팅 갱신
def main():
    st.set_page_config(page_title="해커톤", page_icon="🚀", layout="wide")
    mode = st.query_params.get("mode", "show")
    if mode == "admin":
        render_admin()
    elif mode == "ceremony":
        render_ceremony()
    elif mode == "raw":
        render_raw()
    elif mode == "vote":
        render_voter()
    else:
        render_show()

기존 default → render_voter 였음. 이제 default → render_show. ?mode=vote로 voter 진입.

  • Step 4: 라우트 검증
docker cp app.py hackathon-vote:/app/app.py
docker compose restart vote
sleep 3
curl -s -o /dev/null -w "/=%{http_code} vote=%{http_code} admin=%{http_code}\n" \
  http://localhost:8501/ \
  "http://localhost:8501/?mode=vote" \
  "http://localhost:8501/?mode=admin&token=mlops2026"

Expected: 모두 200.

  • Step 5: Commit
git add app.py
git commit -m "feat: render_show dispatcher + 큰 화면 CSS, default '/' 변경"

Task 9: Stage intro — 팀 편성 + 안내

Files:

  • Modify: app.pyrender_stage_intro 본구현

  • Step 1: render_stage_intro 교체

def render_stage_intro(data):
    st.markdown('<div class="show-stage-title">🚀 MLOps 해커톤 2026</div>', unsafe_allow_html=True)
    st.markdown('<div class="show-stage-sub">팀 편성</div>', unsafe_allow_html=True)

    people = data.get("people", [])
    teams = {}
    for p in people:
        teams.setdefault(p["team"], []).append(p["name"])
    team_names = sorted(teams.keys())

    # 4×2 그리드 (7팀 + 1 빈 칸)
    rows = [team_names[i:i + 4] for i in range(0, len(team_names), 4)]
    for row in rows:
        cols = st.columns(4)
        for col, team in zip(cols, row):
            members = teams[team]
            members_html = "<br>".join(members)
            with col:
                st.markdown(
                    f'<div class="show-team-card">'
                    f'  <div class="show-team-name">{team}</div>'
                    f'  <div class="show-team-member">{members_html}</div>'
                    f'</div>',
                    unsafe_allow_html=True,
                )

    st.markdown(
        '<div class="show-info-box">'
        '<b>📋 순서:</b> 팀 편성 → 주제 소개 → 해킹 (2시간) → 발표 → 투표 → 시상'
        '</div>',
        unsafe_allow_html=True,
    )
    st.markdown(
        '<div class="show-info-box">'
        '<b>🏆 시상 부문:</b> 🎉 재미상 · 🏆 완성도상 · 🛠 실용성상 (1팀 1상)'
        '</div>',
        unsafe_allow_html=True,
    )
  • Step 2: 수동 검증
docker cp app.py hackathon-vote:/app/app.py
docker compose restart vote
sleep 3

브라우저: http://localhost:8501/ → 7팀 그리드 + 순서/시상 박스 큰 글씨로 보이는지 시각 확인.

  • Step 3: HTTP 200 회귀
curl -s -o /dev/null -w "show=%{http_code}\n" http://localhost:8501/

Expected: 200.

  • Step 4: Commit
git add app.py
git commit -m "feat: stage intro — 팀편성 4×2 그리드 + 순서/시상 박스"

Task 10: Stage topics — 4 카테고리 × 10 주제

Files:

  • Modify: app.pyrender_stage_topics 본구현

  • Step 1: render_stage_topics 교체

def render_stage_topics(data):
    st.markdown('<div class="show-stage-title">💡 예시 주제</div>', unsafe_allow_html=True)
    st.markdown('<div class="show-stage-sub">영감 얻으세요 — 똑같이 안 만들어도 됩니다</div>', unsafe_allow_html=True)

    cats = data.get("topics", {}).get("categories", [])
    if not cats:
        st.warning("주제가 비어 있습니다. 어드민에서 입력하세요.")
        return

    # 2×2 그리드
    rows = [cats[i:i + 2] for i in range(0, len(cats), 2)]
    for row in rows:
        cols = st.columns(2)
        for col, cat in zip(cols, row):
            cat_id = cat.get("id", "T?")
            items_html = "".join(
                f'<div class="show-cat-item">▸ {item}</div>' for item in cat.get("items", [])
            )
            with col:
                st.markdown(
                    f'<div class="show-cat-card show-cat-{cat_id}">'
                    f'  <div class="show-cat-title">{cat_id}. {cat.get("title", "")}</div>'
                    f'  <div class="show-cat-tagline">{cat.get("tagline", "")}</div>'
                    f'  <div class="show-cat-tone">톤: {cat.get("tone", "")}</div>'
                    f'  {items_html}'
                    f'</div>',
                    unsafe_allow_html=True,
                )
  • Step 2: stage 전환 후 시각 확인
docker cp app.py hackathon-vote:/app/app.py
docker compose restart vote
sleep 3
docker exec hackathon-vote python3 -c "
import os; os.environ['DATA_PATH']='/app/data/hackathon.json'
import sys; sys.path.insert(0,'/app')
from app import set_stage; set_stage('topics')
"

브라우저로 / 진입 → 2×2 카테고리 카드 + 색상 그라디언트 + 10주제 list.

  • Step 3: Commit
git add app.py
git commit -m "feat: stage topics — 2×2 카테고리 그리드 + 10주제 list + 색상"

Task 11: Stage vote — QR + 진행률 + autorefresh

Files:

  • Modify: app.pyrender_stage_vote 본구현 + autorefresh import

  • Step 1: import 추가

app.py import 섹션 끝에:

from streamlit_autorefresh import st_autorefresh
  • Step 2: render_stage_vote 교체
def render_stage_vote(data):
    st_autorefresh(interval=3000, key="vote_poll")

    st.markdown('<div class="show-stage-title">🗳 투표</div>', unsafe_allow_html=True)
    st.markdown(
        '<div class="show-stage-sub">📱 휴대폰으로 QR 스캔 → 본인 이름 선택 → 투표</div>',
        unsafe_allow_html=True,
    )

    vote_url = compute_vote_url()
    qr_png = make_qr_png(vote_url)

    c1, c2, c3 = st.columns([1, 2, 1])
    with c2:
        st.image(qr_png, use_container_width=False, width=500)
        st.markdown(
            f'<div class="show-vote-caption">{vote_url}</div>',
            unsafe_allow_html=True,
        )

    votes = data.get("votes", [])
    total = len(data.get("people", []))
    voted = len(votes)
    pct = int(100 * voted / total) if total else 0
    st.markdown(
        f'<div class="show-vote-counter">{voted} / {total}</div>',
        unsafe_allow_html=True,
    )
    st.progress(pct / 100 if total else 0)
  • Step 3: 검증
docker cp app.py hackathon-vote:/app/app.py
docker compose restart vote
sleep 3
docker exec hackathon-vote python3 -c "
import os; os.environ['DATA_PATH']='/app/data/hackathon.json'
import sys; sys.path.insert(0,'/app')
from app import set_stage; set_stage('vote')
"

브라우저 / → QR + 카운터 보이는지. 모바일에서 QR 스캔 → vote URL 진입 확인 (LAN IP + ?mode=vote).

  • Step 4: HTTP 회귀
curl -s -o /dev/null -w "show=%{http_code} vote=%{http_code}\n" \
  http://localhost:8501/ "http://localhost:8501/?mode=vote"

Expected: 200 / 200.

  • Step 5: Commit
git add app.py
git commit -m "feat: stage vote — QR + 카운터 + autorefresh 3초"

Task 12: Admin — Stage 진행 Section

Files:

  • Modify: app.pyrender_admin() 상단

  • Step 1: stage section 삽입

render_admin() 의 "🔗 다른 페이지 URL" expander 다음에:

    st.divider()
    st.subheader("🎬 Stage 진행")
    cur = get_stage()
    st.markdown(f"**현재 stage:** `{cur}`")

    stage_order = list(VALID_STAGES)  # ("intro", "topics", "vote")
    idx = stage_order.index(cur) if cur in stage_order else 0

    sc1, sc2, sc3 = st.columns(3)
    with sc1:
        if st.button("← 이전 stage", disabled=(idx == 0)):
            set_stage(stage_order[idx - 1])
            st.rerun()
    with sc2:
        chosen = st.radio(
            "직접 선택",
            stage_order,
            index=idx,
            horizontal=True,
            label_visibility="collapsed",
            key="stage_radio",
        )
        if chosen != cur:
            if st.button("적용", key="stage_apply"):
                set_stage(chosen)
                st.rerun()
    with sc3:
        if st.button("다음 stage →", disabled=(idx == len(stage_order) - 1)):
            set_stage(stage_order[idx + 1])
            st.rerun()

    if cur == "vote":
        st.caption(" vote stage 진입 시 투표가 자동 open 됨. 마감은 아래 '투표 마감' 버튼으로.")
  • Step 2: 수동 테스트
docker cp app.py hackathon-vote:/app/app.py
docker compose restart vote
sleep 3

브라우저 ?mode=admin&token=mlops2026 → "Stage 진행" section. 다음 → 클릭 → 큰 화면 (/)이 3초 내 갱신되는지 확인 (autorefresh는 vote stage만 — 그 외는 수동 새로고침 또는 admin이 set 후 큰 화면 새로고침).

  • Step 3: Commit
git add app.py
git commit -m "feat: admin stage 진행 section (이전/직접/다음)"

Task 13: Admin — 주제 편집 (Form Mode)

Files:

  • Modify: app.py

  • Step 1: form section 삽입

stage section 직후에:

    st.divider()
    st.subheader("🗒 주제 편집")
    cur_topics = get_topics()
    if not cur_topics:
        st.warning("주제 시드 비어있음. 컨테이너 재시작 시 시드 자동 적용됨.")
        return_early_topics = True
    else:
        return_early_topics = False

    if not return_early_topics:
        edit_mode = st.radio(
            "편집 모드", ["Form", "JSON 직접 편집"], horizontal=True, key="topics_mode"
        )

        if edit_mode == "Form":
            with st.form("topics_form"):
                new_cats = []
                for cat in cur_topics:
                    cid = cat.get("id", "T?")
                    with st.expander(f"{cid}. {cat.get('title', '')}", expanded=False):
                        title = st.text_input(
                            "title", cat.get("title", ""), key=f"t_{cid}_title"
                        )
                        tagline = st.text_input(
                            "tagline", cat.get("tagline", ""), key=f"t_{cid}_tagline"
                        )
                        tone = st.text_input(
                            "tone", cat.get("tone", ""), key=f"t_{cid}_tone"
                        )
                        items = []
                        for i, item in enumerate(cat.get("items", []) + [""] * (10 - len(cat.get("items", [])))):
                            items.append(
                                st.text_input(
                                    f"주제 {i + 1}",
                                    item,
                                    key=f"t_{cid}_item_{i}",
                                )
                            )
                        items = [x for x in items if x.strip()]
                        new_cats.append(
                            {
                                "id": cid,
                                "title": title.strip(),
                                "tagline": tagline.strip(),
                                "tone": tone.strip(),
                                "items": items,
                            }
                        )
                if st.form_submit_button("주제 저장"):
                    update_topics(new_cats)
                    st.success("저장됨. 큰 화면 다음 갱신 시 반영.")
                    st.rerun()
  • Step 2: 수동 테스트

브라우저 admin → "주제 편집" → 첫 카테고리 expander 열고 title 변경 → 저장 → 큰 화면 (/, stage=topics)에서 변경 반영 확인.

  • Step 3: Commit
git add app.py
git commit -m "feat: admin 주제 편집 — form mode (4 카테고리 expander)"

Task 14: Admin — 주제 편집 (JSON Mode)

Files:

  • Modify: app.py

  • Step 1: JSON mode 분기 추가

Task 13에서 if edit_mode == "Form": 블록 다음에:

        else:  # JSON 직접 편집
            current_json = json.dumps(
                {"categories": cur_topics}, ensure_ascii=False, indent=2
            )
            edited = st.text_area(
                "topics JSON",
                value=current_json,
                height=400,
                key="topics_json_editor",
            )
            jc1, jc2 = st.columns(2)
            with jc1:
                if st.button("JSON 검증"):
                    try:
                        parsed = json.loads(edited)
                        cats = parsed.get("categories", [])
                        if not isinstance(cats, list):
                            st.error("'categories'는 list 여야 합니다.")
                        else:
                            st.success(f"OK — {len(cats)}개 카테고리")
                    except json.JSONDecodeError as e:
                        st.error(f"JSON 파싱 실패: {e}")
            with jc2:
                if st.button("JSON 저장", type="primary"):
                    try:
                        parsed = json.loads(edited)
                        cats = parsed.get("categories", [])
                        if not isinstance(cats, list):
                            st.error("'categories'는 list 여야 합니다.")
                        else:
                            update_topics(cats)
                            st.success("저장됨.")
                            st.rerun()
                    except json.JSONDecodeError as e:
                        st.error(f"저장 실패 — JSON 파싱 에러: {e}")
  • Step 2: 수동 테스트

JSON mode로 전환 → invalid JSON 입력 → 검증 클릭 → 에러 표시 확인. valid JSON 저장 → 반영 확인.

  • Step 3: Commit
git add app.py
git commit -m "feat: admin 주제 편집 — JSON 직접 편집 + 검증"

Task 15: Admin — public_base_url Override

Files:

  • Modify: app.py

  • Step 1: set_public_base_url 헬퍼

set_voting_open 부근에:

def set_public_base_url(url):
    def _fn(data):
        data.setdefault("settings", {})
        if url:
            data["settings"]["public_base_url"] = url.strip()
        else:
            data["settings"].pop("public_base_url", None)
    update_data(_fn)
  • Step 2: admin section에 입력란 추가

stage section 끝(또는 vote 안내 caption 직후)에:

    st.markdown("**📱 모바일 QR target URL**")
    cur_url = compute_vote_url()
    st.caption(f"현재: `{cur_url}`")
    cur_override = load_data().get("settings", {}).get("public_base_url", "")
    new_override = st.text_input(
        "Override (비워두면 자동 감지)",
        value=cur_override,
        placeholder="http://192.168.1.10:8501",
        key="qr_override",
    )
    if st.button("Override 저장"):
        set_public_base_url(new_override)
        st.success("저장됨.")
        st.rerun()
  • Step 3: 검증

admin → URL 입력 → 저장 → vote stage / → QR target URL이 변경된 것 확인.

  • Step 4: Commit
git add app.py
git commit -m "feat: admin public_base_url override (QR target)"

Task 16: show-urls.sh 갱신

Files:

  • Modify: show-urls.sh

  • Step 1: show + vote URL 추가

기존 "참가자 투표" section을 다음으로 교체:

echo "🖥  큰 화면 (발표자):"
echo "   http://localhost:${PORT}/"
[[ -n "$LAN_IP" ]] && echo "   http://${LAN_IP}:${PORT}/  (LAN)"
echo
echo "📱 모바일 투표 (QR target):"
echo "   http://localhost:${PORT}/?mode=vote"
[[ -n "$LAN_IP" ]] && echo "   http://${LAN_IP}:${PORT}/?mode=vote"
  • Step 2: 검증
./show-urls.sh

Expected: 큰 화면 / 어드민 / 시상식 / JSON / 모바일 vote 5개 URL 출력.

  • Step 3: Commit
git add show-urls.sh
git commit -m "docs: show-urls.sh — 큰 화면 + 모바일 vote URL 추가"

Task 17: README 갱신

Files:

  • Modify: README.md

  • Step 1: 흐름 / URL section 갱신

기존 "흐름" section 교체:

## 흐름 (행사 진행)

1. **Stage 1 — 팀 편성 + 안내** (큰 화면 `/`)
2. **Stage 2 — 예시 주제** (큰 화면 `/`, 어드민이 "다음 stage →")
3. **해킹** (앱 외부, 2시간)
4. **발표**
5. **Stage 3 — 투표** (큰 화면에 QR, 모바일 → `/?mode=vote`)
6. **시상** (`/?mode=ceremony&token=mlops2026`)

기존 URL section 갱신:

- 큰 화면: `http://<서버>:8501/`
- 모바일 투표 (QR target): `http://<서버>:8501/?mode=vote`
- 어드민: `http://<서버>:8501/?mode=admin&token=mlops2026`
- 시상식: `http://<서버>:8501/?mode=ceremony&token=mlops2026`
- JSON 원본: `http://<서버>:8501/?mode=raw&token=mlops2026`

기존 데이터 파일 section 끝부분에 추가:

- `topics.categories` 4 카테고리 × 10 items. 어드민에서 form / JSON 둘 다 편집.
- `settings.current_stage` ∈ {"intro","topics","vote"} — 어드민에서 stage 컨트롤.
  • Step 2: Commit
git add README.md
git commit -m "docs: README — 새 흐름 + URL + topics 설명"

Task 18: E2E 추가 시나리오 회귀

Files:

  • (이미 Task 2~6에서 추가됨)

  • Step 1: 모든 e2e 통과 확인

docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py
docker exec hackathon-vote python3 /tmp/e2e.py

Expected: 기존 12 + 신규 (Task 2,3,4,5,6에서 추가) → 총 ≥16. 모두 통과.

  • Step 2: 결과 확인

마지막 줄 # N 중 통과 N, 실패 0 패턴.

  • Step 3: 회귀 시 fix

실패가 있으면 해당 task로 돌아가 fix.


Task 19: 라우트 + 부팅 smoke test

Files: (없음 — 검증만)

  • Step 1: 빈 컨테이너 부팅 검증
docker compose down
rm -rf data
docker compose up -d
sleep 10
docker logs hackathon-vote 2>&1 | tail -20

Expected: [init] 시드 완료 로그 + Streamlit 부팅 메시지.

  • Step 2: 시드 데이터 검증
docker exec hackathon-vote python3 -c "
import json
d = json.load(open('/app/data/hackathon.json'))
assert d['settings']['current_stage'] == 'intro'
assert len(d['topics']['categories']) == 4
for c in d['topics']['categories']:
    assert len(c['items']) == 10
print('seed OK')
"
  • Step 3: 라우트 5개 200 확인
curl -s -o /dev/null -w "show=%{http_code} vote=%{http_code} admin=%{http_code} raw=%{http_code} ceremony=%{http_code}\n" \
  http://localhost:8501/ \
  "http://localhost:8501/?mode=vote" \
  "http://localhost:8501/?mode=admin&token=mlops2026" \
  "http://localhost:8501/?mode=raw&token=mlops2026" \
  "http://localhost:8501/?mode=ceremony&token=mlops2026"

Expected: 모두 200.

  • Step 4: 사용자 시각 검증 (브라우저)

다음 시나리오 직접 클릭/확인:

  1. / → intro: 팀 7개 + 순서/시상 박스 큰 글씨.
  2. admin → "다음 stage →" 클릭 → 새로고침 (/) → topics: 2×2 카테고리, 색상 그라디언트.
  3. 다시 "다음 →" → vote: QR + 카운터 + 진행률 bar. autorefresh 동작 확인 (3초마다 화면 깜빡임 없이 갱신).
  4. 모바일 (또는 동일 LAN 다른 기기)로 QR 스캔 → vote 화면 진입 → 한 번 투표 → 큰 화면 카운터 1 증가 (3초 내).
  5. admin → 주제 편집 form → 한 줄 변경 → topics 화면 변경 반영.
  6. JSON mode → invalid JSON 입력 → 에러 표시 확인.
  • Step 5: Commit (smoke 통과)

(코드 변경 없으면 commit 생략. 있으면 fix 후 commit.)


Self-Review Notes

스펙 매핑 체크 (Spec → Task):

  • 라우트 5개 → Task 8 (dispatcher), Task 7 (gate)
  • Stage 모델 (intro/topics/vote) → Task 3 (helpers), Task 8-11 (rendering)
  • 데이터 스키마 변경 → Task 2, 5
  • 어드민 stage 컨트롤 → Task 12
  • 어드민 주제 편집 (form + JSON) → Task 13, 14
  • public_base_url override → Task 15
  • QR + autorefresh → Task 6, 11
  • 시드 → Task 5
  • 테스트 → Task 2, 3, 4, 5, 6, 18
  • 큰 화면 CSS → Task 8, 9, 10, 11
  • show-urls.sh → Task 16
  • README → Task 17

스펙 모든 요구사항 → task로 매핑됨. 누락 없음.

타입 일관성: categories list, current_stage str ∈ VALID_STAGES, voting_open bool. 함수 시그니처 일관 — get_topics() → list, update_topics(list), set_stage(str), can_accept_votes(data) → bool.