diff --git a/apps/app/src/components/host/round-brief.test.ts b/apps/app/src/components/host/round-brief.test.ts new file mode 100644 index 0000000..cfa47ba --- /dev/null +++ b/apps/app/src/components/host/round-brief.test.ts @@ -0,0 +1,96 @@ +import {describe, expect, it} from 'vitest' + +import {roundBrief, roundToBrief, spokenDuration} from './round-brief' +import type {BriefRound} from './round-brief' + +const round = (over: Partial = {}): BriefRound => ({ + id: 'r1', + n: 1, + status: 'pending', + turnSeconds: 90, + oddRule: 'trio', + overtime: 'nudge', + overtimeSeconds: 30, + questionPlan: {mode: 'same', question: 'c:abcd'}, + ...over, +}) + +describe('spokenDuration', () => { + it('reads seconds, whole minutes and mixed', () => { + expect(spokenDuration(1)).toBe('1 second') + expect(spokenDuration(90)).toBe('90 seconds') + expect(spokenDuration(120)).toBe('2 minutes') + expect(spokenDuration(150)).toBe('2 min 30 s') + }) +}) + +describe('roundToBrief', () => { + it('prefers the live round, then the lowest pending one', () => { + const done = round({id: 'a', n: 1, status: 'done'}) + const later = round({id: 'c', n: 3}) + const next = round({id: 'b', n: 2}) + expect(roundToBrief([done, later, next])?.id).toBe('b') + expect(roundToBrief([done, round({id: 'l', n: 2, status: 'live'}), later])?.id).toBe('l') + expect(roundToBrief([done])).toBeNull() + expect(roundToBrief([])).toBeNull() + }) +}) + +describe('roundBrief', () => { + it('describes a first same-question voice round', () => { + expect(roundBrief(round(), {answerMode: 'voice', standby: false})).toEqual([ + 'Pair up with someone.', + 'Each of you gets 90 seconds to answer.', + 'Everyone gets the same question.', + 'Speak your answer; it is saved as text for you.', + 'When time is up, you get 30 seconds more to wrap up.', + 'Odd one out joins a pair as a trio.', + ]) + }) + + it('covers later rounds, other modes, hard stops and sitting out', () => { + const lines = roundBrief( + round({ + n: 2, + turnSeconds: 120, + overtime: 'hard', + oddRule: 'sitout', + questionPlan: {mode: 'random', source: 'custom'}, + }), + {answerMode: 'type', standby: false} + ) + expect(lines).toEqual([ + 'Pair with someone new.', + 'Each of you gets 2 minutes to answer.', + 'Each of you draws your own question on your phone.', + 'Type your answer on your phone.', + 'When time is up, the turn ends.', + 'Odd one out sits this round out.', + ]) + }) + + it('treats a zero grace period as a hard stop', () => { + expect(roundBrief(round({overtimeSeconds: 0}), {answerMode: 'none', standby: false})).toContain( + 'When time is up, the turn ends.' + ) + }) + + it('sends the odd one out to a facilitator first, falling back on the round rule', () => { + const lines = roundBrief(round({oddRule: 'sitout'}), {answerMode: 'none', standby: true}) + expect(lines).toContain('Odd one out: find a facilitator. If none is free, sit this round out.') + expect(lines).toContain('Just talk. Nothing is recorded.') + expect(roundBrief(round(), {answerMode: 'none', standby: true})).toContain( + 'Odd one out: find a facilitator. If none is free, join a pair as a trio.' + ) + }) + + it('describes physical and per-turn rounds', () => { + const opts = {answerMode: 'voice', standby: false} as const + expect(roundBrief(round({questionPlan: {mode: 'physical'}}), opts)).toContain( + 'Draw a card from the deck in the room and read it out.' + ) + expect( + roundBrief(round({questionPlan: {mode: 'per-turn', questions: ['c:aaaa', 'c:bbbb']}}), opts) + ).toContain('Each turn has its own question, the same for every pair.') + }) +}) diff --git a/apps/app/src/components/host/round-brief.ts b/apps/app/src/components/host/round-brief.ts new file mode 100644 index 0000000..a66d572 --- /dev/null +++ b/apps/app/src/components/host/round-brief.ts @@ -0,0 +1,64 @@ +import type {AnswerMode, OddRule, Overtime, QuestionPlan, RoundStatus} from '@whocards/game' + +export type BriefRound = { + id: string + n: number + status: RoundStatus + turnSeconds: number + oddRule: OddRule + overtime: Overtime + overtimeSeconds: number + questionPlan: QuestionPlan +} + +/** The live round, else the next one to start; null once every round is done. */ +export const roundToBrief = (rounds: readonly R[]): R | null => + rounds.find((r) => r.status === 'live') ?? + rounds.filter((r) => r.status === 'pending').toSorted((a, b) => a.n - b.n)[0] ?? + null + +/** `90 seconds`, `2 minutes`, `2 min 30 s`. */ +export const spokenDuration = (seconds: number): string => { + if (seconds < 120) return `${seconds} second${seconds === 1 ? '' : 's'}` + const minutes = Math.floor(seconds / 60) + const rest = seconds % 60 + return rest === 0 ? `${minutes} minutes` : `${minutes} min ${rest} s` +} + +const QUESTIONS: Record = { + random: 'Each of you draws your own question on your phone.', + same: 'Everyone gets the same question.', + 'per-turn': 'Each turn has its own question, the same for every pair.', + physical: 'Draw a card from the deck in the room and read it out.', +} + +const ANSWERS: Record = { + voice: 'Speak your answer; it is saved as text for you.', + type: 'Type your answer on your phone.', + none: 'Just talk. Nothing is recorded.', +} + +/** Plain sentences telling the room how a round plays. */ +export const roundBrief = ( + round: BriefRound, + o: {answerMode: AnswerMode; standby: boolean} +): string[] => { + const overtime = + round.overtime === 'hard' || round.overtimeSeconds === 0 + ? 'When time is up, the turn ends.' + : `When time is up, you get ${spokenDuration(round.overtimeSeconds)} more to wrap up.` + const trio = round.oddRule === 'trio' + const odd = o.standby + ? `Odd one out: find a facilitator. If none is free, ${trio ? 'join a pair as a trio' : 'sit this round out'}.` + : trio + ? 'Odd one out joins a pair as a trio.' + : 'Odd one out sits this round out.' + return [ + round.n === 1 ? 'Pair up with someone.' : 'Pair with someone new.', + `Each of you gets ${spokenDuration(round.turnSeconds)} to answer.`, + QUESTIONS[round.questionPlan.mode], + ANSWERS[o.answerMode], + overtime, + odd, + ] +} diff --git a/apps/app/src/components/host/use-host-game.ts b/apps/app/src/components/host/use-host-game.ts new file mode 100644 index 0000000..edf5e42 --- /dev/null +++ b/apps/app/src/components/host/use-host-game.ts @@ -0,0 +1,96 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {redirect} from '@tanstack/react-router' +import {TRPCClientError} from '@trpc/client' +import type {AnswerMode} from '@whocards/game' + +import {trpc} from '~/lib/trpc' +import {roleAtLeast} from '~/server/auth/permissions' +import type {CurrentMember} from '~/server/auth/session' +import type {GameDetail} from '~/server/trpc/routers/games' + +const LIVE_REFRESH_MS = 4000 + +export type Run = (id: string, fn: () => Promise, onDone?: () => void) => void + +/** `beforeLoad` for every host page of one game. */ +export const requireHost = ({context}: {context: Pick}) => { + if (!context.membership || !roleAtLeast(context.membership.role, 'facilitator')) { + throw redirect({to: '/'}) + } +} + +// The server answers CONFLICT when another host moved the game on (a lost race or a stale write). +export const isConflict = (error: Error) => + error instanceof TRPCClientError && error.data?.code === 'CONFLICT' + +export const ANSWER_MODES: Record = { + voice: { + label: 'Voice', + hint: 'Players record; answers turn into text and save in the background.', + }, + type: {label: 'Type', hint: 'No mic. Players type their answer.'}, + none: {label: 'None', hint: 'Nothing captured. Phones only pace turns.'}, +} + +/** The polled game detail plus one-at-a-time host actions keyed by `busy`. */ +export const useHostGame = (gameId: string) => { + const queryClient = useQueryClient() + const key = ['games.detail', gameId] + const detail = useQuery({ + queryKey: key, + queryFn: () => trpc.games.detail.query({gameId}), + refetchInterval: (query) => { + const game = query.state.data?.game + const sending = game?.recap?.pending || game?.revisit?.pending + return game?.status === 'ended' && !sending ? false : LIVE_REFRESH_MS + }, + }) + const act = useMutation({ + mutationFn: (action: {id: string; fn: () => Promise}) => action.fn(), + onMutate: () => queryClient.cancelQueries({queryKey: key}), + onSuccess: async (data) => { + // A poll that started before the write would land after it and flash the old state back. + await queryClient.cancelQueries({queryKey: key}) + if (data) queryClient.setQueryData(key, data) + }, + onError: (error) => { + if (isConflict(error)) void queryClient.invalidateQueries({queryKey: key}) + }, + }) + const busy = act.isPending ? act.variables.id : null + const run: Run = (id, fn, onDone) => { + if (act.isPending) return + act.mutate({id, fn}, {onSuccess: onDone}) + } + + const goLive = () => { + const ok = window.confirm('Go live? Players can join with the code from now on.') + if (ok) run('go-live', () => trpc.games.goLive.mutate({gameId})) + } + const endGame = () => { + const ok = window.confirm( + 'End the game? Any live round stops and players can no longer play. You preview the recap email next and send it when ready.' + ) + if (ok) run('end-game', () => trpc.games.endGame.mutate({gameId})) + } + const startRound = (roundId: string) => + run(`start:${roundId}`, () => trpc.games.startRound.mutate({gameId, roundId})) + const endRound = (roundId: string) => + run(`end:${roundId}`, () => trpc.games.endRound.mutate({gameId, roundId})) + const setAnswerMode = (mode: AnswerMode) => + run(`mode:${mode}`, () => trpc.games.setAnswerMode.mutate({gameId, mode})) + + return { + key, + detail, + act, + busy, + run, + goLive, + endGame, + startRound, + endRound, + setAnswerMode, + pendingMode: busy?.startsWith('mode:') ? busy.slice('mode:'.length) : null, + } +} diff --git a/apps/app/src/components/ui/button.tsx b/apps/app/src/components/ui/button.tsx index 5e75fbc..952dcb1 100644 --- a/apps/app/src/components/ui/button.tsx +++ b/apps/app/src/components/ui/button.tsx @@ -29,14 +29,38 @@ export const buttonVariants = cva( type ButtonVariants = VariantProps +export function Spinner({className}: Readonly<{className?: string}>) { + return ( + + ) +} + export function Button({ className, variant, size, type = 'button', + pending = false, + disabled, + children, ...props -}: React.ComponentProps<'button'> & ButtonVariants) { +}: React.ComponentProps<'button'> & ButtonVariants & {pending?: boolean}) { return ( - ) } diff --git a/apps/app/src/lib/event-links.ts b/apps/app/src/lib/event-links.ts index 30ff5ad..17e6620 100644 --- a/apps/app/src/lib/event-links.ts +++ b/apps/app/src/lib/event-links.ts @@ -10,5 +10,6 @@ export const eventLinks = (code: string) => { screen: `${event}/screen${q}`, me: `${event}/me${q}`, app: `${WEBSITE_ORIGIN}/app`, + play: `${WEBSITE_ORIGIN}/play`, } } diff --git a/apps/app/src/lib/game-actions.ts b/apps/app/src/lib/game-actions.ts new file mode 100644 index 0000000..dec2f91 --- /dev/null +++ b/apps/app/src/lib/game-actions.ts @@ -0,0 +1,11 @@ +export const confirmDeleteGame = (title: string) => + window.confirm( + `Delete “${title}”? Every player’s saved answers are deleted for good and the recap email can no longer be sent. This cannot be undone.` + ) + +export const confirmCopyGame = (title: string) => + window.confirm( + `Copy “${title}” into a new draft? It keeps the organizer, questions, answer mode, rounds and co-facilitators, but no players or answers.` + ) + +export const LIVE_DELETE_HINT = 'End the game to delete it.' diff --git a/apps/app/src/routeTree.gen.ts b/apps/app/src/routeTree.gen.ts index ce8ff9b..c89bc25 100644 --- a/apps/app/src/routeTree.gen.ts +++ b/apps/app/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as AuthedHostIndexRouteImport } from './routes/_authed/host/index import { Route as AuthedHostGameIdRouteImport } from './routes/_authed/host/$gameId' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc/$' +import { Route as AuthedHostGameIdPresentRouteImport } from './routes/_authed/host/$gameId_.present' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -75,6 +76,11 @@ const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ path: '/api/trpc/$', getParentRoute: () => rootRouteImport, } as any) +const AuthedHostGameIdPresentRoute = AuthedHostGameIdPresentRouteImport.update({ + id: '/host/$gameId_/present', + path: '/host/$gameId/present', + getParentRoute: () => AuthedRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { '/api/trpc/$': typeof ApiTrpcSplatRoute '/decks/': typeof AuthedDecksIndexRoute '/host/': typeof AuthedHostIndexRoute + '/host/$gameId/present': typeof AuthedHostGameIdPresentRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -99,6 +106,7 @@ export interface FileRoutesByTo { '/api/trpc/$': typeof ApiTrpcSplatRoute '/decks': typeof AuthedDecksIndexRoute '/host': typeof AuthedHostIndexRoute + '/host/$gameId/present': typeof AuthedHostGameIdPresentRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -113,6 +121,7 @@ export interface FileRoutesById { '/api/trpc/$': typeof ApiTrpcSplatRoute '/_authed/decks/': typeof AuthedDecksIndexRoute '/_authed/host/': typeof AuthedHostIndexRoute + '/_authed/host/$gameId_/present': typeof AuthedHostGameIdPresentRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -127,6 +136,7 @@ export interface FileRouteTypes { | '/api/trpc/$' | '/decks/' | '/host/' + | '/host/$gameId/present' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -139,6 +149,7 @@ export interface FileRouteTypes { | '/api/trpc/$' | '/decks' | '/host' + | '/host/$gameId/present' id: | '__root__' | '/' @@ -152,6 +163,7 @@ export interface FileRouteTypes { | '/api/trpc/$' | '/_authed/decks/' | '/_authed/host/' + | '/_authed/host/$gameId_/present' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -241,6 +253,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTrpcSplatRouteImport parentRoute: typeof rootRouteImport } + '/_authed/host/$gameId_/present': { + id: '/_authed/host/$gameId_/present' + path: '/host/$gameId/present' + fullPath: '/host/$gameId/present' + preLoaderRoute: typeof AuthedHostGameIdPresentRouteImport + parentRoute: typeof AuthedRoute + } } } @@ -251,6 +270,7 @@ interface AuthedRouteChildren { AuthedHostGameIdRoute: typeof AuthedHostGameIdRoute AuthedDecksIndexRoute: typeof AuthedDecksIndexRoute AuthedHostIndexRoute: typeof AuthedHostIndexRoute + AuthedHostGameIdPresentRoute: typeof AuthedHostGameIdPresentRoute } const AuthedRouteChildren: AuthedRouteChildren = { @@ -260,6 +280,7 @@ const AuthedRouteChildren: AuthedRouteChildren = { AuthedHostGameIdRoute: AuthedHostGameIdRoute, AuthedDecksIndexRoute: AuthedDecksIndexRoute, AuthedHostIndexRoute: AuthedHostIndexRoute, + AuthedHostGameIdPresentRoute: AuthedHostGameIdPresentRoute, } const AuthedRouteWithChildren = diff --git a/apps/app/src/routes/_authed.tsx b/apps/app/src/routes/_authed.tsx index 0cf2b6f..4d89cf3 100644 --- a/apps/app/src/routes/_authed.tsx +++ b/apps/app/src/routes/_authed.tsx @@ -1,4 +1,4 @@ -import {createFileRoute, Link, Outlet, redirect} from '@tanstack/react-router' +import {createFileRoute, Link, Outlet, redirect, useMatches} from '@tanstack/react-router' import {useState} from 'react' import {Logo} from '~/components/logo' @@ -9,6 +9,14 @@ import {roleAtLeast} from '~/server/auth/permissions' import {getCurrentMember} from '~/server/auth/session' import type {CurrentMember} from '~/server/auth/session' +declare module '@tanstack/react-router' { + // oxlint-disable-next-line typescript/consistent-type-definitions -- module augmentation only merges into an interface + interface StaticDataRouteOption { + /** Full-screen page: no app header. */ + bare?: boolean + } +} + export const Route = createFileRoute('/_authed')({ beforeLoad: async (): Promise => { const member = await getCurrentMember() @@ -31,10 +39,13 @@ const navActive = { function AuthedLayout() { const {user, membership} = Route.useRouteContext() const [menuOpen, setMenuOpen] = useState(false) + const bare = useMatches({select: (matches) => matches.some((m) => m.staticData.bare)}) const isAdmin = membership ? roleAtLeast(membership.role, 'admin') : false const isHost = membership ? roleAtLeast(membership.role, 'facilitator') : false const close = () => setMenuOpen(false) + if (bare && membership) return + const links = ( <> diff --git a/apps/app/src/routes/_authed/host/$gameId.tsx b/apps/app/src/routes/_authed/host/$gameId.tsx index dc0ee1d..b931841 100644 --- a/apps/app/src/routes/_authed/host/$gameId.tsx +++ b/apps/app/src/routes/_authed/host/$gameId.tsx @@ -1,10 +1,11 @@ -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import {createFileRoute, redirect} from '@tanstack/react-router' -import {TRPCClientError} from '@trpc/client' -import {answerModeSchema, CUSTOM_SOURCE, qrPath, sourceOf} from '@whocards/game' +import {keepPreviousData, useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {createFileRoute, Link, useNavigate} from '@tanstack/react-router' +import {answerModeSchema, CUSTOM_SOURCE, qrPath, questionPlanSchema, sourceOf} from '@whocards/game' import type {AnswerMode, OddRule, Overtime, QuestionPlan} from '@whocards/game' import {useMemo, useState} from 'react' +import {ANSWER_MODES, isConflict, requireHost, useHostGame} from '~/components/host/use-host-game' +import type {Run} from '~/components/host/use-host-game' import {Empty, ErrorText, Page, PageHeader, SectionTitle} from '~/components/page' import {Badge} from '~/components/ui/badge' import {Button, buttonVariants} from '~/components/ui/button' @@ -12,21 +13,15 @@ import {Card} from '~/components/ui/card' import {Input, Label, Select} from '~/components/ui/field' import {cn} from '~/lib/cn' import {eventLinks} from '~/lib/event-links' +import {confirmCopyGame, confirmDeleteGame, LIVE_DELETE_HINT} from '~/lib/game-actions' import {trpc} from '~/lib/trpc' -import {roleAtLeast} from '~/server/auth/permissions' import type {GameDetail} from '~/server/trpc/routers/games' export const Route = createFileRoute('/_authed/host/$gameId')({ - beforeLoad: ({context}) => { - if (!context.membership || !roleAtLeast(context.membership.role, 'facilitator')) { - throw redirect({to: '/'}) - } - }, + beforeLoad: requireHost, component: HostGame, }) -const LIVE_REFRESH_MS = 4000 - type RoundRow = GameDetail['rounds'][number] type CustomRow = GameDetail['questions'][number] type RoundSettings = { @@ -48,17 +43,20 @@ const settingsOf = (r: RoundSettings): RoundSettings => ({ const isOddRule = (v: string): v is OddRule => v === 'trio' || v === 'sitout' const isOvertime = (v: string): v is Overtime => v === 'nudge' || v === 'hard' -const isMode = (v: string): v is Mode => v === 'random' || v === 'same' || v === 'per-turn' +const isMode = (v: string): v is Mode => + questionPlanSchema.options.some((o) => o.shape.mode.value === v) const MODES: Record = { random: {label: 'Random', option: 'Random: each player draws their own'}, same: {label: 'Same question', option: 'Same question for everyone'}, 'per-turn': {label: 'Question per turn', option: 'One question per turn (1st and 2nd speaker)'}, + physical: {label: 'Physical cards', option: 'Physical cards: players draw from a real deck'}, } const planSummary = (round: RoundRow, deckTitle: (slug: string) => string): string => { const plan = round.questionPlan if (plan.mode === 'random') return `From ${deckTitle(plan.source)}` + if (plan.mode === 'physical') return 'Players draw a card from the deck in the room' const [a, b] = round.picks.map((t) => `“${t}”`) return plan.mode === 'same' ? `Everyone: ${a}` : `Turn 1: ${a} · Turn 2: ${b}` } @@ -66,10 +64,6 @@ const planSummary = (round: RoundRow, deckTitle: (slug: string) => string): stri const clock = (ms: number | null) => ms === null ? '' : new Date(ms).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) -// The server answers CONFLICT when another host moved the game on (a lost race or a stale write). -const isConflict = (error: Error) => - error instanceof TRPCClientError && error.data?.code === 'CONFLICT' - const STATUS = { lobby: {label: 'Draft', tone: 'neutral'}, live: {label: 'Live', tone: 'live'}, @@ -79,30 +73,23 @@ const STATUS = { function HostGame() { const {gameId} = Route.useParams() const queryClient = useQueryClient() - const key = ['games.detail', gameId] - const detail = useQuery({ - queryKey: key, - queryFn: () => trpc.games.detail.query({gameId}), - refetchInterval: (query) => { - const game = query.state.data?.game - return game?.status === 'ended' && !game.recap?.pending ? false : LIVE_REFRESH_MS - }, - }) - const act = useMutation({ - mutationFn: (run: () => Promise) => run(), - onSuccess: (data) => queryClient.setQueryData(key, data), - onError: (error) => { - if (isConflict(error)) void queryClient.invalidateQueries({queryKey: key}) - }, - }) + const navigate = useNavigate() + const { + key, + detail, + act, + busy, + run, + goLive, + endGame, + startRound, + endRound, + setAnswerMode, + pendingMode, + } = useHostGame(gameId) const [editingDetails, setEditingDetails] = useState(false) - if (detail.isLoading) - return ( - -

