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

1423 lines
42 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.json``topics` + `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: 컨테이너 재빌드**
```bash
docker compose down
docker compose up -d --build
```
- [ ] **Step 3: import 검증**
```bash
docker exec hackathon-vote python3 -c "import qrcode; from streamlit_autorefresh import st_autorefresh; print('OK')"
```
Expected: `OK` 출력.
- [ ] **Step 4: 기존 라우트 회귀 확인**
```bash
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**
```bash
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` 끝에 추가:
```python
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: 테스트 실패 확인**
```bash
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` 함수를 다음으로 교체:
```python
def _empty_state():
return {
"people": [],
"settings": {"voting_open": True, "current_stage": "intro"},
"titles": {},
"tie_breaks": {},
"votes": [],
"topics": {"categories": []},
}
```
- [ ] **Step 4: 테스트 통과 확인**
```bash
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**
```bash
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`에 추가:
```python
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: 테스트 실패 확인**
```bash
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` 함수 아래에 추가:
```python
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: 테스트 통과 확인**
```bash
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**
```bash
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: 실패 테스트 작성**
```python
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: 실패 확인**
```bash
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` 부근에:
```python
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: 통과 확인**
```bash
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**
```bash
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`에 추가:
```python
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 다음)에 상수 추가:
```python
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` 직후에 호출 한 줄 추가:
```python
data["people"] = people_records
ensure_topics_seeded(data) # 신규
```
- [ ] **Step 4: 통과 확인**
```bash
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**
```bash
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: 실패 테스트**
```python
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 추가 (파일 상단):
```python
import socket
from io import BytesIO
import qrcode
```
함수 추가 (파일 어디든, 예: `compute_winners` 위):
```python
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: 통과 확인**
```bash
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**
```bash
git add app.py tests/e2e.py
git commit -m "feat: QR PNG 생성 + vote URL resolver"
```
---
## Task 7: Voter Stage Gate
**Files:**
- Modify: `app.py``render_voter()` 진입부
- [ ] **Step 1: 기존 `render_voter` 위치 파악**
```bash
grep -n "def render_voter" app.py
```
- [ ] **Step 2: 진입 가드 추가**
`render_voter()` 함수 본문 첫 줄에:
```python
def render_voter():
if not can_accept_votes(load_data()):
st.title("🗳 해커톤 투표")
st.info("⏳ 지금은 투표 시간이 아닙니다. 진행자가 투표 stage로 전환할 때까지 기다려주세요.")
return
# ... 기존 로직 그대로
```
기존 `is_voting_open()` 체크가 함수 안에 있다면 위 가드가 상위 — 중복은 그대로 둠 (수비 깊이).
- [ ] **Step 3: 수동 검증**
```bash
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:
```bash
curl -s -o /dev/null -w "vote=%{http_code}\n" "http://localhost:8501/?mode=vote"
```
- [ ] **Step 4: stage=vote 전환 후 정상 진입 확인**
Python으로:
```bash
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**
```bash
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` 상수 아래에:
```python
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` 위 또는 아래에:
```python
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()` 라우팅 갱신**
```python
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: 라우트 검증**
```bash
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**
```bash
git add app.py
git commit -m "feat: render_show dispatcher + 큰 화면 CSS, default '/' 변경"
```
---
## Task 9: Stage `intro` — 팀 편성 + 안내
**Files:**
- Modify: `app.py``render_stage_intro` 본구현
- [ ] **Step 1: `render_stage_intro` 교체**
```python
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: 수동 검증**
```bash
docker cp app.py hackathon-vote:/app/app.py
docker compose restart vote
sleep 3
```
브라우저: `http://localhost:8501/` → 7팀 그리드 + 순서/시상 박스 큰 글씨로 보이는지 시각 확인.
- [ ] **Step 3: HTTP 200 회귀**
```bash
curl -s -o /dev/null -w "show=%{http_code}\n" http://localhost:8501/
```
Expected: 200.
- [ ] **Step 4: Commit**
```bash
git add app.py
git commit -m "feat: stage intro — 팀편성 4×2 그리드 + 순서/시상 박스"
```
---
## Task 10: Stage `topics` — 4 카테고리 × 10 주제
**Files:**
- Modify: `app.py``render_stage_topics` 본구현
- [ ] **Step 1: `render_stage_topics` 교체**
```python
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 전환 후 시각 확인**
```bash
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**
```bash
git add app.py
git commit -m "feat: stage topics — 2×2 카테고리 그리드 + 10주제 list + 색상"
```
---
## Task 11: Stage `vote` — QR + 진행률 + autorefresh
**Files:**
- Modify: `app.py``render_stage_vote` 본구현 + autorefresh import
- [ ] **Step 1: import 추가**
`app.py` import 섹션 끝에:
```python
from streamlit_autorefresh import st_autorefresh
```
- [ ] **Step 2: `render_stage_vote` 교체**
```python
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: 검증**
```bash
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 회귀**
```bash
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**
```bash
git add app.py
git commit -m "feat: stage vote — QR + 카운터 + autorefresh 3초"
```
---
## Task 12: Admin — Stage 진행 Section
**Files:**
- Modify: `app.py``render_admin()` 상단
- [ ] **Step 1: stage section 삽입**
`render_admin()` 의 "🔗 다른 페이지 URL" expander 다음에:
```python
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: 수동 테스트**
```bash
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**
```bash
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 직후에:
```python
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**
```bash
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":` 블록 다음에:
```python
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**
```bash
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` 부근에:
```python
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 직후)에:
```python
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**
```bash
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을 다음으로 교체:
```bash
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: 검증**
```bash
./show-urls.sh
```
Expected: 큰 화면 / 어드민 / 시상식 / JSON / 모바일 vote 5개 URL 출력.
- [ ] **Step 3: Commit**
```bash
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 교체:
```markdown
## 흐름 (행사 진행)
1. **Stage 1 — 팀 편성 + 안내** (큰 화면 `/`)
2. **Stage 2 — 예시 주제** (큰 화면 `/`, 어드민이 "다음 stage →")
3. **해킹** (앱 외부, 2시간)
4. **발표**
5. **Stage 3 — 투표** (큰 화면에 QR, 모바일 → `/?mode=vote`)
6. **시상** (`/?mode=ceremony&token=mlops2026`)
```
기존 URL section 갱신:
```markdown
- 큰 화면: `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 끝부분에 추가:
```markdown
- `topics.categories` 4 카테고리 × 10 items. 어드민에서 form / JSON 둘 다 편집.
- `settings.current_stage` ∈ {"intro","topics","vote"} — 어드민에서 stage 컨트롤.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: README — 새 흐름 + URL + topics 설명"
```
---
## Task 18: E2E 추가 시나리오 회귀
**Files:**
- (이미 Task 2~6에서 추가됨)
- [ ] **Step 1: 모든 e2e 통과 확인**
```bash
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: 빈 컨테이너 부팅 검증**
```bash
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: 시드 데이터 검증**
```bash
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 확인**
```bash
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.