diff --git a/Makefile b/Makefile
index 5ca66a1..19c70ad 100644
--- a/Makefile
+++ b/Makefile
@@ -2,10 +2,10 @@
# the user has activated it (or has direnv loaded). `make venv` creates it.
export PATH := $(CURDIR)/.venv/bin:$(PATH)
-.PHONY: ci lint format format-check test install-dev run venv init build
+.PHONY: ci lint format format-check test install-dev run venv init build frontend-lint frontend-type-check
# Run all CI checks — called by GitHub Actions.
-ci: lint format-check frontend-lint test
+ci: lint format-check frontend-lint frontend-type-check test
# ── Python ──────────────────────────────────────────────────────────────────
@@ -27,6 +27,9 @@ test:
frontend-lint:
npm --prefix frontend run lint
+frontend-type-check:
+ npm --prefix frontend run type-check
+
# ── Dev setup ────────────────────────────────────────────────────────────────
venv:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 0092e46..895524c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -5,6 +5,7 @@
"packages": {
"": {
"name": "ceopardy-frontend",
+ "license": "GPL-3.0-or-later",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
"pinia": "^2.2.2",
@@ -13,9 +14,13 @@
"vue-router": "^4.4.3"
},
"devDependencies": {
+ "@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.6",
+ "@vue/tsconfig": "^0.9.1",
"prettier": "^3.3.3",
- "vite": "^8.0.10"
+ "typescript": "^6.0.3",
+ "vite": "^8.0.10",
+ "vue-tsc": "^3.3.1"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -423,6 +428,16 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/node": {
+ "version": "25.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
+ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
@@ -440,6 +455,35 @@
"vue": "^3.2.25"
}
},
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
+ "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
+ "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
+ "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
"node_modules/@vue/compiler-core": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
@@ -496,6 +540,22 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
+ "node_modules/@vue/language-core": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.1.tgz",
+ "integrity": "sha512-NP8g6V7x81NVOXbLupUvYY6i6LqUkjkVowe2epRedmpgaFCOdjgWHE/rQBvEJ4r7koAYODIjGeBWEdt6n7jYXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^3.2.0",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1",
+ "picomatch": "^4.0.4"
+ }
+ },
"node_modules/@vue/reactivity": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
@@ -546,6 +606,32 @@
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"license": "MIT"
},
+ "node_modules/@vue/tsconfig": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz",
+ "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": ">= 5.8",
+ "vue": "^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/alien-signals": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
+ "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -928,6 +1014,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -946,6 +1039,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1134,6 +1234,27 @@
"license": "0BSD",
"optional": true
},
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
@@ -1212,6 +1333,13 @@
}
}
},
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vue": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
@@ -1274,6 +1402,23 @@
"vue": "^3.5.0"
}
},
+ "node_modules/vue-tsc": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.1.tgz",
+ "integrity": "sha512-webBP3jhlxzhELZ2g+11KJ6pg5OVY1xWhWrj7N/yQMi1CrtxJnW+tUACyRVeDK0cQNLP2Va5HNYK8pe+7c+msw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.28",
+ "@vue/language-core": "3.3.1"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 20ac226..310585d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,9 +6,10 @@
"license": "GPL-3.0-or-later",
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
- "lint": "prettier --check \"src/**/*.{js,vue}\""
+ "lint": "prettier --check \"src/**/*.{ts,vue}\"",
+ "type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
@@ -18,8 +19,12 @@
"vue-router": "^4.4.3"
},
"devDependencies": {
+ "@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.6",
+ "@vue/tsconfig": "^0.9.1",
"prettier": "^3.3.3",
- "vite": "^8.0.10"
+ "typescript": "^6.0.3",
+ "vite": "^8.0.10",
+ "vue-tsc": "^3.3.1"
}
}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 352ec61..0f9e0e5 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,5 +1,5 @@
-
@@ -58,7 +60,7 @@ function onClick(col, row) {
-
${{ row * game.config.SCORE_TICK }}
+
${{ row * game.scoreTick }}
diff --git a/frontend/src/components/HostControls.vue b/frontend/src/components/HostControls.vue
index e2f36a6..4b98c97 100644
--- a/frontend/src/components/HostControls.vue
+++ b/frontend/src/components/HostControls.vue
@@ -1,21 +1,23 @@
-
diff --git a/frontend/src/components/HostFooterDrawer.vue b/frontend/src/components/HostFooterDrawer.vue
index 6a790b1..3a7101d 100644
--- a/frontend/src/components/HostFooterDrawer.vue
+++ b/frontend/src/components/HostFooterDrawer.vue
@@ -1,5 +1,5 @@
-
@@ -107,7 +107,7 @@ function toggleCustom() {
v-if="customEditing"
class="form-color form-edit"
contenteditable="true"
- @input="customText = $event.target.innerText"
+ @input="customText = ($event.target as HTMLElement).innerText"
>
{{ customText }}
diff --git a/frontend/src/components/HostHeaderDrawer.vue b/frontend/src/components/HostHeaderDrawer.vue
index 88055d1..19f71f3 100644
--- a/frontend/src/components/HostHeaderDrawer.vue
+++ b/frontend/src/components/HostHeaderDrawer.vue
@@ -1,13 +1,13 @@
-
diff --git a/frontend/src/components/QuestionOverlay.vue b/frontend/src/components/QuestionOverlay.vue
index 5ef4128..1f9e108 100644
--- a/frontend/src/components/QuestionOverlay.vue
+++ b/frontend/src/components/QuestionOverlay.vue
@@ -1,5 +1,5 @@
-
diff --git a/frontend/src/components/TeamRow.vue b/frontend/src/components/TeamRow.vue
index c61e5d6..78148b9 100644
--- a/frontend/src/components/TeamRow.vue
+++ b/frontend/src/components/TeamRow.vue
@@ -1,5 +1,5 @@
-
@@ -71,7 +71,7 @@ function questionLabel(row) {
| null;
+}
+
export const useGameStore = defineStore("game", {
- state: () => ({
+ state: (): GameStoreState => ({
initialized: false,
config: {}, // populated from /api/v1/state on connect
game_state: "uninitialized",
@@ -30,46 +81,59 @@ export const useGameStore = defineStore("game", {
// {team, amount} as the host moves the wager slider during a DD; null
// outside DD or before the operator has set anything.
dailydouble_wager: null,
- // Incremented every time the server fires a new daily-double. Lets the
- // viewer trigger the flip animation without watching for state changes
- // that might bounce during reconnects.
dailydoubleTrigger: 0,
socket: null,
rouletteTarget: null,
- // Set by HostView on mount. Host skips the roulette animation and jumps
- // straight to the winner so the operator can re-roll quickly for showmanship.
isHost: false,
+ _rouletteTimer: null,
}),
getters: {
- isInProgress: (s) =>
+ // Config getters with sane defaults so components can use them without
+ // having to guard against the brief window between mount and the first
+ // /api/v1/state response.
+ questionsPerCategory: (s): number => s.config.QUESTIONS_PER_CATEGORY ?? 5,
+ scoreTick: (s): number => s.config.SCORE_TICK ?? 100,
+
+ isInProgress: (s): boolean =>
s.game_state === "in_round" || s.game_state === "in_final",
- isFinished: (s) => s.game_state === "finished",
- teamByTid: (s) => (tid) => s.teams.find((t) => t.tid === tid),
- activeQuestionId: (s) => s.ui_state.question || "",
- selectedTeam: (s) => s.ui_state.team || "",
- isDailyDouble: (s) =>
+ isFinished: (s): boolean => s.game_state === "finished",
+ teamByTid:
+ (s) =>
+ (tid: string): Team | undefined =>
+ s.teams.find((t) => t.tid === tid),
+ activeQuestionId: (s): string => s.ui_state.question || "",
+ selectedTeam: (s): string => s.ui_state.team || "",
+ isDailyDouble: (s): boolean =>
s.ui_state.dailydouble === "enabled" ||
s.ui_state.dailydouble === "revealed",
- isDailyDoubleRevealed: (s) => s.ui_state.dailydouble === "revealed",
- bigOverlayHtml: (s) => s.ui_state["overlay-big"] || "",
- questionAnswered: (s) => (qid) => !!s.questions[qid]?.answered,
+ isDailyDoubleRevealed: (s): boolean =>
+ s.ui_state.dailydouble === "revealed",
+ bigOverlayHtml: (s): string => s.ui_state["overlay-big"] || "",
+ questionAnswered:
+ (s) =>
+ (qid: string): boolean =>
+ !!s.questions[qid]?.answered,
},
actions: {
- async refresh() {
+ async refresh(): Promise {
const data = await api.state();
this.applyServerState(data);
this.initialized = true;
},
- applyServerState(data) {
+ applyServerState(data: ServerState): void {
if (data.config) this.config = { ...this.config, ...data.config };
if (data.game_state) this.game_state = data.game_state;
if (data.teams) this.teams = data.teams;
if (data.categories) this.categories = data.categories;
if (data.questions) this.questions = data.questions;
- if (data.state) this.ui_state = { ...this.ui_state, ...data.state };
+ if (data.state) {
+ for (const [k, v] of Object.entries(data.state)) {
+ if (v !== undefined) this.ui_state[k] = v;
+ }
+ }
if (data.active_question !== undefined)
this.active_question = data.active_question || {};
if (data.messages) this.messages = data.messages;
@@ -79,7 +143,7 @@ export const useGameStore = defineStore("game", {
this.dailydouble_wager = data.dailydouble_wager;
},
- connectSocket() {
+ connectSocket(): void {
if (this.socket) return;
const s = getSocket();
this.socket = s;
@@ -88,9 +152,9 @@ export const useGameStore = defineStore("game", {
// no-op, state is already loaded via REST
});
- s.on("state", (data) => this.applyServerState(data));
+ s.on("state", (data: ServerState) => this.applyServerState(data));
- s.on("question-show", (data) => {
+ s.on("question-show", (data: QuestionShowEvent) => {
this.active_question = {
text: data.text,
category: data.category,
@@ -107,7 +171,7 @@ export const useGameStore = defineStore("game", {
this.dailydouble_wager = null;
});
- s.on("dailydouble", (data) => {
+ s.on("dailydouble", (data: DailyDoubleEvent) => {
this.ui_state.question = data.qid;
this.ui_state.dailydouble = "enabled";
this.active_question = { category: data.category, dailydouble: true };
@@ -117,7 +181,7 @@ export const useGameStore = defineStore("game", {
this.dailydoubleTrigger += 1;
});
- s.on("dailydouble-reveal", (data) => {
+ s.on("dailydouble-reveal", (data: DailyDoubleRevealEvent) => {
this.ui_state.dailydouble = "revealed";
this.active_question = {
...this.active_question,
@@ -128,47 +192,47 @@ export const useGameStore = defineStore("game", {
this.ui_state.question = data.qid;
});
- s.on("dailydouble-range", (data) => {
+ s.on("dailydouble-range", (data: DailyDoubleRangeEvent) => {
if (data?.range) this.dailydouble_range = data.range;
});
- s.on("dailydouble-wager", (data) => {
+ s.on("dailydouble-wager", (data: DailyDoubleWagerEvent) => {
this.dailydouble_wager =
data && data.team != null && data.amount != null
? { team: data.team, amount: data.amount }
: null;
});
- s.on("board-update", (data) => {
+ s.on("board-update", (data: BoardUpdateEvent) => {
this.questions = { ...this.questions, ...data.questions };
if (data.teams) this.teams = data.teams;
});
- s.on("team-select", (data) => {
+ s.on("team-select", (data: TeamSelectEvent) => {
this.ui_state.team = data.tid ?? "";
});
- s.on("team-roulette", (data) => {
+ s.on("team-roulette", (data: TeamRouletteEvent) => {
this.runRoulette(data.sequence);
});
- s.on("team-names", (data) => {
+ s.on("team-names", (data: TeamNamesEvent) => {
for (const [tid, name] of Object.entries(data.names)) {
const t = this.teamByTid(tid);
if (t) t.name = name;
}
});
- s.on("overlay-big", (data) => {
+ s.on("overlay-big", (data: OverlayBigEvent) => {
this.ui_state["overlay-big"] = data.html || "";
this.ui_state.message = data.id || "";
});
- s.on("slider", (data) => {
- this.ui_state[data.id] = data.value;
+ s.on("slider", (data: SliderEvent) => {
+ this.ui_state[data.id] = String(data.value);
});
- s.on("redirect", (data) => {
+ s.on("redirect", (data: RedirectEvent) => {
// game was reset; reload to land on the right view
if (data?.url) {
window.location.href = data.url;
@@ -180,22 +244,22 @@ export const useGameStore = defineStore("game", {
s.connect();
},
- runRoulette(sequence) {
+ runRoulette(sequence: string[]): void {
if (!sequence || sequence.length === 0) return;
// Clear any pending roulette first.
if (this._rouletteTimer) clearInterval(this._rouletteTimer);
if (this.isHost) {
- this.ui_state.team = sequence[sequence.length - 1];
+ this.ui_state.team = sequence[sequence.length - 1] ?? "";
return;
}
const queue = [...sequence];
this._rouletteTimer = setInterval(() => {
if (queue.length === 0) {
- clearInterval(this._rouletteTimer);
+ if (this._rouletteTimer) clearInterval(this._rouletteTimer);
this._rouletteTimer = null;
return;
}
- this.ui_state.team = queue.shift();
+ this.ui_state.team = queue.shift() ?? "";
}, 100);
},
},
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
new file mode 100644
index 0000000..218f2d6
--- /dev/null
+++ b/frontend/src/types.ts
@@ -0,0 +1,163 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Type shapes shared across the front-end. Mirror the JSON the Python back-end
+// emits (see ceopardy/api/routes.py and the /game Socket.IO namespace).
+
+export interface Team {
+ tid: string;
+ name: string;
+ score: number;
+}
+
+export interface QuestionState {
+ answered: boolean;
+ team_scores?: Record;
+}
+
+export type QuestionsMap = Record;
+
+export interface AppConfig {
+ NB_TEAMS?: number;
+ VARIABLE_TEAMS?: boolean;
+ CATEGORIES_PER_GAME?: number;
+ QUESTIONS_PER_CATEGORY?: number;
+ SCORE_TICK?: number;
+ DAILYDOUBLE_WAIGER_MIN?: number;
+ DAILYDOUBLE_WAIGER_MAX_MIN?: number;
+}
+
+export type GameState = "uninitialized" | "in_round" | "in_final" | "finished";
+
+export interface UiState {
+ question: string;
+ team: string;
+ dailydouble: "" | "enabled" | "revealed";
+ message: string;
+ "overlay-big": string;
+ "overlay-small": string;
+ "overlay-question": string;
+ "container-header": string;
+ "container-footer": string;
+ [key: string]: string;
+}
+
+export interface ActiveQuestion {
+ text?: string;
+ category?: string;
+ dailydouble?: boolean;
+}
+
+export interface Range {
+ min: number;
+ max: number;
+}
+
+export interface DailyDoubleWager {
+ team: string;
+ amount: number;
+}
+
+export interface ServerMessage {
+ id?: string;
+ title?: string;
+ text?: string;
+}
+
+// /api/v1/state payload (and the equivalent on the "state" socket event).
+export interface ServerState {
+ config?: AppConfig;
+ game_state?: GameState;
+ teams?: Team[];
+ categories?: string[];
+ questions?: QuestionsMap;
+ state?: Partial;
+ active_question?: ActiveQuestion | null;
+ messages?: ServerMessage[];
+ dailydouble_range?: Range;
+ dailydouble_wager?: DailyDoubleWager | null;
+}
+
+// Socket event payloads (one per `s.on("...")` subscription in the store).
+export interface QuestionShowEvent {
+ qid: string;
+ text: string;
+ category: string;
+ dailydouble: boolean;
+}
+
+export interface DailyDoubleEvent {
+ qid: string;
+ category: string;
+ team?: string;
+ range?: Range;
+}
+
+export interface DailyDoubleRevealEvent {
+ qid: string;
+ text: string;
+ category: string;
+}
+
+export interface DailyDoubleRangeEvent {
+ team?: string;
+ range?: Range;
+}
+
+export interface DailyDoubleWagerEvent {
+ team: string | null;
+ amount: number | null;
+}
+
+export interface BoardUpdateEvent {
+ questions: QuestionsMap;
+ teams?: Team[];
+}
+
+export interface TeamSelectEvent {
+ tid?: string | null;
+}
+
+export interface TeamRouletteEvent {
+ sequence: string[];
+ winner: string;
+}
+
+export interface TeamNamesEvent {
+ names: Record;
+}
+
+export interface OverlayBigEvent {
+ html?: string;
+ id?: string;
+}
+
+export interface SliderEvent {
+ id: string;
+ value: string | number;
+}
+
+export interface RedirectEvent {
+ url?: string;
+}
+
+// REST request/response shapes.
+export interface SubmitAnswerPayload {
+ id: string;
+ answers: Record;
+}
+
+export interface SelectQuestionResponse {
+ result?: string;
+ dailydouble?: boolean;
+ dailydouble_range?: Range;
+ team?: string;
+}
+
+export interface InitPayload {
+ action: "new" | string;
+ [key: string]: unknown;
+}
+
+export interface ApiOk {
+ result?: string;
+ [key: string]: unknown;
+}
diff --git a/frontend/src/views/HostView.vue b/frontend/src/views/HostView.vue
index 93f5ab8..ea7038a 100644
--- a/frontend/src/views/HostView.vue
+++ b/frontend/src/views/HostView.vue
@@ -1,5 +1,5 @@
-
diff --git a/frontend/src/views/StartView.vue b/frontend/src/views/StartView.vue
index b362e91..ba551a3 100644
--- a/frontend/src/views/StartView.vue
+++ b/frontend/src/views/StartView.vue
@@ -1,5 +1,5 @@
-