Loading…

-
- ) + if (detail.isLoading) return if (!detail.data) return ( @@ -114,16 +101,25 @@ function HostGame() { const draft = game.status === 'lobby' const status = STATUS[game.status] - const endGame = () => { - const ok = window.confirm( - 'End the game? Any live round stops and players can no longer play. You preview the recap email next and send it when ready.' - ) - if (ok) act.mutate(() => trpc.games.endGame.mutate({gameId})) + const copyGame = () => { + if (!confirmCopyGame(game.title)) return + run('copy-game', async () => { + const copy = await trpc.games.copyGame.mutate({gameId}) + void queryClient.invalidateQueries({queryKey: ['games.list']}) + await navigate({to: '/host/$gameId', params: {gameId: copy.id}}) + return null + }) } - const goLive = () => { - const ok = window.confirm('Go live? Players can join with the code from now on.') - if (ok) act.mutate(() => trpc.games.goLive.mutate({gameId})) + const deleteGame = () => { + if (!confirmDeleteGame(game.title)) return + run('delete-game', async () => { + await trpc.games.deleteGame.mutate({gameId}) + queryClient.removeQueries({queryKey: key}) + void queryClient.invalidateQueries({queryKey: ['games.list']}) + await navigate({to: '/host'}) + return null + }) } return ( @@ -139,23 +135,64 @@ function HostGame() { .filter(Boolean) .join(' · ')} actions={ - ended ? null : ( - <> - {editingDetails ? null : ( - - )} - {draft ? ( - + )} + {draft ? ( + + ) : null} + - ) : null} - + {game.canManageHosts && game.status === 'live' ? ( + {LIVE_DELETE_HINT} + ) : null} + {game.canManageHosts && game.status !== 'live' ? ( + - - ) + ) : null} + } /> @@ -173,12 +210,16 @@ function HostGame() { setEditingDetails(false)} - onSave={(input) => { - setEditingDetails(false) - act.mutate(() => trpc.games.updateGame.mutate({gameId, ...input})) - }} + onSave={(input) => + run( + 'details', + () => trpc.games.updateGame.mutate({gameId, ...input}), + () => setEditingDetails(false) + ) + } /> ) : null} {draft ? ( @@ -190,13 +231,29 @@ function HostGame() { ) : null} {ended ? ( - + <> + trpc.games.recapPreview.query({gameId})} + send={() => trpc.games.sendRecaps.mutate({gameId})} + /> + trpc.games.revisitPreview.query({gameId})} + send={() => trpc.games.sendRevisits.mutate({gameId})} + /> + ) : ( act.mutate(() => trpc.games.setAnswerMode.mutate({gameId, mode}))} + disabled={act.isPending} + pendingMode={pendingMode} + onChange={setAnswerMode} /> )} @@ -210,6 +267,16 @@ function HostGame() { + {ended ? null : ( + run('standby', () => trpc.games.enableStandby.mutate({gameId}))} + /> + )} + act.mutate(fn)} + disabled={act.isPending} + busy={busy} + run={run} + startRound={startRound} + endRound={endRound} /> - act.mutate(fn)} - /> +
{players.length}}> @@ -252,22 +317,26 @@ function HostGame() {

{p.name}

- {`${p.language.toUpperCase()}${p.recap ? ' · wants recap' : ''}`} + {`${p.language.toUpperCase()}${p.standby ? ' · standby facilitator' : ''}${p.recap ? ' · wants recap' : ''}`}

))} @@ -277,6 +346,31 @@ function HostGame() { ) } +const skeleton = 'animate-pulse rounded-2.5xl bg-white/[0.05] motion-reduce:animate-none' + +function HostSkeleton() { + return ( + +
+
+
+
+
+
+
+
+
+ {['players', 'pairs', 'turns', 'answers'].map((k) => ( +
+ ))} +
+
+
+
+ + ) +} + function Stat({label, value}: Readonly<{label: string; value: number}>) { return ( @@ -326,16 +420,79 @@ function JoinPanel({code}: Readonly<{code: string}>) { ) } +function StandbyPanel({ + code, + standbyKey, + pending, + disabled, + onEnable, +}: Readonly<{ + code: string + standbyKey: string | null + pending: boolean + disabled: boolean + onEnable: () => void +}>) { + const link = standbyKey + ? `${eventLinks(code).join}&standby=${encodeURIComponent(standbyKey)}` + : null + const qr = useMemo(() => (link ? qrPath(link) : null), [link]) + return ( + + {qr ? ( + + Facilitator standby QR code + + + + ) : null} +
+

Facilitator on standby

+

+ A facilitator who joins with this link pairs with the odd player out. Keep it off the + projector. +

+ {link ? ( + + Join as facilitator (standby) ↗ + + ) : ( + + )} +
+
+ ) +} + function DetailsEditor({ title: initialTitle, organizer: initialOrganizer, pending, + disabled, onCancel, onSave, }: Readonly<{ title: string organizer: string pending: boolean + disabled: boolean onCancel: () => void onSave: (input: {title: string; organizer: string}) => void }>) { @@ -363,10 +520,10 @@ function DetailsEditor({ />
- -
@@ -375,21 +532,17 @@ function DetailsEditor({ ) } -const ANSWER_MODES: Record = { - voice: {label: 'Voice', hint: 'Players record, read the transcript, and save it.'}, - type: {label: 'Type', hint: 'No mic. Players type their answer.'}, - none: {label: 'None', hint: 'Nothing captured. Phones only pace turns.'}, -} - function AnswerModePanel({ mode, live, - pending, + disabled, + pendingMode, onChange, }: Readonly<{ mode: AnswerMode live: boolean - pending: boolean + disabled: boolean + pendingMode: string | null onChange: (mode: AnswerMode) => void }>) { return ( @@ -407,7 +560,8 @@ function AnswerModePanel({ size="sm" variant={m === mode ? 'selected' : 'secondary'} aria-pressed={m === mode} - disabled={pending} + disabled={disabled} + pending={pendingMode === m} onClick={() => { if (m !== mode) onChange(m) }} @@ -420,54 +574,104 @@ function AnswerModePanel({ ) } -function RecapPanel({ +type MailCopy = { + key: string + title: string + intro?: string + sample: string + real: string + confirm: (recipients: string) => string + status: string +} + +const RECAP_COPY: MailCopy = { + key: 'recap', + title: 'Recap email', + sample: 'Nobody asked for a recap, so this preview uses a sample player.', + real: "Preview of one player's email. Each player gets their own numbers and question.", + confirm: (to) => `Send the recap to ${to}? It goes out once and can't be recalled.`, + status: 'Recap emails', +} + +const REVISIT_COPY: MailCopy = { + key: 'revisit', + title: 'Revisit email', + intro: + 'Optional, for later: the questions each player answered, with a nudge to sit with them again. No answers in it. Goes to players who asked for the recap.', + sample: 'Nobody would get this yet, so the preview uses a sample with the room’s questions.', + real: "Preview of one player's email. Each player gets the questions they answered.", + confirm: (to) => `Send the revisit email to ${to}? It goes out once and can't be recalled.`, + status: 'Revisit emails', +} + +type MailPreview = {recipients: number; sample: boolean; html: string} + +function MailPanel({ gameId, - recap, -}: Readonly<{gameId: string; recap: GameDetail['game']['recap']}>) { + state, + copy, + preview: fetchPreview, + send: sendNow, +}: Readonly<{ + gameId: string + state: GameDetail['game']['recap'] + copy: MailCopy + preview: () => Promise + send: () => Promise +}>) { const queryClient = useQueryClient() const preview = useQuery({ - queryKey: ['games.recapPreview', gameId], - queryFn: () => trpc.games.recapPreview.query({gameId}), + queryKey: ['games.mailPreview', copy.key, gameId], + queryFn: fetchPreview, staleTime: Infinity, }) const send = useMutation({ - mutationFn: () => trpc.games.sendRecaps.mutate({gameId}), - onSuccess: (data) => queryClient.setQueryData(['games.detail', gameId], data), + mutationFn: sendNow, + onSuccess: async (data) => { + await queryClient.cancelQueries({queryKey: ['games.detail', gameId]}) + queryClient.setQueryData(['games.detail', gameId], data) + }, onError: (error) => { if (isConflict(error)) void queryClient.invalidateQueries({queryKey: ['games.detail', gameId]}) }, }) // A send that delivered nothing can go again; anything else is final. - const canSend = !recap || (!recap.pending && recap.sent === 0) + const canSend = !state || (!state.pending && state.sent === 0) const recipients = preview.data?.recipients ?? 0 const plural = recipients === 1 ? 'player' : 'players' const status = () => { - if (!recap) return null - if (recap.pending) return

Sending…

+ if (!state) return null + if (state.pending) return

Sending…

return ( -

0 ? 'text-errorOnDark' : 'text-gray-lighter')}> - {`Recap emails: ${recap.sent} sent, ${recap.failed} failed${recap.error ? ` (${recap.error})` : ''}.`} +

0 ? 'text-errorOnDark' : 'text-gray-lighter')}> + {`${copy.status}: ${state.sent} sent, ${state.failed} failed${state.error ? ` (${state.error})` : ''}.`}

) } return (
- Recap email + {copy.title} - {preview.isLoading ?

Rendering preview…

: null} + {copy.intro ?

{copy.intro}

: null} + {preview.isLoading ? ( +
+ Rendering preview… +
+ ) : null} {preview.isError ? {preview.error.message} : null} {preview.data ? ( <>

- {preview.data.sample - ? 'Nobody asked for a recap, so this preview uses a sample player.' - : `Preview of one player's email. Each player gets their own numbers and question.`} + {preview.data.sample ? copy.sample : copy.real}