Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
7 changes: 7 additions & 0 deletions migrations/0015_poker_break.sql
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions openspec/specs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details>`: 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

Expand Down
136 changes: 136 additions & 0 deletions openspec/specs/planning-poker/planning-poker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ({
Expand Down Expand Up @@ -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 }) => {
Expand Down
57 changes: 55 additions & 2 deletions openspec/specs/planning-poker/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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), **∞**
Expand All @@ -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**,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/organisms/LandingPoker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
</div>

{#if signal && distribution}
<Signal {signal} {distribution} />
<Signal {signal} {distribution} voters={SEATS} />
{:else}
<button
type="button"
Expand Down
39 changes: 39 additions & 0 deletions src/lib/components/poker/Break.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import { Coffee } from '@lucide/svelte';
import Button from '$lib/components/atoms/Button.svelte';
import { m } from '$lib/paraglide/messages';

// A coffee break is a human signal, available in every phase and to everyone
// in the room - the ☕ card only reaches the table during a voting round.
// Advisory: it holds nothing up, it just says out loud that the room stopped.
let {
calledBy,
oncall,
onend
}: {
// null = no break; '' = called by someone without a seat.
calledBy: string | null;
oncall: () => void;
onend: () => void;
} = $props();
</script>

{#if calledBy === null}
<div class="w-fit">
<Button variant="ghost" onclick={oncall}>
<Coffee size={14} aria-hidden="true" />
{m.pokerCallBreak()}
</Button>
</div>
{:else}
<div
data-testid="poker-break"
class="flex items-center gap-2.5 rounded-card border-2 border-border bg-hl-tint px-4 py-3 text-sm font-semibold text-ink"
>
<Coffee size={17} aria-hidden="true" />
<span class="min-w-0 flex-1"
>{calledBy ? m.pokerBreakCalledBy({ name: calledBy }) : m.pokerBreakCalledAnon()}</span
>
<Button variant="ghost" onclick={onend}>{m.pokerEndBreak()}</Button>
</div>
{/if}
Loading
Loading