Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
be4f106
fix(game): join consent screen follows the answer mode
acharlop Oct 15, 2026
2c686c1
fix(host): loading skeleton, per-action pending states, no refetch fl…
acharlop Oct 15, 2026
2310485
Merge branch 'fix/342-host-ui' into t3code/workshop-screen-fixes
acharlop Oct 15, 2026
f623482
fix(sdd): brand projector + player pages, sane timer, calm loading (#…
acharlop Oct 15, 2026
9cac33b
Merge branch 'fix/342-website-ui' into t3code/workshop-screen-fixes
acharlop Oct 15, 2026
b3d4323
fix(game): small-group pairing, standby facilitators, pair by name
acharlop Oct 15, 2026
313dc47
feat(game): thumbs up/down on your own question after answering
acharlop Oct 15, 2026
481a902
Merge branch 'fix/342-pairing' into t3code/workshop-screen-fixes
acharlop Oct 15, 2026
bb19a3c
Merge branch 'fix/342-question-vote' into t3code/workshop-screen-fixes
acharlop Oct 15, 2026
3b4af63
fix(host): standby link button uses the per-action pending flow
acharlop Oct 15, 2026
1e0d4cc
fix(host): standby link button uses the per-action pending flow
acharlop Oct 15, 2026
9200e7b
feat(game): physical-cards round mode
acharlop Oct 15, 2026
7c4419a
feat(game): optional "revisit your questions" email after the workshop
acharlop Oct 15, 2026
326e788
Merge branch 'fix/342-revisit-email' into t3code/workshop-screen-fixes
acharlop Oct 15, 2026
a594518
fix(game): consent copy covers the revisit email
acharlop Oct 15, 2026
1852292
feat(host): copy or delete a workshop, even once ended (#342)
acharlop Oct 15, 2026
7a41afd
Merge branch 'fix/342-delete-copy' into t3code/workshop-screen-fixes
acharlop Oct 15, 2026
88115a5
feat(host): projector mode with join panel, round brief and on-screen…
acharlop Oct 15, 2026
3eda9dc
Merge branch 'fix/342-present-mode' into t3code/workshop-screen-fixes
acharlop Oct 15, 2026
d06dadd
fix(game): address issue 342 review findings
acharlop Oct 15, 2026
9fd403c
feat(game): save recorded answers in the background (#342)
acharlop Oct 15, 2026
e5f9480
Merge branch 'fix/342-review' into fix/342-background-transcribe
acharlop Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions apps/app/src/components/host/round-brief.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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.')
})
})
64 changes: 64 additions & 0 deletions apps/app/src/components/host/round-brief.ts
Original file line number Diff line number Diff line change
@@ -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 = <R extends BriefRound>(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<QuestionPlan['mode'], string> = {
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<AnswerMode, string> = {
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,
]
}
96 changes: 96 additions & 0 deletions apps/app/src/components/host/use-host-game.ts
Original file line number Diff line number Diff line change
@@ -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<GameDetail | null>, onDone?: () => void) => void

/** `beforeLoad` for every host page of one game. */
export const requireHost = ({context}: {context: Pick<CurrentMember, 'membership'>}) => {
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<AnswerMode, {label: string; hint: string}> = {
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<GameDetail | null>}) => 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,
}
}
28 changes: 26 additions & 2 deletions apps/app/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,38 @@ export const buttonVariants = cva(

type ButtonVariants = VariantProps<typeof buttonVariants>

export function Spinner({className}: Readonly<{className?: string}>) {
return (
<span
aria-hidden
className={cn(
'size-4 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none',
className
)}
/>
)
}

export function Button({
className,
variant,
size,
type = 'button',
pending = false,
disabled,
children,
...props
}: React.ComponentProps<'button'> & ButtonVariants) {
}: React.ComponentProps<'button'> & ButtonVariants & {pending?: boolean}) {
return (
<button type={type} className={cn(buttonVariants({variant, size}), className)} {...props} />
<button
type={type}
disabled={disabled || pending}
aria-busy={pending || undefined}
className={cn(buttonVariants({variant, size}), className)}
{...props}
>
{pending ? <Spinner /> : null}
{children}
</button>
)
}
1 change: 1 addition & 0 deletions apps/app/src/lib/event-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
}
}
11 changes: 11 additions & 0 deletions apps/app/src/lib/game-actions.ts
Original file line number Diff line number Diff line change
@@ -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.'
Loading
Loading