Compare commits
18 Commits
pr-153
...
dev/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| b6617fc356 | |||
| fa6c5e30c9 | |||
| cd3c604ba6 | |||
| 1aa296c45c | |||
| ea8954e702 | |||
| 61eb08ad73 | |||
| 37b86d7065 | |||
| 2e25d32ba1 | |||
| 825f8c599b | |||
| 87ba42c68a | |||
| 2882a7737b | |||
| a9868ae450 | |||
| d6c7a36730 | |||
| de99e456c7 | |||
| 79c4734fe6 | |||
| c8c27346a8 | |||
| 994e2930d5 | |||
| 3e0cb9cee7 |
18
docs/spa-cutover-flag.md
Normal file
18
docs/spa-cutover-flag.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# SPA cutover feature flag (`USE_SPA_UI`)
|
||||||
|
|
||||||
|
## Formål
|
||||||
|
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
|
||||||
|
|
||||||
|
## Miljø-toggle (uden kodeændring)
|
||||||
|
Sæt env var pr. miljø:
|
||||||
|
|
||||||
|
- `USE_SPA_UI=true` -> `/lobby/ui/host` og `/lobby/ui/player` returnerer SPA shell
|
||||||
|
- `USE_SPA_UI=false` (default) -> legacy template-flow bruges uændret
|
||||||
|
|
||||||
|
Backward compatibility under cutover:
|
||||||
|
- Hvis `USE_SPA_UI` ikke er sat, bruges `WPP_SPA_ENABLED` som fallback.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
|
||||||
|
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
|
||||||
|
- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled`
|
||||||
1
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Frontend API client baseline
|
||||||
|
|
||||||
|
Dette er baseline-klientlaget for SPA-sporet.
|
||||||
|
|
||||||
|
## Kører checks lokalt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
1454
frontend/package-lock.json
generated
Normal file
1454
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/package.json
Normal file
15
frontend/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "wpp-frontend-api-client-baseline",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"build": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/src/api/angular-client.ts
Normal file
62
frontend/src/api/angular-client.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ApiFailure, ApiResult, HealthResponse, SessionDetailResponse } from './types';
|
||||||
|
|
||||||
|
export interface AngularHttpError {
|
||||||
|
status?: number;
|
||||||
|
message?: string;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AngularHttpClientLike {
|
||||||
|
get<T>(url: string, options?: { withCredentials?: boolean }): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AngularApiClient {
|
||||||
|
health(): Promise<ApiResult<HealthResponse>>;
|
||||||
|
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFailure(error: unknown): ApiFailure {
|
||||||
|
const candidate = (error ?? {}) as AngularHttpError;
|
||||||
|
const status = typeof candidate.status === 'number' ? candidate.status : 0;
|
||||||
|
const payload = candidate.error;
|
||||||
|
|
||||||
|
if (status === 0) {
|
||||||
|
return {
|
||||||
|
kind: 'network',
|
||||||
|
status: 0,
|
||||||
|
message: candidate.message ?? 'Network error while contacting API'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'http',
|
||||||
|
status,
|
||||||
|
message: candidate.message ?? `HTTP ${status}`,
|
||||||
|
...(payload === undefined ? {} : { payload })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCode(code: string): string {
|
||||||
|
return code.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapGet<T>(call: () => Promise<T>): Promise<ApiResult<T>> {
|
||||||
|
try {
|
||||||
|
const data = await call();
|
||||||
|
return { ok: true, status: 200, data };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { ok: false, status: typeof (error as AngularHttpError)?.status === 'number' ? (error as AngularHttpError).status! : 0, error: toFailure(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient {
|
||||||
|
return {
|
||||||
|
health: () => wrapGet(() => http.get<HealthResponse>(`${baseUrl}/healthz`, { withCredentials: true })),
|
||||||
|
getSession: (code: string) =>
|
||||||
|
wrapGet(() =>
|
||||||
|
http.get<SessionDetailResponse>(`${baseUrl}/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`, {
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
81
frontend/src/api/client.ts
Normal file
81
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type {
|
||||||
|
ApiResult,
|
||||||
|
HealthResponse,
|
||||||
|
JoinSessionRequest,
|
||||||
|
JoinSessionResponse,
|
||||||
|
SessionDetailResponse,
|
||||||
|
StartRoundRequest,
|
||||||
|
StartRoundResponse
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export interface ApiClient {
|
||||||
|
health(): Promise<ApiResult<HealthResponse>>;
|
||||||
|
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
|
||||||
|
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
|
||||||
|
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
|
||||||
|
async function request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<ApiResult<T>> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||||
|
},
|
||||||
|
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
error: { kind: 'network', status: 0, message: 'Network error while contacting API' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let responsePayload: unknown;
|
||||||
|
try {
|
||||||
|
responsePayload = await response.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: response.status,
|
||||||
|
error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: response.status,
|
||||||
|
error: {
|
||||||
|
kind: 'http',
|
||||||
|
status: response.status,
|
||||||
|
message: `HTTP ${response.status}`,
|
||||||
|
payload: responsePayload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, status: response.status, data: responsePayload as T };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
health: () => request<HealthResponse>('/healthz', 'GET'),
|
||||||
|
getSession: (code: string) =>
|
||||||
|
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, 'GET'),
|
||||||
|
joinSession: (payload: JoinSessionRequest) =>
|
||||||
|
request<JoinSessionResponse>('/lobby/sessions/join', 'POST', {
|
||||||
|
code: payload.code.trim().toUpperCase(),
|
||||||
|
nickname: payload.nickname.trim()
|
||||||
|
}),
|
||||||
|
startRound: (code: string, payload: StartRoundRequest) =>
|
||||||
|
request<StartRoundResponse>(
|
||||||
|
`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`,
|
||||||
|
'POST',
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
115
frontend/src/api/types.ts
Normal file
115
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
export interface HealthResponse {
|
||||||
|
ok: boolean;
|
||||||
|
service: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
host_id: number | null;
|
||||||
|
current_round: number;
|
||||||
|
players_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionPlayer {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
score: number;
|
||||||
|
is_connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionAnswer {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRoundQuestion {
|
||||||
|
id: number;
|
||||||
|
round_number: number;
|
||||||
|
prompt: string;
|
||||||
|
shown_at: string;
|
||||||
|
answers: SessionAnswer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseViewModel {
|
||||||
|
status: string;
|
||||||
|
round_number: number;
|
||||||
|
players_count: number;
|
||||||
|
constraints: {
|
||||||
|
min_players_to_start: number;
|
||||||
|
max_players_mvp: number;
|
||||||
|
min_players_reached: boolean;
|
||||||
|
max_players_allowed: boolean;
|
||||||
|
};
|
||||||
|
host: {
|
||||||
|
can_start_round: boolean;
|
||||||
|
can_show_question: boolean;
|
||||||
|
can_mix_answers: boolean;
|
||||||
|
can_calculate_scores: boolean;
|
||||||
|
can_reveal_scoreboard: boolean;
|
||||||
|
can_start_next_round: boolean;
|
||||||
|
can_finish_game: boolean;
|
||||||
|
};
|
||||||
|
player: {
|
||||||
|
can_join: boolean;
|
||||||
|
can_submit_lie: boolean;
|
||||||
|
can_submit_guess: boolean;
|
||||||
|
can_view_final_result: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDetailResponse {
|
||||||
|
session: SessionSummary;
|
||||||
|
players: SessionPlayer[];
|
||||||
|
round_question: SessionRoundQuestion | null;
|
||||||
|
phase_view_model: PhaseViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinSessionRequest {
|
||||||
|
code: string;
|
||||||
|
nickname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinSessionResponse {
|
||||||
|
player: {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
session_token: string;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
session: {
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRoundRequest {
|
||||||
|
category_slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRoundResponse {
|
||||||
|
session: {
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
current_round: number;
|
||||||
|
};
|
||||||
|
round: {
|
||||||
|
number: number;
|
||||||
|
category: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiErrorKind = 'network' | 'http' | 'parse';
|
||||||
|
|
||||||
|
export interface ApiFailure {
|
||||||
|
kind: ApiErrorKind;
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResult<T> =
|
||||||
|
| { ok: true; status: number; data: T }
|
||||||
|
| { ok: false; status: number; error: ApiFailure };
|
||||||
87
frontend/src/spa/vertical-slice.ts
Normal file
87
frontend/src/spa/vertical-slice.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { ApiClient } from '../api/client';
|
||||||
|
import type { SessionDetailResponse } from '../api/types';
|
||||||
|
|
||||||
|
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface VerticalSliceState {
|
||||||
|
sessionCode: string;
|
||||||
|
session: SessionDetailResponse | null;
|
||||||
|
joinState: AsyncState;
|
||||||
|
startRoundState: AsyncState;
|
||||||
|
loadingSession: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerticalSliceController {
|
||||||
|
getState(): VerticalSliceState;
|
||||||
|
hydrateLobby(sessionCode: string): Promise<VerticalSliceState>;
|
||||||
|
joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState>;
|
||||||
|
startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVerticalSliceController(api: ApiClient): VerticalSliceController {
|
||||||
|
const state: VerticalSliceState = {
|
||||||
|
sessionCode: '',
|
||||||
|
session: null,
|
||||||
|
joinState: 'idle',
|
||||||
|
startRoundState: 'idle',
|
||||||
|
loadingSession: false,
|
||||||
|
errorMessage: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCode = (value: string): string => value.trim().toUpperCase();
|
||||||
|
|
||||||
|
async function hydrateLobby(sessionCode: string): Promise<VerticalSliceState> {
|
||||||
|
state.loadingSession = true;
|
||||||
|
state.errorMessage = null;
|
||||||
|
state.sessionCode = normalizeCode(sessionCode);
|
||||||
|
|
||||||
|
const result = await api.getSession(state.sessionCode);
|
||||||
|
state.loadingSession = false;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
state.errorMessage = 'Kunne ikke hente lobby-status.';
|
||||||
|
return { ...state };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.session = result.data;
|
||||||
|
return { ...state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState> {
|
||||||
|
state.joinState = 'loading';
|
||||||
|
state.errorMessage = null;
|
||||||
|
|
||||||
|
const join = await api.joinSession({ code: sessionCode, nickname });
|
||||||
|
if (!join.ok) {
|
||||||
|
state.joinState = 'error';
|
||||||
|
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
|
||||||
|
return { ...state };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.joinState = 'success';
|
||||||
|
return hydrateLobby(sessionCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {
|
||||||
|
state.startRoundState = 'loading';
|
||||||
|
state.errorMessage = null;
|
||||||
|
|
||||||
|
const start = await api.startRound(sessionCode, { category_slug: categorySlug });
|
||||||
|
if (!start.ok) {
|
||||||
|
state.startRoundState = 'error';
|
||||||
|
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
|
||||||
|
return { ...state };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.startRoundState = 'success';
|
||||||
|
return hydrateLobby(sessionCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState: () => ({ ...state }),
|
||||||
|
hydrateLobby,
|
||||||
|
joinLobby,
|
||||||
|
startRound
|
||||||
|
};
|
||||||
|
}
|
||||||
92
frontend/tests/angular-api-client.test.ts
Normal file
92
frontend/tests/angular-api-client.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
|
||||||
|
|
||||||
|
describe('createAngularApiClient', () => {
|
||||||
|
it('reads health and session detail using Django-compatible endpoints', async () => {
|
||||||
|
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
|
||||||
|
if (url === '/healthz') {
|
||||||
|
return { ok: true, service: 'partyhub' } as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === '/lobby/sessions/ABCD12') {
|
||||||
|
return {
|
||||||
|
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
|
||||||
|
players: [
|
||||||
|
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
|
||||||
|
{ id: 3, nickname: 'Bo', score: 0, is_connected: false }
|
||||||
|
],
|
||||||
|
round_question: null,
|
||||||
|
phase_view_model: {
|
||||||
|
status: 'lobby',
|
||||||
|
round_number: 1,
|
||||||
|
players_count: 2,
|
||||||
|
constraints: {
|
||||||
|
min_players_to_start: 2,
|
||||||
|
max_players_mvp: 8,
|
||||||
|
min_players_reached: true,
|
||||||
|
max_players_allowed: true
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
can_start_round: true,
|
||||||
|
can_show_question: false,
|
||||||
|
can_mix_answers: false,
|
||||||
|
can_calculate_scores: false,
|
||||||
|
can_reveal_scoreboard: false,
|
||||||
|
can_start_next_round: false,
|
||||||
|
can_finish_game: false
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
can_join: true,
|
||||||
|
can_submit_lie: false,
|
||||||
|
can_submit_guess: false,
|
||||||
|
can_view_final_result: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw { status: 404, error: { error: 'Not found' } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const http = { get };
|
||||||
|
const client = createAngularApiClient(http as AngularHttpClientLike);
|
||||||
|
|
||||||
|
const health = await client.health();
|
||||||
|
expect(health.ok).toBe(true);
|
||||||
|
if (health.ok) {
|
||||||
|
expect(health.data.ok).toBe(true);
|
||||||
|
expect(health.data.service).toBe('partyhub');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await client.getSession(' abcd12 ');
|
||||||
|
expect(session.ok).toBe(true);
|
||||||
|
if (session.ok) {
|
||||||
|
expect(session.data.session.code).toBe('ABCD12');
|
||||||
|
expect(session.data.session.host_id).toBe(1);
|
||||||
|
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(get).toHaveBeenNthCalledWith(1, '/healthz', { withCredentials: true });
|
||||||
|
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', { withCredentials: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps HttpErrorResponse-style failures to ApiResult errors', async () => {
|
||||||
|
const http = {
|
||||||
|
get: vi.fn<AngularHttpClientLike['get']>(async () => {
|
||||||
|
throw { status: 503, message: 'Service unavailable', error: { error: 'maintenance' } };
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = createAngularApiClient(http as AngularHttpClientLike);
|
||||||
|
const result = await client.health();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.status).toBe(503);
|
||||||
|
expect(result.error.kind).toBe('http');
|
||||||
|
expect(result.error.payload).toEqual({ error: 'maintenance' });
|
||||||
|
expect(result.error.message).toContain('Service unavailable');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
148
frontend/tests/api-client.integration.test.ts
Normal file
148
frontend/tests/api-client.integration.test.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { AddressInfo } from 'node:net';
|
||||||
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||||
|
import { createApiClient } from '../src/api/client';
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
if (req.url === '/healthz') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === '/lobby/sessions/ABCD12' && req.method === 'GET') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
|
||||||
|
players: [],
|
||||||
|
round_question: null,
|
||||||
|
phase_view_model: {
|
||||||
|
status: 'lobby',
|
||||||
|
round_number: 1,
|
||||||
|
players_count: 3,
|
||||||
|
constraints: {
|
||||||
|
min_players_to_start: 3,
|
||||||
|
max_players_mvp: 5,
|
||||||
|
min_players_reached: true,
|
||||||
|
max_players_allowed: true
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
can_start_round: true,
|
||||||
|
can_show_question: false,
|
||||||
|
can_mix_answers: false,
|
||||||
|
can_calculate_scores: false,
|
||||||
|
can_reveal_scoreboard: false,
|
||||||
|
can_start_next_round: false,
|
||||||
|
can_finish_game: false
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
can_join: true,
|
||||||
|
can_submit_lie: false,
|
||||||
|
can_submit_guess: false,
|
||||||
|
can_view_final_result: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === '/lobby/sessions/join' && req.method === 'POST') {
|
||||||
|
res.writeHead(201, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 },
|
||||||
|
session: { code: 'ABCD12', status: 'lobby' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === '/lobby/sessions/ABCD12/rounds/start' && req.method === 'POST') {
|
||||||
|
res.writeHead(201, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
||||||
|
round: { number: 1, category: { slug: 'history', name: 'History' } }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url?.startsWith('/lobby/sessions/')) {
|
||||||
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Session not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(500, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'unexpected route' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((err?: Error) => (err ? reject(err) : resolve()))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createApiClient', () => {
|
||||||
|
it('reads health + session detail through typed wrappers', async () => {
|
||||||
|
const client = createApiClient(baseUrl);
|
||||||
|
|
||||||
|
const health = await client.health();
|
||||||
|
expect(health.ok).toBe(true);
|
||||||
|
|
||||||
|
const session = await client.getSession('abcd12');
|
||||||
|
expect(session.ok).toBe(true);
|
||||||
|
if (session.ok) {
|
||||||
|
expect(session.data.session.code).toBe('ABCD12');
|
||||||
|
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports join + start round writes for lobby vertical slice', async () => {
|
||||||
|
const client = createApiClient(baseUrl);
|
||||||
|
|
||||||
|
const join = await client.joinSession({ code: 'abcd12', nickname: 'Maja' });
|
||||||
|
expect(join.ok).toBe(true);
|
||||||
|
|
||||||
|
const start = await client.startRound('abcd12', { category_slug: 'history' });
|
||||||
|
expect(start.ok).toBe(true);
|
||||||
|
if (start.ok) {
|
||||||
|
expect(start.data.session.status).toBe('lie');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns consistent HTTP error shape for 4xx/5xx', async () => {
|
||||||
|
const client = createApiClient(baseUrl);
|
||||||
|
|
||||||
|
const missing = await client.getSession('missing');
|
||||||
|
expect(missing.ok).toBe(false);
|
||||||
|
if (!missing.ok) {
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.error.kind).toBe('http');
|
||||||
|
expect(missing.error.payload).toEqual({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns consistent network error shape', async () => {
|
||||||
|
const client = createApiClient('http://127.0.0.1:9');
|
||||||
|
|
||||||
|
const health = await client.health();
|
||||||
|
expect(health.ok).toBe(false);
|
||||||
|
if (!health.ok) {
|
||||||
|
expect(health.error.kind).toBe('network');
|
||||||
|
expect(health.status).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
115
frontend/tests/vertical-slice.test.ts
Normal file
115
frontend/tests/vertical-slice.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createVerticalSliceController } from '../src/spa/vertical-slice';
|
||||||
|
import type { ApiClient } from '../src/api/client';
|
||||||
|
|
||||||
|
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
|
||||||
|
const base: ApiClient = {
|
||||||
|
health: vi.fn(),
|
||||||
|
getSession: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
|
||||||
|
players: [],
|
||||||
|
round_question: null,
|
||||||
|
phase_view_model: {
|
||||||
|
status: 'lobby',
|
||||||
|
round_number: 1,
|
||||||
|
players_count: 3,
|
||||||
|
constraints: {
|
||||||
|
min_players_to_start: 3,
|
||||||
|
max_players_mvp: 5,
|
||||||
|
min_players_reached: true,
|
||||||
|
max_players_allowed: true
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
can_start_round: true,
|
||||||
|
can_show_question: false,
|
||||||
|
can_mix_answers: false,
|
||||||
|
can_calculate_scores: false,
|
||||||
|
can_reveal_scoreboard: false,
|
||||||
|
can_start_next_round: false,
|
||||||
|
can_finish_game: false
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
can_join: true,
|
||||||
|
can_submit_lie: false,
|
||||||
|
can_submit_guess: false,
|
||||||
|
can_view_final_result: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
joinSession: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
data: { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }
|
||||||
|
}),
|
||||||
|
startRound: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
data: {
|
||||||
|
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
||||||
|
round: { number: 1, category: { slug: 'history', name: 'History' } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...base, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||||
|
it('tracks loading and success state for join + start flow', async () => {
|
||||||
|
const api = makeApiMock();
|
||||||
|
const controller = createVerticalSliceController(api);
|
||||||
|
|
||||||
|
const beforeJoinPromise = controller.joinLobby('abcd12', 'Maja');
|
||||||
|
expect(controller.getState().joinState).toBe('loading');
|
||||||
|
await beforeJoinPromise;
|
||||||
|
|
||||||
|
const postJoin = controller.getState();
|
||||||
|
expect(postJoin.joinState).toBe('success');
|
||||||
|
expect(postJoin.session?.session.code).toBe('ABCD12');
|
||||||
|
|
||||||
|
const beforeStartPromise = controller.startRound('abcd12', 'history');
|
||||||
|
expect(controller.getState().startRoundState).toBe('loading');
|
||||||
|
await beforeStartPromise;
|
||||||
|
|
||||||
|
const postStart = controller.getState();
|
||||||
|
expect(postStart.startRoundState).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a friendly error when join fails', async () => {
|
||||||
|
const api = makeApiMock({
|
||||||
|
joinSession: vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = createVerticalSliceController(api);
|
||||||
|
await controller.joinLobby('missing', 'Maja');
|
||||||
|
|
||||||
|
const state = controller.getState();
|
||||||
|
expect(state.joinState).toBe('error');
|
||||||
|
expect(state.errorMessage).toContain('Join fejlede');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a friendly error when round start fails', async () => {
|
||||||
|
const api = makeApiMock({
|
||||||
|
startRound: vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = createVerticalSliceController(api);
|
||||||
|
await controller.startRound('ABCD12', 'history');
|
||||||
|
|
||||||
|
const state = controller.getState();
|
||||||
|
expect(state.startRoundState).toBe('error');
|
||||||
|
expect(state.errorMessage).toContain('Kunne ikke starte runden');
|
||||||
|
});
|
||||||
|
});
|
||||||
12
frontend/tsconfig.json
Normal file
12
frontend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": ["vitest/globals", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests"]
|
||||||
|
}
|
||||||
8
frontend/vitest.config.ts
Normal file
8
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
exclude: ['**/node_modules/**']
|
||||||
|
}
|
||||||
|
});
|
||||||
6
lobby/feature_flags.py
Normal file
6
lobby/feature_flags.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def use_spa_ui() -> bool:
|
||||||
|
"""Central read-point for SPA cutover flag."""
|
||||||
|
return bool(getattr(settings, "USE_SPA_UI", False))
|
||||||
@@ -28,6 +28,10 @@
|
|||||||
<button id="autoRefreshToggleBtn" onclick="toggleAutoRefresh()">Auto-refresh: OFF</button>
|
<button id="autoRefreshToggleBtn" onclick="toggleAutoRefresh()">Auto-refresh: OFF</button>
|
||||||
<p id="autoRefreshHint">Auto-refresh er slået fra.</p>
|
<p id="autoRefreshHint">Auto-refresh er slået fra.</p>
|
||||||
<p id="lastRefreshStatus">Session-data ikke opdateret endnu.</p>
|
<p id="lastRefreshStatus">Session-data ikke opdateret endnu.</p>
|
||||||
|
<div id="hostShellErrorBoundary" style="display:none;margin:8px 0 10px;padding:8px 10px;border-radius:8px;border:1px solid #b91c1c;background:#fee2e2;color:#7f1d1d;">Der opstod en kritisk fejl i app-skallen.
|
||||||
|
<button id="hostRecoverRetryBtn" type="button" onclick="recoverHostShell('retry')">Prøv gendan</button>
|
||||||
|
<button id="hostRecoverReloadBtn" type="button" onclick="recoverHostShell('reload')">Genindlæs siden</button>
|
||||||
|
</div>
|
||||||
<pre id="out">Klar.</pre>
|
<pre id="out">Klar.</pre>
|
||||||
<script>
|
<script>
|
||||||
var currentSessionStatus="";
|
var currentSessionStatus="";
|
||||||
@@ -37,13 +41,23 @@ var hostActionInFlight=false;
|
|||||||
var lastRefreshAtLabel="";
|
var lastRefreshAtLabel="";
|
||||||
var lastRefreshFailed=false;
|
var lastRefreshFailed=false;
|
||||||
var sessionDetailInFlight=false;
|
var sessionDetailInFlight=false;
|
||||||
|
var hostShellRouteHint="";
|
||||||
|
var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",finished:"finished"};
|
||||||
|
var hostShellFatalError=false;
|
||||||
|
var hostShellRecoverInFlight=false;
|
||||||
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
|
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
|
||||||
|
function updateHostShellErrorBoundary(){var panel=document.getElementById("hostShellErrorBoundary");var retryBtn=document.getElementById("hostRecoverRetryBtn");var reloadBtn=document.getElementById("hostRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=hostShellFatalError?"block":"none";retryBtn.disabled=hostShellRecoverInFlight||sessionDetailInFlight||!code();reloadBtn.disabled=hostShellRecoverInFlight;}
|
||||||
|
function setHostShellFatalError(detail){hostShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"host_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("hostErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updateHostShellErrorBoundary();}
|
||||||
|
function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();}
|
||||||
|
function recoverHostShell(mode){if(hostShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}hostShellRecoverInFlight=true;updateHostShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearHostShellFatalError();return result;}).catch(function(err){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();throw err;});}
|
||||||
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
||||||
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
||||||
|
|
||||||
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
|
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
|
||||||
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
|
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
|
||||||
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
|
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
|
||||||
|
function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";}
|
||||||
|
function expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";}
|
||||||
|
function syncHostShellRoute(){var currentRoute=hostShellRouteFromPath();var expectedRoute=expectedHostShellRoute();if(!currentRoute||!expectedRoute){hostShellRouteHint="";return;}if(currentRoute===expectedRoute){hostShellRouteHint="";return;}var nextPath="/lobby/ui/host/"+expectedRoute;window.history.replaceState(null,"",nextPath);hostShellRouteHint="Deep-link route guard: omdirigeret fra /"+currentRoute+" til /"+expectedRoute+" for fase "+phaseLabel(currentSessionStatus)+".";}
|
||||||
function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggleBtn");var hint=document.getElementById("autoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(autoRefreshEnabled?"ON":"OFF");btn.disabled=hostActionInFlight||sessionDetailInFlight||!code();}if(!hint){return;}if(hostActionInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv host-handling.";return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(!autoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(!code()){hint.textContent="Auto-refresh venter: angiv sessionkode.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for lobby-status.";}
|
function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggleBtn");var hint=document.getElementById("autoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(autoRefreshEnabled?"ON":"OFF");btn.disabled=hostActionInFlight||sessionDetailInFlight||!code();}if(!hint){return;}if(hostActionInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv host-handling.";return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(!autoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(!code()){hint.textContent="Auto-refresh venter: angiv sessionkode.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for lobby-status.";}
|
||||||
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
|
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
|
||||||
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
|
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
|
||||||
@@ -54,12 +68,14 @@ function updateLastRefreshStatus(){var el=document.getElementById("lastRefreshSt
|
|||||||
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
|
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
|
||||||
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
|
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
|
||||||
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
||||||
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";return;}hint.textContent="Session-opdatering klar.";}
|
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updateHostShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updateHostShellErrorBoundary();}
|
||||||
|
|
||||||
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
|
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
|
||||||
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
|
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
|
||||||
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
|
|
||||||
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}saveHostContext();return d;}
|
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
|
||||||
|
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;}
|
||||||
|
|
||||||
function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});}
|
function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});}
|
||||||
function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});}
|
function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});}
|
||||||
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
|
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
|
||||||
@@ -72,7 +88,9 @@ function nextRound(){return withHostActionLock(function(){return api("/lobby/ses
|
|||||||
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});}
|
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});}
|
||||||
["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
|
["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
|
||||||
|
|
||||||
updatePhaseStatus();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();
|
window.addEventListener("error",function(event){setHostShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
|
||||||
if(restoreHostContext()){updatePhaseStatus();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
|
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setHostShellFatalError(detail);});
|
||||||
|
updatePhaseStatus();syncHostShellRoute();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();updateHostShellErrorBoundary();
|
||||||
|
if(restoreHostContext()){updatePhaseStatus();syncHostShellRoute();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
|
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
|
||||||
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
||||||
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
#playerShellErrorBoundary { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
|
||||||
|
#playerShellErrorBoundary button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
||||||
|
#playerShellErrorBoundary button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
||||||
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
|
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
|
||||||
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
|
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
@@ -42,6 +45,10 @@
|
|||||||
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
|
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
|
||||||
<p id="playerErrorHint">Ingen fejl.</p>
|
<p id="playerErrorHint">Ingen fejl.</p>
|
||||||
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
|
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
|
||||||
|
<div id="playerShellErrorBoundary">Der opstod en kritisk fejl i app-skallen.
|
||||||
|
<button id="playerRecoverRetryBtn" type="button" onclick="recoverPlayerShell('retry')">Prøv gendan</button>
|
||||||
|
<button id="playerRecoverReloadBtn" type="button" onclick="recoverPlayerShell('reload')">Genindlæs siden</button>
|
||||||
|
</div>
|
||||||
<pre id="out">Klar.</pre>
|
<pre id="out">Klar.</pre>
|
||||||
<script>
|
<script>
|
||||||
var availableAnswers=[];
|
var availableAnswers=[];
|
||||||
@@ -59,7 +66,13 @@ var playerLastRefreshAtLabel="";
|
|||||||
var playerLastRefreshFailed=false;
|
var playerLastRefreshFailed=false;
|
||||||
var connectionLost=false;
|
var connectionLost=false;
|
||||||
var connectionRetryInFlight=false;
|
var connectionRetryInFlight=false;
|
||||||
|
var playerShellFatalError=false;
|
||||||
|
var playerShellRecoverInFlight=false;
|
||||||
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
||||||
|
function updatePlayerShellErrorBoundary(){var panel=document.getElementById("playerShellErrorBoundary");var retryBtn=document.getElementById("playerRecoverRetryBtn");var reloadBtn=document.getElementById("playerRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=playerShellFatalError?"block":"none";retryBtn.disabled=playerShellRecoverInFlight||sessionDetailInFlight||joinInFlight||!code();reloadBtn.disabled=playerShellRecoverInFlight;}
|
||||||
|
function setPlayerShellFatalError(detail){playerShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"player_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("playerErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updatePlayerShellErrorBoundary();}
|
||||||
|
function clearPlayerShellFatalError(){playerShellFatalError=false;playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();}
|
||||||
|
function recoverPlayerShell(mode){if(playerShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}playerShellRecoverInFlight=true;updatePlayerShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearPlayerShellFatalError();return result;}).catch(function(err){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();throw err;});}
|
||||||
function pid(){return document.getElementById("playerId").value.trim();}
|
function pid(){return document.getElementById("playerId").value.trim();}
|
||||||
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
||||||
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
|
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
|
||||||
@@ -86,7 +99,7 @@ function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();
|
|||||||
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
|
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
|
||||||
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
|
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
|
||||||
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
||||||
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();}
|
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();}
|
||||||
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
|
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
|
||||||
|
|
||||||
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
|
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
|
||||||
@@ -102,7 +115,7 @@ function updateGuessSubmitState(){var selected=document.getElementById("guessTex
|
|||||||
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
|
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
|
||||||
updateJoinState();}
|
updateJoinState();}
|
||||||
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
|
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
|
||||||
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}try{var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
|
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}try{var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}if(playerShellFatalError){clearPlayerShellFatalError();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
|
||||||
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";throw err;});}
|
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";throw err;});}
|
||||||
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
|
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
|
||||||
function retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
|
function retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
|
||||||
@@ -110,6 +123,8 @@ function submitLie(){if(lieSubmitted){return Promise.resolve({error:"lie_already
|
|||||||
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
|
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
|
||||||
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
|
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
|
||||||
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
|
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
|
||||||
|
window.addEventListener("error",function(event){setPlayerShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
|
||||||
|
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setPlayerShellFatalError(detail);});
|
||||||
updatePhaseStatus();
|
updatePhaseStatus();
|
||||||
updateGuessSubmitState();
|
updateGuessSubmitState();
|
||||||
updateJoinState();
|
updateJoinState();
|
||||||
@@ -117,6 +132,7 @@ updatePlayerAutoRefreshUi();
|
|||||||
updatePlayerLastRefreshStatus();
|
updatePlayerLastRefreshStatus();
|
||||||
updateRoundContextHint();
|
updateRoundContextHint();
|
||||||
updateConnectionBanner();
|
updateConnectionBanner();
|
||||||
|
updatePlayerShellErrorBoundary();
|
||||||
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();}
|
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();}
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
12
lobby/templates/lobby/spa_shell.html
Normal file
12
lobby/templates/lobby/spa_shell.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WPP SPA</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root data-wpp-shell-route="{{ shell_route }}"></app-root>
|
||||||
|
<script type="module" src="{{ spa_asset_base }}/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
129
lobby/tests.py
129
lobby/tests.py
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -869,6 +869,14 @@ class UiScreenTests(TestCase):
|
|||||||
self.assertContains(response, "hostShellRouteFromPath")
|
self.assertContains(response, "hostShellRouteFromPath")
|
||||||
self.assertContains(response, "syncHostShellRoute")
|
self.assertContains(response, "syncHostShellRoute")
|
||||||
self.assertContains(response, "Deep-link route guard: omdirigeret")
|
self.assertContains(response, "Deep-link route guard: omdirigeret")
|
||||||
|
self.assertContains(response, "id=\"hostShellErrorBoundary\"")
|
||||||
|
self.assertContains(response, "recoverHostShell('retry')")
|
||||||
|
self.assertContains(response, "recoverHostShell('reload')")
|
||||||
|
self.assertContains(response, "setHostShellFatalError")
|
||||||
|
self.assertContains(response, "clearHostShellFatalError")
|
||||||
|
self.assertContains(response, "updateHostShellErrorBoundary")
|
||||||
|
self.assertContains(response, "host_shell_runtime_error")
|
||||||
|
self.assertContains(response, "window.addEventListener(\"unhandledrejection\"")
|
||||||
|
|
||||||
def test_host_screen_deeplink_requires_login(self):
|
def test_host_screen_deeplink_requires_login(self):
|
||||||
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
||||||
@@ -956,6 +964,46 @@ class UiScreenTests(TestCase):
|
|||||||
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
||||||
self.assertContains(response, "Sidst opdateret:")
|
self.assertContains(response, "Sidst opdateret:")
|
||||||
self.assertContains(response, "Session-data kan være forældet")
|
self.assertContains(response, "Session-data kan være forældet")
|
||||||
|
self.assertContains(response, "id=\"playerShellErrorBoundary\"")
|
||||||
|
self.assertContains(response, "recoverPlayerShell('retry')")
|
||||||
|
self.assertContains(response, "recoverPlayerShell('reload')")
|
||||||
|
self.assertContains(response, "setPlayerShellFatalError")
|
||||||
|
self.assertContains(response, "clearPlayerShellFatalError")
|
||||||
|
self.assertContains(response, "updatePlayerShellErrorBoundary")
|
||||||
|
self.assertContains(response, "player_shell_runtime_error")
|
||||||
|
self.assertContains(response, "window.addEventListener(\"error\"")
|
||||||
|
|
||||||
|
@override_settings(USE_SPA_UI=False)
|
||||||
|
def test_legacy_templates_are_used_when_spa_flag_is_off(self):
|
||||||
|
self.client.login(username="host_ui", password="secret123")
|
||||||
|
|
||||||
|
host_response = self.client.get(reverse("lobby:host_screen"))
|
||||||
|
player_response = self.client.get(reverse("lobby:player_screen"))
|
||||||
|
|
||||||
|
self.assertContains(host_response, "Host panel")
|
||||||
|
self.assertContains(player_response, "Player panel")
|
||||||
|
self.assertNotContains(host_response, "<app-root")
|
||||||
|
self.assertNotContains(player_response, "<app-root")
|
||||||
|
|
||||||
|
@override_settings(USE_SPA_UI=True)
|
||||||
|
def test_host_screen_can_render_angular_shell_when_feature_flag_enabled(self):
|
||||||
|
self.client.login(username="host_ui", password="secret123")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("lobby:host_screen"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<app-root")
|
||||||
|
self.assertContains(response, "data-wpp-shell-route=\"/host\"")
|
||||||
|
self.assertContains(response, "/static/frontend/angular/browser/main.js")
|
||||||
|
|
||||||
|
@override_settings(USE_SPA_UI=True)
|
||||||
|
def test_player_screen_can_render_angular_shell_when_feature_flag_enabled(self):
|
||||||
|
response = self.client.get(reverse("lobby:player_screen"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<app-root")
|
||||||
|
self.assertContains(response, "data-wpp-shell-route=\"/player\"")
|
||||||
|
self.assertContains(response, "/static/frontend/angular/browser/main.js")
|
||||||
|
|
||||||
|
|
||||||
class SessionDetailRoundQuestionTests(TestCase):
|
class SessionDetailRoundQuestionTests(TestCase):
|
||||||
@@ -986,6 +1034,85 @@ class SessionDetailRoundQuestionTests(TestCase):
|
|||||||
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
|
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDetailPhaseViewModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.host = User.objects.create_user(username="host_phase", password="secret123")
|
||||||
|
self.session = GameSession.objects.create(host=self.host, code="PHASE1", status=GameSession.Status.LOBBY)
|
||||||
|
|
||||||
|
def test_session_detail_includes_shared_phase_view_model_contract(self):
|
||||||
|
Player.objects.create(session=self.session, nickname="P1")
|
||||||
|
Player.objects.create(session=self.session, nickname="P2")
|
||||||
|
Player.objects.create(session=self.session, nickname="P3")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
phase = response.json()["phase_view_model"]
|
||||||
|
self.assertEqual(phase["status"], GameSession.Status.LOBBY)
|
||||||
|
self.assertEqual(phase["round_number"], 1)
|
||||||
|
self.assertEqual(phase["players_count"], 3)
|
||||||
|
self.assertEqual(phase["constraints"]["min_players_to_start"], 3)
|
||||||
|
self.assertEqual(phase["constraints"]["max_players_mvp"], 5)
|
||||||
|
self.assertTrue(phase["constraints"]["min_players_reached"])
|
||||||
|
self.assertTrue(phase["constraints"]["max_players_allowed"])
|
||||||
|
self.assertTrue(phase["host"]["can_start_round"])
|
||||||
|
self.assertFalse(phase["host"]["can_show_question"])
|
||||||
|
self.assertTrue(phase["player"]["can_join"])
|
||||||
|
self.assertFalse(phase["player"]["can_submit_lie"])
|
||||||
|
self.assertFalse(phase["player"]["can_submit_guess"])
|
||||||
|
|
||||||
|
def test_phase_view_model_flags_change_with_round_phase(self):
|
||||||
|
category = Category.objects.create(name="Kultur", slug="kultur", is_active=True)
|
||||||
|
question = Question.objects.create(
|
||||||
|
category=category,
|
||||||
|
prompt="Hvilket land kommer sushi fra?",
|
||||||
|
correct_answer="Japan",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=1,
|
||||||
|
question=question,
|
||||||
|
correct_answer="Japan",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.session.status = GameSession.Status.LIE
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
lie_phase = lie_payload["phase_view_model"]
|
||||||
|
self.assertFalse(lie_phase["host"]["can_show_question"])
|
||||||
|
self.assertTrue(lie_phase["host"]["can_mix_answers"])
|
||||||
|
self.assertTrue(lie_phase["player"]["can_submit_lie"])
|
||||||
|
self.assertFalse(lie_phase["player"]["can_submit_guess"])
|
||||||
|
|
||||||
|
self.session.status = GameSession.Status.GUESS
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
guess_phase = guess_payload["phase_view_model"]
|
||||||
|
self.assertTrue(guess_phase["host"]["can_mix_answers"])
|
||||||
|
self.assertTrue(guess_phase["host"]["can_calculate_scores"])
|
||||||
|
self.assertFalse(guess_phase["player"]["can_submit_lie"])
|
||||||
|
self.assertTrue(guess_phase["player"]["can_submit_guess"])
|
||||||
|
|
||||||
|
round_question.delete()
|
||||||
|
self.session.status = GameSession.Status.REVEAL
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
reveal_phase = reveal_payload["phase_view_model"]
|
||||||
|
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
|
||||||
|
self.assertTrue(reveal_phase["host"]["can_start_next_round"])
|
||||||
|
self.assertTrue(reveal_phase["host"]["can_finish_game"])
|
||||||
|
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
|
||||||
|
|
||||||
|
self.session.status = GameSession.Status.FINISHED
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
finished_phase = finished_payload["phase_view_model"]
|
||||||
|
self.assertFalse(finished_phase["player"]["can_join"])
|
||||||
|
self.assertTrue(finished_phase["player"]["can_view_final_result"])
|
||||||
|
|
||||||
class SmokeStagingCommandTests(TestCase):
|
class SmokeStagingCommandTests(TestCase):
|
||||||
def test_smoke_staging_command_runs_full_flow(self):
|
def test_smoke_staging_command_runs_full_flow(self):
|
||||||
call_command("smoke_staging")
|
call_command("smoke_staging")
|
||||||
|
|||||||
@@ -1,14 +1,34 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
from fupogfakta.models import Category
|
from fupogfakta.models import Category
|
||||||
|
|
||||||
|
from .feature_flags import use_spa_ui
|
||||||
|
|
||||||
|
|
||||||
|
def _render_spa_shell(request, shell_route: str):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"lobby/spa_shell.html",
|
||||||
|
{
|
||||||
|
"shell_route": shell_route,
|
||||||
|
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def host_screen(request):
|
def host_screen(request, spa_path=None):
|
||||||
|
if use_spa_ui():
|
||||||
|
return _render_spa_shell(request, "/host")
|
||||||
|
|
||||||
categories = Category.objects.filter(is_active=True).order_by("name")
|
categories = Category.objects.filter(is_active=True).order_by("name")
|
||||||
return render(request, "lobby/host_screen.html", {"categories": categories})
|
return render(request, "lobby/host_screen.html", {"categories": categories})
|
||||||
|
|
||||||
|
|
||||||
def player_screen(request):
|
def player_screen(request):
|
||||||
|
if use_spa_ui():
|
||||||
|
return _render_spa_shell(request, "/player")
|
||||||
|
|
||||||
return render(request, "lobby/player_screen.html")
|
return render(request, "lobby/player_screen.html")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ app_name = "lobby"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("ui/host", ui_views.host_screen, name="host_screen"),
|
path("ui/host", ui_views.host_screen, name="host_screen"),
|
||||||
|
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
|
||||||
path("ui/player", ui_views.player_screen, name="player_screen"),
|
path("ui/player", ui_views.player_screen, name="player_screen"),
|
||||||
path("sessions/create", views.create_session, name="create_session"),
|
path("sessions/create", views.create_session, name="create_session"),
|
||||||
path("sessions/join", views.join_session, name="join_session"),
|
path("sessions/join", views.join_session, name="join_session"),
|
||||||
|
|||||||
@@ -58,6 +58,45 @@ def _create_unique_session_code() -> str:
|
|||||||
raise RuntimeError("Could not generate unique session code")
|
raise RuntimeError("Could not generate unique session code")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
|
||||||
|
status = session.status
|
||||||
|
in_lobby = status == GameSession.Status.LOBBY
|
||||||
|
in_lie = status == GameSession.Status.LIE
|
||||||
|
in_guess = status == GameSession.Status.GUESS
|
||||||
|
in_reveal = status == GameSession.Status.REVEAL
|
||||||
|
in_finished = status == GameSession.Status.FINISHED
|
||||||
|
|
||||||
|
min_players_reached = players_count >= 3
|
||||||
|
max_players_allowed = players_count <= 5
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"round_number": session.current_round,
|
||||||
|
"players_count": players_count,
|
||||||
|
"constraints": {
|
||||||
|
"min_players_to_start": 3,
|
||||||
|
"max_players_mvp": 5,
|
||||||
|
"min_players_reached": min_players_reached,
|
||||||
|
"max_players_allowed": max_players_allowed,
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
|
||||||
|
"can_show_question": in_lie and not has_round_question,
|
||||||
|
"can_mix_answers": in_lie or in_guess,
|
||||||
|
"can_calculate_scores": in_guess,
|
||||||
|
"can_reveal_scoreboard": in_reveal,
|
||||||
|
"can_start_next_round": in_reveal,
|
||||||
|
"can_finish_game": in_reveal,
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"can_join": status in JOINABLE_STATUSES,
|
||||||
|
"can_submit_lie": in_lie and has_round_question,
|
||||||
|
"can_submit_guess": in_guess and has_round_question,
|
||||||
|
"can_view_final_result": in_finished,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@login_required
|
@login_required
|
||||||
def create_session(request: HttpRequest) -> JsonResponse:
|
def create_session(request: HttpRequest) -> JsonResponse:
|
||||||
@@ -155,6 +194,12 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
|
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
phase_view_model = _build_phase_view_model(
|
||||||
|
session,
|
||||||
|
players_count=len(players),
|
||||||
|
has_round_question=bool(current_round_question),
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"session": {
|
"session": {
|
||||||
@@ -166,6 +211,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
},
|
},
|
||||||
"players": players,
|
"players": players,
|
||||||
"round_question": round_question_payload,
|
"round_question": round_question_payload,
|
||||||
|
"phase_view_model": phase_view_model,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
|
||||||
|
USE_SPA_UI_RAW = env('USE_SPA_UI')
|
||||||
|
if USE_SPA_UI_RAW is None:
|
||||||
|
# Backward-compatible fallback while cutover is rolling out.
|
||||||
|
USE_SPA_UI_RAW = env('WPP_SPA_ENABLED', 'false')
|
||||||
|
USE_SPA_UI = USE_SPA_UI_RAW.lower() == 'true'
|
||||||
|
WPP_SPA_ASSET_BASE = env('WPP_SPA_ASSET_BASE', '/static/frontend/angular/browser').rstrip('/')
|
||||||
|
|
||||||
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')
|
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')
|
||||||
CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379'))
|
CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379'))
|
||||||
CHANNEL_LAYERS = {
|
CHANNEL_LAYERS = {
|
||||||
|
|||||||
Reference in New Issue
Block a user