From 056e88c77d53938b7b449be9bf3d7db43b0dccdd Mon Sep 17 00:00:00 2001 From: malpou Date: Wed, 29 Jul 2026 13:03:10 +0200 Subject: [PATCH] Order poker roster, expand distribution cards, add coffee break - roster sorts A-Z, and by card low-to-high once revealed - distribution cards expand to name who played them - anyone can call or end a coffee break in any phase --- messages/da.json | 4 + messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + migrations/0015_poker_break.sql | 7 + openspec/specs/DESIGN.md | 12 ++ .../planning-poker/planning-poker.spec.ts | 136 ++++++++++++++++++ openspec/specs/planning-poker/spec.md | 57 +++++++- .../components/organisms/LandingPoker.svelte | 2 +- src/lib/components/poker/Break.svelte | 39 +++++ src/lib/components/poker/Signal.svelte | 44 +++++- src/lib/data/poker.ts | 14 +- src/lib/logic/poker-snapshot.test.ts | 30 ++++ src/lib/logic/poker-snapshot.ts | 36 +++-- src/lib/types.ts | 3 + .../poker/api/[token]/command/+server.ts | 13 ++ src/routes/poker/c/[token]/+page.svelte | 12 +- src/routes/poker/j/[token]/+page.svelte | 20 ++- 19 files changed, 421 insertions(+), 24 deletions(-) create mode 100644 migrations/0015_poker_break.sql create mode 100644 src/lib/components/poker/Break.svelte diff --git a/messages/da.json b/messages/da.json index f4366ef..d739692 100644 --- a/messages/da.json +++ b/messages/da.json @@ -273,6 +273,10 @@ "pokerSignalClose": "Næsten enige, kun ét trin fra hinanden.", "pokerSignalSpread": "Spredt. Tid til at tale om det.", "pokerBreakHint": "Nogen har brug for en pause.", + "pokerCallBreak": "Kaffepause", + "pokerBreakCalledBy": "{name} har kaldt kaffepause.", + "pokerBreakCalledAnon": "Der er kaldt kaffepause.", + "pokerEndBreak": "Tilbage til det", "pokerSuggested": "Foreslået estimat: {value}", "pokerResultsHeading": "Estimeret indtil nu", "pokerNoResults": "Intet estimeret endnu.", diff --git a/messages/de.json b/messages/de.json index 2161353..d9c34fc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -273,6 +273,10 @@ "pokerSignalClose": "Fast geschafft, nur einen Schritt auseinander.", "pokerSignalSpread": "Weit auseinander. Zeit zu reden.", "pokerBreakHint": "Jemand braucht eine Pause.", + "pokerCallBreak": "Kaffeepause", + "pokerBreakCalledBy": "{name} hat eine Kaffeepause ausgerufen.", + "pokerBreakCalledAnon": "Kaffeepause ausgerufen.", + "pokerEndBreak": "Weiter geht’s", "pokerSuggested": "Vorgeschlagene Schätzung: {value}", "pokerResultsHeading": "Bisher geschätzt", "pokerNoResults": "Noch nichts geschätzt.", diff --git a/messages/en.json b/messages/en.json index 40950e7..dd29162 100644 --- a/messages/en.json +++ b/messages/en.json @@ -273,6 +273,10 @@ "pokerSignalClose": "Almost there, one step apart.", "pokerSignalSpread": "Spread. Time to talk it through.", "pokerBreakHint": "Someone needs a break.", + "pokerCallBreak": "Coffee break", + "pokerBreakCalledBy": "{name} called a coffee break.", + "pokerBreakCalledAnon": "Coffee break called.", + "pokerEndBreak": "Back to it", "pokerSuggested": "Suggested estimate: {value}", "pokerResultsHeading": "Estimated so far", "pokerNoResults": "Nothing estimated yet.", diff --git a/messages/es.json b/messages/es.json index a630aee..f417450 100644 --- a/messages/es.json +++ b/messages/es.json @@ -273,6 +273,10 @@ "pokerSignalClose": "Casi, a un paso de distancia.", "pokerSignalSpread": "Dispersión. Hora de hablarlo.", "pokerBreakHint": "Alguien necesita un descanso.", + "pokerCallBreak": "Pausa para el café", + "pokerBreakCalledBy": "{name} ha pedido una pausa para el café.", + "pokerBreakCalledAnon": "Se ha pedido una pausa para el café.", + "pokerEndBreak": "Volvemos", "pokerSuggested": "Estimación sugerida: {value}", "pokerResultsHeading": "Estimado hasta ahora", "pokerNoResults": "Nada estimado todavía.", diff --git a/messages/fr.json b/messages/fr.json index 4ce070d..7873cde 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -273,6 +273,10 @@ "pokerSignalClose": "Presque, à un pas d’écart.", "pokerSignalSpread": "Dispersé. Le moment d’en parler.", "pokerBreakHint": "Quelqu’un a besoin d’une pause.", + "pokerCallBreak": "Pause café", + "pokerBreakCalledBy": "{name} a demandé une pause café.", + "pokerBreakCalledAnon": "Pause café demandée.", + "pokerEndBreak": "On reprend", "pokerSuggested": "Estimation suggérée : {value}", "pokerResultsHeading": "Estimé jusqu’ici", "pokerNoResults": "Rien d’estimé pour l’instant.", diff --git a/migrations/0015_poker_break.sql b/migrations/0015_poker_break.sql new file mode 100644 index 0000000..821599c --- /dev/null +++ b/migrations/0015_poker_break.sql @@ -0,0 +1,7 @@ +-- A coffee break can be called at any time, not only by playing the ☕ card in +-- a voting round (openspec/specs/planning-poker "Call a coffee break"). +-- +-- One nullable column carries the whole feature: NULL = no break, otherwise the +-- display name of whoever called it (empty string when the caller has no seat). +-- Advisory only - it blocks no phase, so no phase/status column changes. +ALTER TABLE poker_rooms ADD COLUMN break_called_by TEXT; diff --git a/openspec/specs/DESIGN.md b/openspec/specs/DESIGN.md index a84922c..f5782fa 100644 --- a/openspec/specs/DESIGN.md +++ b/openspec/specs/DESIGN.md @@ -224,6 +224,18 @@ theme token instead. (with the suggested value pushed right), neutral `card-alt` + ink dot for **close**, and `bad-tint` + `bad` dot for **spread** (borrowing the "no" semantics). A coffee break-hint sits below as quiet caption text. + - **Coffee break** sits above the phase panel in both room views: a ghost + button with the `Coffee` icon while the room is working, replaced by a + highlighter-tint callout (the `Coffee` icon in place of the notice dot, + the caller's name, a trailing ghost "back to it") while a break stands. + Same tokens as the other callouts — the break is loud by position, not by + a new colour. + - **Distribution stacks** under the signal are native `
`: the card + plus its `×count` is the summary (marker hidden, a 12px `ChevronDown` that + rotates 180° when open is the only affordance), and the voters' names list + beneath in `ink-soft` caption text, alphabetical. Disclosure elsewhere + follows this shape — no custom accordion, no hover-only reveal, so touch + and keyboard get the same thing. ## Motion diff --git a/openspec/specs/planning-poker/planning-poker.spec.ts b/openspec/specs/planning-poker/planning-poker.spec.ts index 2c09441..8aa4bba 100644 --- a/openspec/specs/planning-poker/planning-poker.spec.ts +++ b/openspec/specs/planning-poker/planning-poker.spec.ts @@ -86,6 +86,31 @@ test('joining by name appears in the roster for everyone live', async ({ browser await c.close(); }); +test('the roster reads alphabetically whoever joined first', async ({ browser }) => { + const c = await openController(browser); + // Joined out of order on purpose: the roster must not show join order. + const charlie = await joinParticipant(browser, 'Charlie'); + const alice = await joinParticipant(browser, 'Alice'); + const bob = await joinParticipant(browser, 'Bob'); + + const roster = c.page.locator('section', { hasText: m.pokerRosterHeading() }); + await expect(roster.locator('li')).toHaveText([/Alice/, /Bob/, /Charlie/]); + + // Still alphabetical while the votes land - order is by name until reveal. + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-42'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + for (const p of [charlie, alice, bob]) { + await p.page.getByTestId('poker-active-item').waitFor(); + await p.page.getByRole('button', { name: '5', exact: true }).click(); + } + await expect(roster.locator('li')).toHaveText([/Alice/, /Bob/, /Charlie/]); + + await charlie.close(); + await alice.close(); + await bob.close(); + await c.close(); +}); + test('a refresh resumes the same seat, no duplicate', async ({ browser }) => { const p = await joinParticipant(browser, 'Alice'); // Seated: the join form is gone. @@ -176,6 +201,65 @@ test('votes stay hidden until the reveal, then flip face-up', async ({ browser } await c.close(); }); +test('the revealed roster reads by card, low to high', async ({ browser }) => { + const c = await openController(browser); + // Alphabetically Alice, Bob, Charlie - by card the other way round. + const a = await joinParticipant(browser, 'Alice'); + const b = await joinParticipant(browser, 'Bob'); + const ch = await joinParticipant(browser, 'Charlie'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-42'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + for (const [pg, card] of [ + [a.page, '13'], + [b.page, '8'], + [ch.page, '3'] + ] as const) { + await pg.getByTestId('poker-active-item').waitFor(); + await pg.getByRole('button', { name: card, exact: true }).click(); + } + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + + const roster = c.page.locator('section', { hasText: m.pokerRosterHeading() }); + await expect(roster.locator('li')).toHaveText([/Charlie/, /Bob/, /Alice/]); + + await a.close(); + await b.close(); + await ch.close(); + await c.close(); +}); + +test('a distribution card expands to name who played it', async ({ browser }) => { + const c = await openController(browser); + const a = await joinParticipant(browser, 'Alice'); + const b = await joinParticipant(browser, 'Bob'); + const d = await joinParticipant(browser, 'Dave'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-42'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + for (const [pg, card] of [ + [a.page, '5'], + [b.page, '5'], + [d.page, '8'] + ] as const) { + await pg.getByTestId('poker-active-item').waitFor(); + await pg.getByRole('button', { name: card, exact: true }).click(); + } + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + + // Stacks sit in deck order, so the first one is the "5". + const five = c.page.getByTestId('poker-distribution').locator('details').first(); + await five.locator('summary').click(); + await expect(five.getByText('Alice')).toBeVisible(); + await expect(five.getByText('Bob')).toBeVisible(); + await expect(five.getByText('Dave')).toHaveCount(0); + + await a.close(); + await b.close(); + await d.close(); + await c.close(); +}); + // --- Requirement: Agreement signal + Controller records the final estimate --- test('the room agrees, the suggestion is offered, and the estimate is recorded', async ({ @@ -249,6 +333,58 @@ test('an infinity vote forces a spread even when the numbers agree', async ({ br await c.close(); }); +// --- Requirement: Call a coffee break --- + +test('a break called between items reaches the whole room, named', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + + // Waiting phase, no item open - the break is still available. + await p.page.getByRole('button', { name: m.pokerCallBreak() }).click(); + await expect(c.page.getByText(m.pokerBreakCalledBy({ name: 'Alice' }))).toBeVisible(); + await expect(p.page.getByText(m.pokerBreakCalledBy({ name: 'Alice' }))).toBeVisible(); + + await p.close(); + await c.close(); +}); + +test('a break called mid-round is ended by anyone and holds nothing up', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-99'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + await p.page.getByTestId('poker-active-item').waitFor(); + + await p.page.getByRole('button', { name: m.pokerCallBreak() }).click(); + await expect(c.page.getByTestId('poker-break')).toBeVisible(); + + // The controller (who did not call it) ends it; voting was never blocked. + await c.page.getByRole('button', { name: m.pokerEndBreak() }).click(); + await expect(p.page.getByTestId('poker-break')).toHaveCount(0); + await p.page.getByRole('button', { name: '5', exact: true }).click(); + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + await expect(c.page.getByText(m.pokerSignalAgree())).toBeVisible(); + + await p.close(); + await c.close(); +}); + +test('opening the next item ends a standing break', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + + await p.page.getByRole('button', { name: m.pokerCallBreak() }).click(); + await expect(c.page.getByTestId('poker-break')).toBeVisible(); + + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-100'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + await expect(c.page.getByTestId('poker-break')).toHaveCount(0); + await expect(p.page.getByTestId('poker-break')).toHaveCount(0); + + await p.close(); + await c.close(); +}); + // --- Requirement: Close the room --- test('closing the room ends estimation for participants', async ({ browser }) => { diff --git a/openspec/specs/planning-poker/spec.md b/openspec/specs/planning-poker/spec.md index a89195a..b491601 100644 --- a/openspec/specs/planning-poker/spec.md +++ b/openspec/specs/planning-poker/spec.md @@ -73,7 +73,11 @@ SHALL remember the participant's identity in their browser so a refresh or a dropped connection resumes the same seat rather than creating a duplicate. A participant MAY join as an observer who watches without casting a vote. Joining a room whose status is "closed" SHALL NOT allow voting and SHALL show -the final results log. +the final results log. The roster SHALL be ordered alphabetically by display +name, the same order for every viewer, and SHALL NOT reorder as the room +updates — except in the revealed phase, where it SHALL be ordered by the card +each seat played, lowest first, with the special cards after the numerals and +seats that played nothing last. #### Scenario: Join by naming yourself @@ -82,6 +86,19 @@ the final results log. - THEN they appear in the live roster under that name - AND every already-connected participant sees the new seat appear live +#### Scenario: The roster reads alphabetically + +- GIVEN a room joined by several participants in a non-alphabetical order +- WHEN the roster is shown +- THEN the seats are listed alphabetically by name +- AND the order stays the same as votes are cast + +#### Scenario: The revealed roster reads by card + +- GIVEN a revealed room where the seats played different cards +- WHEN the roster is shown +- THEN the seats are listed by the card they played, lowest first + #### Scenario: Refresh resumes the same seat - GIVEN a participant who has joined and been remembered by their browser @@ -173,7 +190,8 @@ message that arrives when the phase is not voting SHALL be rejected. When the controller reveals, every cast vote SHALL become visible to all connected participants at the same time, and the system SHALL show the -distribution of votes across the deck. +distribution of votes across the deck. Each card in the distribution SHALL be +expandable to name the participants who played it. #### Scenario: Reveal flips all votes at once @@ -182,6 +200,13 @@ distribution of votes across the deck. - THEN every participant sees all cast cards face-up together - AND the distribution of votes across the deck is shown +#### Scenario: A distribution card names who played it + +- GIVEN a revealed room where two participants played the same card +- WHEN a viewer expands that card in the distribution +- THEN both their names are shown under it +- AND names of participants who played a different card are not + ### Requirement: Special cards The deck SHALL include three special cards: **?** (need more info), **∞** @@ -205,6 +230,34 @@ affecting the numeric agreement. - THEN a "someone needs a break" hint is shown - AND the ☕ vote does not change the numeric agreement signal +### Requirement: Call a coffee break + +Anyone in an open room SHALL be able to call a coffee break at any time, +whatever the phase and without an item being voted on, and everyone in the +room SHALL see it live, named after whoever called it. Anyone in the room +SHALL be able to end the break. A break SHALL be advisory: it blocks no +action, and voting, revealing, and recording an estimate carry on unaffected. +Opening the next item, or closing the room, SHALL end a standing break. + +#### Scenario: A break is called between items + +- GIVEN an open room in the waiting phase with no item being voted on +- WHEN a participant calls a coffee break +- THEN everyone in the room sees that a break was called, naming them + +#### Scenario: A break is called mid-round and ended + +- GIVEN a room in the voting phase where a break has been called +- WHEN someone in the room ends the break +- THEN the break notice disappears for everyone +- AND the round is still open for voting exactly as before + +#### Scenario: The next item ends the break + +- GIVEN a room with a standing coffee break +- WHEN the controller opens voting on the next item +- THEN the break is over for everyone + ### Requirement: Agreement signal On reveal the system SHALL classify the numeric votes as **agree**, diff --git a/src/lib/components/organisms/LandingPoker.svelte b/src/lib/components/organisms/LandingPoker.svelte index 1b0f79f..a55822c 100644 --- a/src/lib/components/organisms/LandingPoker.svelte +++ b/src/lib/components/organisms/LandingPoker.svelte @@ -63,7 +63,7 @@ {#if signal && distribution} - + {:else} + +{:else} +
+
+{/if} diff --git a/src/lib/components/poker/Signal.svelte b/src/lib/components/poker/Signal.svelte index 9ef52d2..8d805ce 100644 --- a/src/lib/components/poker/Signal.svelte +++ b/src/lib/components/poker/Signal.svelte @@ -1,15 +1,18 @@
@@ -48,12 +57,33 @@
{/if} -
+ +
{#each shown as d (String(d.card))} -
- - ×{d.count} -
+ {@const names = namesFor(d.card)} +
+ 0} + > + + + ×{d.count} + {#if names.length} + +
    + {#each names as name (name)} +
  • {name}
  • + {/each} +
+
{/each}
diff --git a/src/lib/data/poker.ts b/src/lib/data/poker.ts index 061409b..95b31ec 100644 --- a/src/lib/data/poker.ts +++ b/src/lib/data/poker.ts @@ -29,6 +29,7 @@ function mapRoom(r: Record): PokerRoomRow { email: (r.email as string | null) ?? null, locale: r.locale as PokerRoomRow['locale'], accent: r.accent as PokerRoomRow['accent'], + breakCalledBy: (r.break_called_by as string | null) ?? null, createdAt: r.created_at as string }; } @@ -100,6 +101,8 @@ export interface PokerProvider { removeParticipant(roomId: string, participantId: string): Promise; /** Cast/replace the active-round vote. No-op unless the room is in `voting`. */ castVote(roomId: string, participantId: string, card: string): Promise; + /** Raise (name, possibly empty) or clear (null) the room's coffee break. */ + setBreak(roomId: string, calledBy: string | null): Promise; // --- Controller actions (each bumps rev) --- /** Create the next item and open voting on it. */ @@ -240,6 +243,13 @@ export function pokerProvider(db: D1Database): PokerProvider { ]); }, + async setBreak(roomId, calledBy) { + await db + .prepare(`UPDATE poker_rooms SET break_called_by = ?, rev = rev + 1 WHERE id = ?`) + .bind(calledBy, roomId) + .run(); + }, + async openRound(roomId, title) { const roundId = id('round'); await db.batch([ @@ -251,7 +261,7 @@ export function pokerProvider(db: D1Database): PokerProvider { .bind(roundId, roomId, title, roomId), db .prepare( - `UPDATE poker_rooms SET phase = 'voting', active_round_id = ?, rev = rev + 1 + `UPDATE poker_rooms SET phase = 'voting', active_round_id = ?, break_called_by = NULL, rev = rev + 1 WHERE id = ? AND status = 'open'` ) .bind(roundId, roomId) @@ -315,7 +325,7 @@ export function pokerProvider(db: D1Database): PokerProvider { .bind(roomId), db .prepare( - `UPDATE poker_rooms SET status = 'closed', phase = 'waiting', active_round_id = NULL, rev = rev + 1 + `UPDATE poker_rooms SET status = 'closed', phase = 'waiting', active_round_id = NULL, break_called_by = NULL, rev = rev + 1 WHERE id = ?` ) .bind(roomId) diff --git a/src/lib/logic/poker-snapshot.test.ts b/src/lib/logic/poker-snapshot.test.ts index 6b12e1b..42c2e14 100644 --- a/src/lib/logic/poker-snapshot.test.ts +++ b/src/lib/logic/poker-snapshot.test.ts @@ -21,6 +21,7 @@ const room = (phase: RoomPhase): PokerRoomRow => ({ email: null, locale: 'en', accent: 'blue', + breakCalledBy: null, status: 'open', phase, activeRoundId: phase === 'waiting' ? null : 'round-1', @@ -88,6 +89,35 @@ describe('buildSnapshot privacy', () => { expect(snap.distribution?.find((d) => d.card === 5)?.count).toBe(3); }); + it('orders the roster alphabetically whatever order the rows arrive in', () => { + const input = base('voting', [], null); + input.participants = [seat('c', { name: 'Charlie' }), seat('a', { name: 'Alice' })]; + expect(buildSnapshot(input).roster.map((s) => s.name)).toEqual(['Alice', 'Charlie']); + }); + + it('re-sorts the roster by card, low to high, once revealed', () => { + const input = base( + 'revealed', + [vote('c', '13'), vote('a', '3'), vote('b', 'coffee'), vote('d', '5')], + null + ); + input.participants = [ + seat('c', { name: 'Charlie' }), + seat('a', { name: 'Alice' }), + seat('b', { name: 'Bob' }), + seat('d', { name: 'Dave' }), + // No card cast: sorts last, after the specials. + seat('e', { name: 'Eve' }) + ]; + expect(buildSnapshot(input).roster.map((s) => s.name)).toEqual([ + 'Alice', + 'Dave', + 'Charlie', + 'Bob', + 'Eve' + ]); + }); + it('derives presence from the heartbeat window', () => { const input = base('voting', [], 'alice'); input.participants = [ diff --git a/src/lib/logic/poker-snapshot.ts b/src/lib/logic/poker-snapshot.ts index d10375a..630a486 100644 --- a/src/lib/logic/poker-snapshot.ts +++ b/src/lib/logic/poker-snapshot.ts @@ -15,6 +15,7 @@ import type { import { type AgreementSignal, type Card, + DECK, agreementSignal, cardFromText, voteDistribution @@ -71,6 +72,9 @@ export interface RoomSnapshot { distribution: { card: Card; count: number }[] | null; // Decided items, in order - the durable results log. results: { title: string; estimate: string }[]; + // Who called the standing coffee break ('' = a caller without a seat), or + // null when the room is not on a break. + breakCalledBy: string | null; viewerIsController: boolean; // Whether this viewer already holds a seat (so a refresh skips the join // form), and that seat's role. Null role when they have no seat yet. @@ -108,14 +112,29 @@ export function buildSnapshot(input: SnapshotInput): RoomSnapshot { const votedIds = new Set(votes.map((v) => v.participantId)); const nameById = new Map(participants.map((p) => [p.id, p.name])); - const roster: RosterSeat[] = participants.map((p) => ({ - id: p.id, - name: p.name, - role: p.role, - isController: p.isController, - present: nowMs - Date.parse(p.lastSeenAt) <= presenceWindowMs, - hasVoted: votedIds.has(p.id) - })); + // Alphabetical by name so the roster does not reshuffle between polls + // (join order and row order are both unstable); id breaks name ties. Once + // revealed the same list re-sorts by card, low to high, so the spread reads + // off the roster directly - deck order puts the specials after the numerals + // and seats without a card last. + const cardByPid = new Map(votes.map((v) => [v.participantId, cardFromText(v.card)])); + const rank = (pid: string) => { + const i = DECK.indexOf(cardByPid.get(pid) ?? (null as never)); + return i < 0 ? DECK.length : i; + }; + const byName = (a: RosterSeat, b: RosterSeat) => + a.name.localeCompare(b.name) || a.id.localeCompare(b.id); + + const roster: RosterSeat[] = participants + .map((p) => ({ + id: p.id, + name: p.name, + role: p.role, + isController: p.isController, + present: nowMs - Date.parse(p.lastSeenAt) <= presenceWindowMs, + hasVoted: votedIds.has(p.id) + })) + .sort(room.phase === 'revealed' ? (a, b) => rank(a.id) - rank(b.id) || byName(a, b) : byName); // The caller's own vote is always theirs to see (voting or revealed). const own = viewerParticipantId @@ -162,6 +181,7 @@ export function buildSnapshot(input: SnapshotInput): RoomSnapshot { signal, distribution, results, + breakCalledBy: room.breakCalledBy, viewerIsController, viewerSeated: !!mySeat, viewerRole: mySeat?.role ?? null diff --git a/src/lib/types.ts b/src/lib/types.ts index a794c3e..d8f174e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -247,6 +247,9 @@ export interface PokerRoomRow { locale: Locale; // The room's highlighter, picked at creation like a poll's. accent: Accent; + // Who called the current coffee break, or null when nobody has. Empty string + // when the caller holds no seat (a controller who is not estimating). + breakCalledBy: string | null; createdAt: string; } diff --git a/src/routes/poker/api/[token]/command/+server.ts b/src/routes/poker/api/[token]/command/+server.ts index 9d9b06b..80e10f9 100644 --- a/src/routes/poker/api/[token]/command/+server.ts +++ b/src/routes/poker/api/[token]/command/+server.ts @@ -59,6 +59,19 @@ export const POST: RequestHandler = async ({ params, platform, cookies, request await provider.castVote(room.id, pid, String(card)); break; } + case 'break': { + // Anyone in the room may call a break or end one, in any phase - it is a + // human signal, not a control action. The caller's seat name is stored so + // the banner can say who asked; a controller without a seat stores ''. + // ponytail: no ownership, whoever is back first ends it for everyone. + if (body.on === false) { + await provider.setBreak(room.id, null); + break; + } + const me = pid ? (await provider.listParticipants(room.id)).find((p) => p.id === pid) : null; + await provider.setBreak(room.id, me?.name ?? ''); + break; + } case 'heartbeat': { if (pid) await provider.heartbeat(pid); break; diff --git a/src/routes/poker/c/[token]/+page.svelte b/src/routes/poker/c/[token]/+page.svelte index 26f74b2..1ca7c34 100644 --- a/src/routes/poker/c/[token]/+page.svelte +++ b/src/routes/poker/c/[token]/+page.svelte @@ -3,6 +3,7 @@ import Deck from '$lib/components/poker/Deck.svelte'; import Roster from '$lib/components/poker/Roster.svelte'; import Signal from '$lib/components/poker/Signal.svelte'; + import Break from '$lib/components/poker/Break.svelte'; import Button from '$lib/components/atoms/Button.svelte'; import TextField from '$lib/components/atoms/TextField.svelte'; import SectionHeading from '$lib/components/atoms/SectionHeading.svelte'; @@ -67,6 +68,11 @@ {#if snap.status === 'closed'} {:else} + room?.command('break', { on: true })} + onend={() => room?.command('break', { on: false })} + />
+ {/if} + + room?.command('break', { on: true })} + onend={() => room?.command('break', { on: false })} + />
{#if snap.phase === 'revealed' && snap.signal && snap.distribution} - + {/if} {:else if snap.phase === 'voting'}
@@ -105,7 +115,11 @@

{m.pokerHiddenNotice()}

{:else if snap.phase === 'revealed'} {#if snap.signal && snap.distribution} - + {/if} {/if} {/if}