このレシピは @event/feedback の live エンジン(実装済み・章28)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。
状態はすべて KV — 本番は Upstash(KV_REST_API_URL/TOKEN)、未設定の dev は kv() の in-memory shim で そのまま動く(Next dev は単一プロセスなのでリクエスト跨ぎで保持される)。DB / WebSocket は不要。 リアルタイムは参加者側の 2 秒ポーリング + 3 系統の rev(viewRev / promptRev / results.rev)観測で実現する。
1. 依存を追加
apps/<app>/package.json の dependencies に追加(starter には既に入っている):
"@event/feedback": "*",
"@event/core": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@event/feedback" と "@event/core" を足す。
2. 機能を有効化
apps/<app>/src/event.config.ts の features で:
live: true,
FEATURES に live descriptor を追加(starter には既に入っている):
{
key: "live",
label: "ライブ投票",
description: "主催主導のライブ poll — KV ポーリング + derived host key(@event/feedback live)",
nav: [
{ id: "live-host", group: "フィードバック", label: "ライブ投票", href: "/admin/live", code: "LIVE", order: 2 },
],
env: [
{ name: "LIVE_HOST_SECRET", required: false, description: "host key の導出秘密(無ければ ADMIN_PASSWORD から導出)" },
{ name: "KV_REST_API_URL", required: false, description: "Upstash KV REST URL(未設定は dev in-memory shim)" },
{ name: "KV_REST_API_TOKEN", required: false, description: "Upstash KV REST token(未設定は dev in-memory shim)" },
],
manualChapters: [28],
},
3. live の配線ファイルを作る
apps/<app>/src/lib/live.ts:
import { createLiveStore, hostKeyFor, verifyHostKey } from "@event/feedback";
import { kv } from "@event/core";
/**
* live poll の配線点。状態はすべて KV — 本番は Upstash(KV_REST_API_*)、
* 未設定の dev は kv() の in-memory shim(Next dev は単一プロセスなので
* リクエスト跨ぎで保持される)。DB / WebSocket は不要。
*/
export const liveStore = createLiveStore({ kv: kv() });
/**
* host key(主催パスワード)の安全な導出。導出秘密
* (LIVE_HOST_SECRET ?? ADMIN_PASSWORD)が無い環境では hostKeyFor が throw
* するので、null に落として呼び出し側が 503 / 案内表示に倒せるようにする。
*/
export function tryHostKeyFor(unitId: string): string | null {
try {
return hostKeyFor(unitId);
} catch {
return null;
}
}
/**
* host key の照合。戻り値 3 値:
* - true … 一致(定数時間比較)
* - false … 不一致
* - null … 導出秘密が未設定で照合不能(= 503 にする)
*/
export function tryVerifyHostKey(unitId: string, key: string): boolean | null {
try {
return verifyHostKey(unitId, key);
} catch {
return null;
}
}
export { hostKeyFor, verifyHostKey };
(createLiveStore(options) の必須は kv のみ。keyPrefix(既定 'live:')と views (既定 ['survey','qa']・先頭が既定 view)は省略でよい。)
4. 公開 API — 参加者のポーリングと投票
apps/<app>/src/app/api/live/[unitId]/route.ts:
import { NextResponse } from "next/server";
import { voterHashOf } from "@event/feedback";
import { features } from "@/event.config";
import { liveStore } from "@/lib/live";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* 公開 live API(認証不要 — 参加者のポーリング用)。
* - GET → 現在の view + active お題 + 集計。参加者は 2 秒間隔でポーリングし、
* viewRev / promptRev / results.rev の変化で差分描画する(WebSocket 不要)。
* - POST → 投票 { promptId, optionId }。voterHashOf(UA+IP+promptId) で 1 人 1 票
* (dedup は KV の SADD。同一端末の連打は 1 票に収束する)。
*/
type Ctx = { params: Promise<{ unitId: string }> };
export async function GET(_req: Request, ctx: Ctx) {
if (!features.isEnabled("live")) {
return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
}
const { unitId } = await ctx.params;
const [{ view, rev: viewRev }, state] = await Promise.all([
liveStore.getLiveView(unitId),
liveStore.getLiveState(unitId),
]);
// state = { prompt, promptRev, results }(LiveState)に view 系を足して 1 応答に。
return NextResponse.json({ ok: true, view, viewRev, ...state });
}
type VoteBody = { promptId?: string; optionId?: string };
export async function POST(req: Request, ctx: Ctx) {
if (!features.isEnabled("live")) {
return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
}
const { unitId } = await ctx.params;
let body: VoteBody;
try {
body = (await req.json()) as VoteBody;
} catch {
return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
}
const promptId = typeof body.promptId === "string" ? body.promptId : "";
const optionId = typeof body.optionId === "string" ? body.optionId : "";
if (!promptId || !optionId) {
return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 });
}
const ua = req.headers.get("user-agent") ?? "";
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
req.headers.get("x-real-ip") ||
"local";
// respond は prompt.id 不一致(既に切替済み)を { ok:true, stale:true } で黙って無視する。
const result = await liveStore.respond(
unitId,
promptId,
{ optionId },
voterHashOf(ua, ip, promptId),
);
if (!result.ok) {
return NextResponse.json({ ok: false, error: "invalid_option" }, { status: 400 });
}
return NextResponse.json(result);
}
(respond(unitId, promptId, { optionId }, voterHash) の戻りは { ok, deduped?, stale? }。 voterHashOf(ua, ip, pid) は promptId を混ぜるので、次のお題では同じ人がまた投票できる。)
5. host API — お題の開始/停止と view 切替
apps/<app>/src/app/api/live/[unitId]/host/route.ts:
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import type { PromptActionBody } from "@event/feedback";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { liveStore, tryHostKeyFor, tryVerifyHostKey } from "@/lib/live";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* 主催(host)API。
* - GET → host key の取得(adminAuth 必須)。admin が登壇者/モデレーターに配布する。
* - POST → host key 照合(verifyHostKey・定数時間比較)の上で
* お題の開始/停止(applyPromptAction)と view 切替(setLiveView)。
* admin cookie では通さない — admin UI も登壇者と同じ host key 経路を通す。
*
* host key は LIVE_HOST_SECRET ?? ADMIN_PASSWORD から決定的に導出(保存しない)。
* どちらも無い環境では 503 を返して機能を安全に無効化する。
*/
type Ctx = { params: Promise<{ unitId: string }> };
export async function GET(_req: Request, ctx: Ctx) {
if (!features.isEnabled("live")) {
return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
}
const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
if (!adminAuth.isAuthedFromValue(cookie)) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
const { unitId } = await ctx.params;
const hostKey = tryHostKeyFor(unitId);
if (!hostKey) {
return NextResponse.json({ ok: false, error: "host_key_unavailable" }, { status: 503 });
}
return NextResponse.json({ ok: true, hostKey });
}
/** PromptActionBody(action/question/options)+ 認可 key + view 切替用 view。 */
type HostBody = PromptActionBody & { key?: string; view?: string };
export async function POST(req: Request, ctx: Ctx) {
if (!features.isEnabled("live")) {
return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
}
const { unitId } = await ctx.params;
let body: HostBody;
try {
body = (await req.json()) as HostBody;
} catch {
return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
}
const verified = tryVerifyHostKey(unitId, typeof body.key === "string" ? body.key : "");
if (verified === null) {
return NextResponse.json({ ok: false, error: "host_key_unavailable" }, { status: 503 });
}
if (!verified) {
return NextResponse.json({ ok: false, error: "forbidden" }, { status: 403 });
}
// view 切替は applyPromptAction の前に分岐(それ以外の action は start/stop として処理)。
if (body.action === "view") {
const v = await liveStore.setLiveView(unitId, typeof body.view === "string" ? body.view : "");
return NextResponse.json({ ok: true, ...v });
}
// applyPromptAction: action==='stop' で終了、それ以外は question + options(2枠以上) で開始。
const res = await liveStore.applyPromptAction(unitId, body);
if (!res.ok) {
return NextResponse.json({ ok: false, error: res.error }, { status: res.status });
}
return NextResponse.json(res);
}
(applyPromptAction(unitId, body) は { ok:true, state: LiveState } か { ok:false, error, status } を返す。question は 140 字 trim、options は 2〜4 枠・ 空ラベルは A/B/C/D にフォールバック。認可は呼び出し側で済ませる前提なので必ず verify を先に。)
6. 参加者ページ(公開・ポーリング投票)
apps/<app>/src/app/live/[unitId]/page.tsx(server・認証不要・feature-gate):
import { notFound } from "next/navigation";
import { features } from "@/event.config";
import { LiveRoom } from "./LiveRoom";
export const dynamic = "force-dynamic";
/**
* /live/[unitId] — 参加者のライブ投票画面(認証不要・feature-gate のみ)。
* QR / リンクで配布する。実体は LiveRoom(2 秒ポーリング)に委譲する。
*/
export default async function LivePage({ params }: { params: Promise<{ unitId: string }> }) {
if (!features.isEnabled("live")) notFound();
const { unitId } = await params;
return (
<main style={{ maxWidth: 560, margin: "0 auto", padding: "40px 20px" }}>
<p
style={{
fontFamily: "var(--mono)",
fontSize: 11,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: "var(--accent)",
margin: "0 0 8px",
}}
>
live / {unitId}
</p>
<h1 style={{ fontSize: 22, margin: "0 0 16px" }}>ライブ投票</h1>
<LiveRoom unitId={unitId} />
</main>
);
}
apps/<app>/src/app/live/[unitId]/LiveRoom.tsx(client。@event/feedback は type-only import = client bundle に server 専用 barrel を持ち込まない):
"use client";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
// type-only import はコンパイル時に消える = client bundle に feedback barrel を持ち込まない。
import type { LivePrompt, LiveResults } from "@event/feedback";
/**
* 参加者画面 — GET /api/live/[unitId] を 2 秒ポーリングし、active お題に投票して
* 結果バーをライブ描画する。リアルタイムは rev(promptRev / results.rev)の観測で、
* WebSocket は使わない。1 人 1 票の dedup はサーバ側(voterHash)。
*/
type LiveSnapshot = {
ok: boolean;
view: string;
viewRev: number;
prompt: LivePrompt | null;
promptRev: number;
results: LiveResults | null;
};
const POLL_MS = 2000;
export function LiveRoom({ unitId }: { unitId: string }) {
const [snap, setSnap] = useState<LiveSnapshot | null>(null);
// promptId → 投票した optionId(同じお題への再投票 UI を抑止。真の dedup はサーバ)。
const [votedFor, setVotedFor] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await fetch(`/api/live/${encodeURIComponent(unitId)}`, { cache: "no-store" });
if (res.ok) setSnap((await res.json()) as LiveSnapshot);
} catch {
// 瞬断はスキップ(次のポーリングで回復する)
}
}, [unitId]);
useEffect(() => {
void refresh();
const t = setInterval(() => void refresh(), POLL_MS);
return () => clearInterval(t);
}, [refresh]);
async function vote(promptId: string, optionId: string) {
if (busy) return;
setBusy(true);
try {
const res = await fetch(`/api/live/${encodeURIComponent(unitId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ promptId, optionId }),
});
const data = (await res.json()) as { ok?: boolean };
if (data.ok) setVotedFor((v) => ({ ...v, [promptId]: optionId }));
await refresh(); // 自票を即反映(次のポーリングを待たない)
} catch {
// 送信失敗は無視(ボタンは押し直せる)
} finally {
setBusy(false);
}
}
if (!snap) return <p style={{ fontSize: 13, opacity: 0.6 }}>接続中…</p>;
const prompt = snap.prompt;
if (!prompt) {
return (
<p style={{ fontSize: 14, opacity: 0.65, padding: "24px 0" }}>
お題を待っています… 主催が開始すると自動でここに表示されます。
</p>
);
}
const myVote = votedFor[prompt.id];
const results = snap.results;
const total = results?.total ?? 0;
return (
<div>
<p style={{ fontSize: 17, fontWeight: 600, margin: "0 0 14px" }}>{prompt.question}</p>
<div style={{ display: "grid", gap: 8 }}>
{(prompt.options ?? []).map((o) => {
const count = results?.counts[o.id] ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const selected = myVote === o.id;
return (
<button
key={o.id}
type="button"
disabled={busy || myVote !== undefined}
onClick={() => void vote(prompt.id, o.id)}
style={{
position: "relative",
overflow: "hidden",
textAlign: "left",
padding: "12px 14px",
fontSize: 14,
fontFamily: "inherit",
borderRadius: 8,
border: selected ? "2px solid var(--accent)" : "1px solid rgba(20,20,15,.2)",
background: "transparent",
cursor: myVote === undefined ? "pointer" : "default",
}}
>
{/* 結果バー(票が入ると幅がライブに伸びる) */}
<span
aria-hidden
style={{
position: "absolute",
inset: 0,
width: `${pct}%`,
background: "var(--accent-soft)",
transition: "width .4s ease",
}}
/>
<span style={{ position: "relative", display: "flex", justifyContent: "space-between", gap: 12 }}>
<span>
{o.label}
{selected ? " ✓" : ""}
</span>
<span style={{ opacity: 0.6, fontVariantNumeric: "tabular-nums" }}>
{count}票{total > 0 ? ` · ${pct}%` : ""}
</span>
</span>
</button>
);
})}
</div>
<p style={{ fontSize: 12, opacity: 0.55, marginTop: 12 }}>
{myVote !== undefined ? "投票済み — 結果はライブ更新されます。" : "タップで投票(1 人 1 票)。"}
{" "}合計 {total} 票
</p>
</div>
);
}
7. admin 主催コンソール
apps/<app>/src/app/admin/live/page.tsx(server・adminAuth + feature-gate):
import { notFound, redirect } from "next/navigation";
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { LiveHost } from "./LiveHost";
export const dynamic = "force-dynamic";
/**
* /admin/live — 主催コンソール。unit(セッション)ID を入れると host key が出て、
* お題の開始/停止・view 切替・ライブ集計ができる。host key は登壇者/モデレーターに
* 配布でき、受け取った人は admin ログイン無しで host API を直接叩ける。
*/
export default async function LiveAdminPage() {
if (!features.isEnabled("live")) notFound();
const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");
// host key の導出秘密(LIVE_HOST_SECRET ?? ADMIN_PASSWORD)が無いと機能は安全に停止する。
const secretConfigured = !!(process.env.LIVE_HOST_SECRET ?? process.env.ADMIN_PASSWORD);
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
<p
style={{
fontFamily: "var(--mono)",
fontSize: 11,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: "var(--accent)",
margin: "0 0 8px",
}}
>
live / host
</p>
<h1 style={{ fontSize: 26, margin: "0 0 4px" }}>ライブ投票</h1>
<p style={{ fontSize: 13, opacity: 0.65, margin: "0 0 16px", maxWidth: "72ch" }}>
unit ID(セッション ID など任意の識別子)ごとに独立した投票ルームが立つ。参加者には{" "}
<code>/live/<unit ID></code> を QR / リンクで配る。
</p>
{!secretConfigured && (
<p
style={{
fontSize: 12.5,
margin: "0 0 24px",
padding: "10px 14px",
borderRadius: 4,
border: "1px solid rgba(20,20,15,.14)",
background: "rgba(20,20,15,.05)",
}}
>
<b>LIVE_HOST_SECRET / ADMIN_PASSWORD が未設定</b> — host key を導出できないため、
お題の開始/停止は動きません。どちらかの env を設定してください。
</p>
)}
<LiveHost />
</main>
);
}
apps/<app>/src/app/admin/live/LiveHost.tsx(client):
"use client";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
// type-only import はコンパイル時に消える = client bundle に feedback barrel を持ち込まない。
import type { LivePrompt, LiveResults } from "@event/feedback";
/**
* 主催コンソール(client)。
* 1. unit ID を入れて「接続」→ GET /api/live/[unitId]/host(adminAuth)で host key を取得・表示
* 2. お題(question + 選択肢 2〜4)を書いて「開始」→ POST(host key 照合)
* 3. 集計は GET /api/live/[unitId] を 2 秒ポーリングでライブ表示。「終了」で active を消す
*
* POST は admin cookie では通らず host key を要求する — 登壇者に key を渡せば
* この画面が無くても同じ API を叩ける(admin UI も同じ経路を通して検証を兼ねる)。
*/
type LiveSnapshot = {
ok: boolean;
view: string;
viewRev: number;
prompt: LivePrompt | null;
promptRev: number;
results: LiveResults | null;
};
const POLL_MS = 2000;
const VIEWS = ["survey", "qa"] as const;
const inputStyle: CSSProperties = {
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid rgba(20,20,15,.2)",
borderRadius: 4,
boxSizing: "border-box",
fontFamily: "inherit",
};
const buttonStyle: CSSProperties = {
padding: "8px 16px",
fontSize: 13,
fontFamily: "inherit",
borderRadius: 6,
border: "1px solid rgba(20,20,15,.25)",
background: "transparent",
cursor: "pointer",
};
export function LiveHost() {
const [unitInput, setUnitInput] = useState("");
const [unitId, setUnitId] = useState<string | null>(null); // 接続済み unit
const [hostKey, setHostKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [question, setQuestion] = useState("");
const [labels, setLabels] = useState<string[]>(["", "", "", ""]);
const [busy, setBusy] = useState(false);
const [snap, setSnap] = useState<LiveSnapshot | null>(null);
// --- 1. 接続: host key を取得(adminAuth 必須の GET) ---
async function connect() {
const uid = unitInput.trim();
if (!uid) return;
setError(null);
try {
const res = await fetch(`/api/live/${encodeURIComponent(uid)}/host`, { cache: "no-store" });
const data = (await res.json()) as { ok?: boolean; hostKey?: string; error?: string };
if (!data.ok || !data.hostKey) {
setError(
data.error === "host_key_unavailable"
? "host key を導出できません(LIVE_HOST_SECRET / ADMIN_PASSWORD 未設定)"
: `接続に失敗しました(${data.error ?? res.status})`,
);
return;
}
setUnitId(uid);
setHostKey(data.hostKey);
setSnap(null);
} catch {
setError("接続に失敗しました(ネットワーク)");
}
}
// --- 3. 集計ポーリング(公開 GET と同じもの) ---
const refresh = useCallback(async () => {
if (!unitId) return;
try {
const res = await fetch(`/api/live/${encodeURIComponent(unitId)}`, { cache: "no-store" });
if (res.ok) setSnap((await res.json()) as LiveSnapshot);
} catch {
// 瞬断はスキップ
}
}, [unitId]);
useEffect(() => {
if (!unitId) return;
void refresh();
const t = setInterval(() => void refresh(), POLL_MS);
return () => clearInterval(t);
}, [unitId, refresh]);
// --- 2. host アクション(開始 / 停止 / view 切替) ---
async function post(body: Record<string, unknown>) {
if (!unitId || !hostKey || busy) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/live/${encodeURIComponent(unitId)}/host`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ key: hostKey, ...body }),
});
const data = (await res.json()) as { ok?: boolean; error?: string };
if (!data.ok) setError(`失敗: ${data.error ?? res.status}`);
await refresh();
} catch {
setError("送信に失敗しました(ネットワーク)");
} finally {
setBusy(false);
}
}
const filledLabels = labels.map((l) => l.trim()).filter(Boolean);
const canStart = question.trim().length > 0 && filledLabels.length >= 2 && !busy;
function start() {
// 空欄を除いた 2〜4 択。id はサーバ側フォールバック(o1..o4)に任せる。
void post({ question: question.trim(), options: filledLabels.map((label) => ({ label })) });
}
if (!unitId) {
return (
<section>
<label style={{ display: "block", fontSize: 12, opacity: 0.7, marginBottom: 6 }}>
unit ID(例: セッション ID。参加者 URL は /live/<unit ID>)
</label>
<div style={{ display: "flex", gap: 8 }}>
<input
value={unitInput}
onChange={(e) => setUnitInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void connect()}
placeholder="demo-session"
style={{ ...inputStyle, flex: 1 }}
/>
<button type="button" onClick={() => void connect()} disabled={!unitInput.trim()} style={buttonStyle}>
接続
</button>
</div>
{error && <p style={{ fontSize: 12.5, color: "#b3261e", marginTop: 10 }}>{error}</p>}
</section>
);
}
const prompt = snap?.prompt ?? null;
const results = snap?.results ?? null;
const total = results?.total ?? 0;
return (
<section style={{ display: "grid", gap: 24 }}>
{/* 接続情報 + host key */}
<div
style={{
padding: "12px 14px",
borderRadius: 6,
border: "1px solid rgba(20,20,15,.14)",
fontSize: 13,
display: "grid",
gap: 4,
}}
>
<div>
unit: <b>{unitId}</b>
<button
type="button"
onClick={() => {
setUnitId(null);
setHostKey(null);
setSnap(null);
}}
style={{ ...buttonStyle, padding: "2px 10px", fontSize: 12, marginLeft: 12 }}
>
変更
</button>
</div>
<div>
host key:{" "}
<code style={{ fontSize: 15, letterSpacing: "0.08em", color: "var(--accent)" }}>{hostKey}</code>
<span style={{ opacity: 0.55, fontSize: 12, marginLeft: 8 }}>
(登壇者/モデレーターに配布可。admin ログイン不要で host API を叩ける)
</span>
</div>
<div style={{ opacity: 0.7 }}>
参加者 URL: <code>/live/{unitId}</code>
</div>
</div>
{/* お題フォーム */}
<div>
<h2 style={{ fontSize: 15, margin: "0 0 10px" }}>お題(poll)</h2>
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="質問(140 字まで)"
maxLength={140}
style={{ ...inputStyle, marginBottom: 8 }}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 10 }}>
{labels.map((l, i) => (
<input
key={i}
value={l}
onChange={(e) => setLabels((prev) => prev.map((x, j) => (j === i ? e.target.value : x)))}
placeholder={`選択肢 ${String.fromCharCode(65 + i)}${i < 2 ? "(必須)" : "(任意)"}`}
maxLength={60}
style={inputStyle}
/>
))}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={start}
disabled={!canStart}
style={{
...buttonStyle,
border: "1px solid var(--accent)",
color: "var(--accent)",
opacity: canStart ? 1 : 0.45,
}}
>
開始(既存のお題を置き換え)
</button>
<button type="button" onClick={() => void post({ action: "stop" })} disabled={busy || !prompt} style={buttonStyle}>
終了
</button>
<span style={{ flex: 1 }} />
{/* view-follow: 参加者全員の画面を切替える(viewRev が上がり全端末が追従) */}
{VIEWS.map((v) => (
<button
key={v}
type="button"
onClick={() => void post({ action: "view", view: v })}
disabled={busy}
style={{
...buttonStyle,
fontSize: 12,
...(snap?.view === v ? { borderColor: "var(--accent)", color: "var(--accent)" } : {}),
}}
>
view: {v}
</button>
))}
</div>
{error && <p style={{ fontSize: 12.5, color: "#b3261e", marginTop: 10 }}>{error}</p>}
</div>
{/* ライブ結果 */}
<div>
<h2 style={{ fontSize: 15, margin: "0 0 10px" }}>
ライブ結果{" "}
<span style={{ fontWeight: 400, fontSize: 12, opacity: 0.6 }}>
{prompt ? `「${prompt.question}」 · 合計 ${total} 票(2 秒更新)` : "お題は停止中"}
</span>
</h2>
{prompt ? (
<div style={{ display: "grid", gap: 6 }}>
{(prompt.options ?? []).map((o) => {
const count = results?.counts[o.id] ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div key={o.id} style={{ fontSize: 13 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>
<span>{o.label}</span>
<span style={{ opacity: 0.65, fontVariantNumeric: "tabular-nums" }}>
{count}票 · {pct}%
</span>
</div>
<div style={{ height: 8, borderRadius: 4, background: "rgba(20,20,15,.08)", overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${pct}%`,
background: "var(--accent)",
transition: "width .4s ease",
}}
/>
</div>
</div>
);
})}
</div>
) : (
<p style={{ fontSize: 13, opacity: 0.55 }}>お題を開始すると集計がここに出ます。</p>
)}
</div>
</section>
);
}
8. 型チェックと検証
monorepo root で:
yarn install
yarn workspace <app> typecheck # 対象 app にスコープ
yarn workspace <app> build # /live/[unitId] と /api/live/* が route 一覧に出ること
dev での end-to-end 検証(KV 未設定でも in-memory shim で動く。dev は adminAuth bypass で ログイン不要。host key の導出には LIVE_HOST_SECRET か ADMIN_PASSWORD のどちらかが必要 — 無ければ .env.local に LIVE_HOST_SECRET=dev-secret を 1 行足す):
yarn workspace <app> dev/admin/liveを開き、unit ID にdemoと入れて「接続」→ 8 文字の host key が表示される- 質問と選択肢 2 つを入れて「開始」
- 別タブで
/live/demoを開く → お題が表示される → 選択肢をタップして投票 - admin タブの「ライブ結果」に票が 2 秒以内に反映される(参加者タブの結果バーも伸びる)
- 同じタブでもう一度投票できないこと(1 人 1 票 = ボタンが投票済みで固定)を確認
- 「終了」→ 参加者タブが「お題を待っています…」に戻る
完了条件: build が通り、host の開始 → 別タブ投票 → 2 秒以内の集計反映 → 終了の一巡が動く。
本番は Upstash(KV_REST_API_URL / KV_REST_API_TOKEN)を設定する — dev shim はプロセス内 メモリなので serverless では使えない。host key の一括ローテーションは LIVE_HOST_SECRET の 差し替えだけ(保存しない導出式なので個別失効は無い。章28 参照)。