diff --git a/messages/da.json b/messages/da.json index be6c602..f4366ef 100644 --- a/messages/da.json +++ b/messages/da.json @@ -116,6 +116,11 @@ "cancel": "Annuller", "timezoneNote": "Alle tidspunkter er i {timezone}", "fieldPollType": "Afstemningstype", + "fieldCreateKind": "Hvad vil du lave?", + "createKindPoll": "En afstemning", + "createKindPoker": "Et planning poker-rum", + "createKindPollHint": "Spør folk om noget, og saml deres svar over tid. Alle svarer via deres eget link, når de får tid.", + "createKindPokerHint": "Estimer en backlog sammen med dit team, live. Alle går ind via ét fælles link og stemmer samtidig.", "pollTypeDates": "Datoafstemning", "pollTypeQuestion": "Spørgsmålsafstemning", "pollTypeDatesHint": "Find en dato: de inviterede svarer på hver mulig dato.", @@ -186,6 +191,11 @@ "landingIntroTypes": "Find en dato, tæl hvem der kommer til en begivenhed, afgør et spørgsmål med dine egne svarmuligheder, prioriter mulighederne, eller fordel point på favoritterne.", "landingExamplesTitle": "Prøv det her", "landingExamplesHint": "Det her er kun eksempler. Tryk på et svar, så flytter optællingen sig; intet bliver gemt.", + "landingPokerBadge": "Nyt", + "landingPokerTitle": "Skal I estimere sammen?", + "landingPokerIntro": "Planning poker er det andet værktøj her, og det virker omvendt: alle er med på én gang. Del ét link, estimer et punkt sammen, og vend alle kort i samme øjeblik.", + "landingPokerExampleLabel": "Eksempel på en runde", + "landingPokerReveal": "Vend kortene", "landingSampleDatesTitle": "Sommermiddag hos os", "landingSampleRsvpTitle": "Noras fødselsdagsfest", "landingSampleQuestionTitle": "Hvor skal familieturen gå hen?", @@ -215,5 +225,63 @@ "codePromptHint": "Denne afstemning er beskyttet. Indtast admin-koden fra den e-mail, du modtog.", "codePromptLabel": "Admin-kode", "codePromptSubmit": "Lås op", - "codePromptError": "Koden passer ikke" + "codePromptError": "Koden passer ikke", + "pokerAppName": "Planning poker", + "pokerCreateTitle": "Start et planning poker-rum", + "pokerCreateLead": "estimer en backlog sammen, live. ingen tilmelding, del bare linket.", + "pokerRoomNameLabel": "Navn på rummet", + "pokerRoomNamePlaceholder": "f.eks. Sprint 12 forfining", + "pokerCreateButton": "Opret rum", + "pokerControllerLinkLabel": "Dit controller-link (hold det privat)", + "pokerJoinLinkLabel": "Del dette link med teamet", + "pokerControllerBadge": "Controller", + "pokerObserverBadge": "Observatør", + "pokerNameLabel": "Dit navn", + "pokerNamePlaceholder": "f.eks. Alex", + "pokerJoinButton": "Gå ind i rummet", + "pokerJoinAsObserver": "Deltag som observatør (se med, stem ikke)", + "pokerControllerEstimates": "jeg vil også estimere", + "pokerPhaseWaiting": "Venter på næste punkt", + "pokerPhaseVoting": "Afstemning er åben", + "pokerPhaseRevealed": "Stemmer afsløret", + "pokerNextItemLabel": "Næste punkt", + "pokerNextItemPlaceholder": "Titel eller sagsnummer", + "pokerOpenVoting": "Åbn afstemning", + "pokerReveal": "Afslør stemmer", + "pokerWaitingOn": "Venter på {names}", + "pokerRevote": "Stem igen", + "pokerRecordEstimate": "Gem estimat", + "pokerCloseRoom": "Luk rum", + "pokerEmailHint": "Du får rummets private link nu og resultaterne, når du lukker rummet.", + "pokerEmailLinkSubject": "Dit planning poker-rum: {title}", + "pokerEmailLinkIntro": "Her er det private link til dit planning poker-rum \"{title}\". Du får resultaterne, når du lukker det.", + "pokerEmailControllerLinkLabel": "Styringslink", + "pokerEmailSecretWarning": "Alle med dette link styrer rummet, så hold det for dig selv.", + "pokerEmailSummarySubject": "Resultater: {title}", + "pokerEmailSummaryIntro": "Dit planning poker-rum \"{title}\" er lukket. Her er det, I estimerede.", + "pokerEmailSummaryEmpty": "Rummet blev lukket, uden at noget punkt blev afgjort.", + "pokerClosedNotice": "Dette rum er lukket.", + "pokerRosterHeading": "Hvem er her", + "pokerVoted": "Stemt", + "pokerThinking": "Tænker", + "pokerYourCard": "Dit kort", + "pokerPickACard": "Vælg dit kort", + "pokerHiddenNotice": "Kortene er skjulte, indtil de afsløres.", + "pokerWaitingForController": "Venter på at controlleren åbner næste punkt.", + "pokerObservingNotice": "du observerer dette rum.", + "pokerSignalAgree": "Rummet er enigt.", + "pokerSignalClose": "Næsten enige, kun ét trin fra hinanden.", + "pokerSignalSpread": "Spredt. Tid til at tale om det.", + "pokerBreakHint": "Nogen har brug for en pause.", + "pokerSuggested": "Foreslået estimat: {value}", + "pokerResultsHeading": "Estimeret indtil nu", + "pokerNoResults": "Intet estimeret endnu.", + "pokerFinalEstimateLabel": "Endeligt estimat", + "pokerSplit": "For stort, del det op", + "pokerSkip": "Spring over", + "pokerCardQuestion": "Brug for mere info", + "pokerCardInfinity": "For stort til at estimere", + "pokerCardCoffee": "jeg har brug for en pause", + "pokerErrorNoRoomName": "Giv rummet et navn.", + "pokerErrorNoName": "Skriv dit navn for at deltage." } diff --git a/messages/de.json b/messages/de.json index 882577a..2161353 100644 --- a/messages/de.json +++ b/messages/de.json @@ -116,6 +116,11 @@ "cancel": "Abbrechen", "timezoneNote": "Alle Zeiten sind in {timezone}", "fieldPollType": "Umfragetyp", + "fieldCreateKind": "Was möchtest du erstellen?", + "createKindPoll": "Eine Umfrage", + "createKindPoker": "Einen Planning-Poker-Raum", + "createKindPollHint": "Stelle eine Frage und sammle die Antworten nach und nach. Alle antworten über ihren eigenen Link, wann es ihnen passt.", + "createKindPokerHint": "Schätzt euer Backlog gemeinsam im Team, live. Alle kommen über einen geteilten Link und stimmen gleichzeitig ab.", "pollTypeDates": "Terminumfrage", "pollTypeQuestion": "Fragenumfrage", "pollTypeDatesHint": "Finde einen Termin: Die Eingeladenen antworten auf jeden möglichen Termin.", @@ -186,6 +191,11 @@ "landingIntroTypes": "Findet einen Termin, zählt wer zu einer Feier kommt, klärt jede Frage mit eigenen Antwortmöglichkeiten, ordnet die Optionen, oder verteilt Punkte auf Favoriten.", "landingExamplesTitle": "Probier es hier aus", "landingExamplesHint": "Das sind nur Beispiele. Tippe auf eine Antwort und die Zählung bewegt sich; nichts wird gespeichert.", + "landingPokerBadge": "Neu", + "landingPokerTitle": "Schätzt ihr im Team?", + "landingPokerIntro": "Planning Poker ist das zweite Werkzeug hier, und es funktioniert andersherum: Alle sind gleichzeitig dabei. Einen Link teilen, ein Backlog-Item gemeinsam schätzen und alle Karten im selben Moment aufdecken.", + "landingPokerExampleLabel": "Beispielrunde", + "landingPokerReveal": "Karten aufdecken", "landingSampleDatesTitle": "Sommeressen bei uns", "landingSampleRsvpTitle": "Noras Geburtstagsfeier", "landingSampleQuestionTitle": "Wohin soll der Familienausflug gehen?", @@ -215,5 +225,63 @@ "codePromptHint": "Diese Umfrage ist geschützt. Gib den Admin-Code aus der E-Mail ein, die du erhalten hast.", "codePromptLabel": "Admin-Code", "codePromptSubmit": "Entsperren", - "codePromptError": "Der Code stimmt nicht" + "codePromptError": "Der Code stimmt nicht", + "pokerAppName": "Planning Poker", + "pokerCreateTitle": "Einen Planning-Poker-Raum starten", + "pokerCreateLead": "Schätzt ein Backlog gemeinsam, live. Keine Anmeldung, teilt einfach den Link.", + "pokerRoomNameLabel": "Name des Raums", + "pokerRoomNamePlaceholder": "z. B. Sprint 12 Verfeinerung", + "pokerCreateButton": "Raum erstellen", + "pokerControllerLinkLabel": "Dein Controller-Link (privat halten)", + "pokerJoinLinkLabel": "Teile diesen Link mit dem Team", + "pokerControllerBadge": "Controller", + "pokerObserverBadge": "Beobachter", + "pokerNameLabel": "Dein Name", + "pokerNamePlaceholder": "z. B. Alex", + "pokerJoinButton": "Raum betreten", + "pokerJoinAsObserver": "Als Beobachter beitreten (zusehen, nicht abstimmen)", + "pokerControllerEstimates": "Ich möchte auch schätzen", + "pokerPhaseWaiting": "Warten auf den nächsten Punkt", + "pokerPhaseVoting": "Abstimmung ist offen", + "pokerPhaseRevealed": "Stimmen aufgedeckt", + "pokerNextItemLabel": "Nächster Punkt", + "pokerNextItemPlaceholder": "Titel oder Ticket-ID", + "pokerOpenVoting": "Abstimmung öffnen", + "pokerReveal": "Stimmen aufdecken", + "pokerWaitingOn": "Warten auf {names}", + "pokerRevote": "Erneut abstimmen", + "pokerRecordEstimate": "Schätzung speichern", + "pokerCloseRoom": "Raum schließen", + "pokerEmailHint": "Du bekommst jetzt den privaten Link zum Raum und die Ergebnisse, sobald du ihn schließt.", + "pokerEmailLinkSubject": "Dein Planning-Poker-Raum: {title}", + "pokerEmailLinkIntro": "Hier ist der private Link zu deinem Planning-Poker-Raum „{title}“. Die Ergebnisse bekommst du, sobald du ihn schließt.", + "pokerEmailControllerLinkLabel": "Steuerungslink", + "pokerEmailSecretWarning": "Wer diesen Link hat, steuert den Raum – behalte ihn für dich.", + "pokerEmailSummarySubject": "Ergebnisse: {title}", + "pokerEmailSummaryIntro": "Dein Planning-Poker-Raum „{title}“ ist geschlossen. Das habt ihr geschätzt.", + "pokerEmailSummaryEmpty": "Der Raum wurde geschlossen, ohne dass ein Punkt entschieden wurde.", + "pokerClosedNotice": "Dieser Raum ist geschlossen.", + "pokerRosterHeading": "Wer ist da", + "pokerVoted": "Abgestimmt", + "pokerThinking": "Überlegt", + "pokerYourCard": "Deine Karte", + "pokerPickACard": "Wähle deine Karte", + "pokerHiddenNotice": "Die Karten bleiben verdeckt bis zur Aufdeckung.", + "pokerWaitingForController": "Warten, bis der Controller den nächsten Punkt öffnet.", + "pokerObservingNotice": "Du beobachtest diesen Raum.", + "pokerSignalAgree": "Der Raum ist sich einig.", + "pokerSignalClose": "Fast geschafft, nur einen Schritt auseinander.", + "pokerSignalSpread": "Weit auseinander. Zeit zu reden.", + "pokerBreakHint": "Jemand braucht eine Pause.", + "pokerSuggested": "Vorgeschlagene Schätzung: {value}", + "pokerResultsHeading": "Bisher geschätzt", + "pokerNoResults": "Noch nichts geschätzt.", + "pokerFinalEstimateLabel": "Endgültige Schätzung", + "pokerSplit": "Zu groß, aufteilen", + "pokerSkip": "Überspringen", + "pokerCardQuestion": "Mehr Infos nötig", + "pokerCardInfinity": "Zu groß zum Schätzen", + "pokerCardCoffee": "Ich brauche eine Pause", + "pokerErrorNoRoomName": "Gib dem Raum einen Namen.", + "pokerErrorNoName": "Gib deinen Namen ein, um beizutreten." } diff --git a/messages/en.json b/messages/en.json index bde158a..40950e7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -116,6 +116,11 @@ "cancel": "Cancel", "timezoneNote": "All times are in {timezone}", "fieldPollType": "Poll type", + "fieldCreateKind": "What are you making?", + "createKindPoll": "A poll", + "createKindPoker": "A planning poker room", + "createKindPollHint": "Ask people something and collect their answers over time. Everyone answers through their own link, whenever they get round to it.", + "createKindPokerHint": "Estimate a backlog with your team, live and together. Everyone joins one shared link and votes at the same time.", "pollTypeDates": "Date poll", "pollTypeQuestion": "Question poll", "pollTypeDatesHint": "Find a date: invitees answer for each candidate date.", @@ -186,6 +191,11 @@ "landingIntroTypes": "Find a date, count who's coming to an event, settle any question with your own options, rank the choices, or spend points on favorites.", "landingExamplesTitle": "Try it here", "landingExamplesHint": "These are just examples. Tap an answer and the tally moves; nothing is saved.", + "landingPokerBadge": "New", + "landingPokerTitle": "Estimating with a team?", + "landingPokerIntro": "Planning poker is the other thing here, and it works the other way round: everyone is in at once. Share one link, size a backlog item together, and reveal every card at the same moment.", + "landingPokerExampleLabel": "Example round", + "landingPokerReveal": "Reveal the cards", "landingSampleDatesTitle": "Summer dinner at ours", "landingSampleRsvpTitle": "Nora's birthday party", "landingSampleQuestionTitle": "Where should the family trip go?", @@ -215,5 +225,63 @@ "codePromptHint": "This poll is protected. Enter the admin code from the email you received.", "codePromptLabel": "Admin code", "codePromptSubmit": "Unlock", - "codePromptError": "That code doesn't match" + "codePromptError": "That code doesn't match", + "pokerAppName": "Planning poker", + "pokerCreateTitle": "Start a planning poker room", + "pokerCreateLead": "Estimate a backlog together, live. No sign-ups, just share the link.", + "pokerRoomNameLabel": "Room name", + "pokerRoomNamePlaceholder": "e.g. Sprint 12 refinement", + "pokerCreateButton": "Create room", + "pokerControllerLinkLabel": "Your controller link (keep this private)", + "pokerJoinLinkLabel": "Share this link with the team", + "pokerControllerBadge": "Controller", + "pokerObserverBadge": "Observer", + "pokerNameLabel": "Your name", + "pokerNamePlaceholder": "e.g. Alex", + "pokerJoinButton": "Join the room", + "pokerJoinAsObserver": "Join as observer (watch, do not vote)", + "pokerControllerEstimates": "I want to estimate too", + "pokerPhaseWaiting": "Waiting for the next item", + "pokerPhaseVoting": "Voting is open", + "pokerPhaseRevealed": "Votes revealed", + "pokerNextItemLabel": "Next item", + "pokerNextItemPlaceholder": "Story title or ticket id", + "pokerOpenVoting": "Open voting", + "pokerReveal": "Reveal votes", + "pokerWaitingOn": "Waiting on {names}", + "pokerRevote": "Vote again", + "pokerRecordEstimate": "Record estimate", + "pokerCloseRoom": "Close room", + "pokerEmailHint": "You'll get this room's private link now, and the results when you close the room.", + "pokerEmailLinkSubject": "Your planning poker room: {title}", + "pokerEmailLinkIntro": "Here is the private link to your planning poker room \"{title}\". You will get the results when you close it.", + "pokerEmailControllerLinkLabel": "Controller link", + "pokerEmailSecretWarning": "Anyone with this link controls the room, so keep it to yourself.", + "pokerEmailSummarySubject": "Results: {title}", + "pokerEmailSummaryIntro": "Your planning poker room \"{title}\" is closed. Here is what you estimated.", + "pokerEmailSummaryEmpty": "The room closed without any item being decided.", + "pokerClosedNotice": "This room is closed.", + "pokerRosterHeading": "Who is here", + "pokerVoted": "Voted", + "pokerThinking": "Thinking", + "pokerYourCard": "Your card", + "pokerPickACard": "Pick your card", + "pokerHiddenNotice": "Cards stay hidden until the reveal.", + "pokerWaitingForController": "Waiting for the controller to open the next item.", + "pokerObservingNotice": "You are observing this room.", + "pokerSignalAgree": "The room agrees.", + "pokerSignalClose": "Almost there, one step apart.", + "pokerSignalSpread": "Spread. Time to talk it through.", + "pokerBreakHint": "Someone needs a break.", + "pokerSuggested": "Suggested estimate: {value}", + "pokerResultsHeading": "Estimated so far", + "pokerNoResults": "Nothing estimated yet.", + "pokerFinalEstimateLabel": "Final estimate", + "pokerSplit": "Too big, split it", + "pokerSkip": "Skip", + "pokerCardQuestion": "Need more info", + "pokerCardInfinity": "Too big to estimate", + "pokerCardCoffee": "I need a break", + "pokerErrorNoRoomName": "Give the room a name.", + "pokerErrorNoName": "Enter your name to join." } diff --git a/messages/es.json b/messages/es.json index ddcd9ee..a630aee 100644 --- a/messages/es.json +++ b/messages/es.json @@ -116,6 +116,11 @@ "cancel": "Cancelar", "timezoneNote": "Todas las horas son en {timezone}", "fieldPollType": "Tipo de encuesta", + "fieldCreateKind": "¿Qué quieres crear?", + "createKindPoll": "Una encuesta", + "createKindPoker": "Una sala de planning poker", + "createKindPollHint": "Pregunta algo y recoge las respuestas con el tiempo. Cada persona responde por su propio enlace cuando puede.", + "createKindPokerHint": "Estimad el backlog con tu equipo, en directo. Todos entran por un enlace compartido y votan a la vez.", "pollTypeDates": "Encuesta de fechas", "pollTypeQuestion": "Encuesta de pregunta", "pollTypeDatesHint": "Encuentra una fecha: los invitados responden a cada fecha propuesta.", @@ -186,6 +191,11 @@ "landingIntroTypes": "Encuentra una fecha, cuenta quién viene a un evento, resuelve cualquier pregunta con tus propias opciones, clasifícalas por orden, o reparte puntos entre favoritas.", "landingExamplesTitle": "Pruébalo aquí", "landingExamplesHint": "Esto son solo ejemplos. Toca una respuesta y el recuento se mueve; no se guarda nada.", + "landingPokerBadge": "Nuevo", + "landingPokerTitle": "¿Estimáis en equipo?", + "landingPokerIntro": "El planning poker es la otra herramienta de aquí, y funciona al revés: todos participáis a la vez. Comparte un enlace, estimad un elemento juntos y descubrid todas las cartas en el mismo momento.", + "landingPokerExampleLabel": "Ronda de ejemplo", + "landingPokerReveal": "Descubrir las cartas", "landingSampleDatesTitle": "Cena de verano en casa", "landingSampleRsvpTitle": "Cumpleaños de Nora", "landingSampleQuestionTitle": "¿Adónde vamos de viaje en familia?", @@ -215,5 +225,63 @@ "codePromptHint": "Esta encuesta está protegida. Introduce el código de administración del correo que recibiste.", "codePromptLabel": "Código de administración", "codePromptSubmit": "Desbloquear", - "codePromptError": "Ese código no coincide" + "codePromptError": "Ese código no coincide", + "pokerAppName": "Planning poker", + "pokerCreateTitle": "Abre una sala de planning poker", + "pokerCreateLead": "Estimad un backlog juntos, en vivo. Sin registros, solo comparte el enlace.", + "pokerRoomNameLabel": "Nombre de la sala", + "pokerRoomNamePlaceholder": "p. ej. Refinamiento del sprint 12", + "pokerCreateButton": "Crear sala", + "pokerControllerLinkLabel": "Tu enlace de controlador (mantenlo privado)", + "pokerJoinLinkLabel": "Comparte este enlace con el equipo", + "pokerControllerBadge": "Controlador", + "pokerObserverBadge": "Observador", + "pokerNameLabel": "Tu nombre", + "pokerNamePlaceholder": "p. ej. Alex", + "pokerJoinButton": "Entrar en la sala", + "pokerJoinAsObserver": "Entrar como observador (mirar, sin votar)", + "pokerControllerEstimates": "Yo también quiero estimar", + "pokerPhaseWaiting": "Esperando el siguiente elemento", + "pokerPhaseVoting": "La votación está abierta", + "pokerPhaseRevealed": "Votos revelados", + "pokerNextItemLabel": "Siguiente elemento", + "pokerNextItemPlaceholder": "Título o id del ticket", + "pokerOpenVoting": "Abrir votación", + "pokerReveal": "Revelar votos", + "pokerWaitingOn": "Esperando a {names}", + "pokerRevote": "Votar de nuevo", + "pokerRecordEstimate": "Guardar estimación", + "pokerCloseRoom": "Cerrar sala", + "pokerEmailHint": "Recibirás ahora el enlace privado de la sala y los resultados cuando la cierres.", + "pokerEmailLinkSubject": "Tu sala de planning poker: {title}", + "pokerEmailLinkIntro": "Este es el enlace privado a tu sala de planning poker «{title}». Recibirás los resultados cuando la cierres.", + "pokerEmailControllerLinkLabel": "Enlace de control", + "pokerEmailSecretWarning": "Cualquiera con este enlace controla la sala, así que guárdalo bien.", + "pokerEmailSummarySubject": "Resultados: {title}", + "pokerEmailSummaryIntro": "Tu sala de planning poker «{title}» está cerrada. Esto es lo que estimasteis.", + "pokerEmailSummaryEmpty": "La sala se cerró sin decidir ningún elemento.", + "pokerClosedNotice": "Esta sala está cerrada.", + "pokerRosterHeading": "Quién está aquí", + "pokerVoted": "Votó", + "pokerThinking": "Pensando", + "pokerYourCard": "Tu carta", + "pokerPickACard": "Elige tu carta", + "pokerHiddenNotice": "Las cartas quedan ocultas hasta revelarlas.", + "pokerWaitingForController": "Esperando a que el controlador abra el siguiente elemento.", + "pokerObservingNotice": "Estás observando esta sala.", + "pokerSignalAgree": "La sala está de acuerdo.", + "pokerSignalClose": "Casi, a un paso de distancia.", + "pokerSignalSpread": "Dispersión. Hora de hablarlo.", + "pokerBreakHint": "Alguien necesita un descanso.", + "pokerSuggested": "Estimación sugerida: {value}", + "pokerResultsHeading": "Estimado hasta ahora", + "pokerNoResults": "Nada estimado todavía.", + "pokerFinalEstimateLabel": "Estimación final", + "pokerSplit": "Muy grande, divídelo", + "pokerSkip": "Omitir", + "pokerCardQuestion": "Falta información", + "pokerCardInfinity": "Demasiado grande para estimar", + "pokerCardCoffee": "Necesito un descanso", + "pokerErrorNoRoomName": "Ponle un nombre a la sala.", + "pokerErrorNoName": "Escribe tu nombre para entrar." } diff --git a/messages/fr.json b/messages/fr.json index 608ea57..4ce070d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -116,6 +116,11 @@ "cancel": "Annuler", "timezoneNote": "Toutes les heures sont en {timezone}", "fieldPollType": "Type de sondage", + "fieldCreateKind": "Que voulez-vous créer ?", + "createKindPoll": "Un sondage", + "createKindPoker": "Un salon de planning poker", + "createKindPollHint": "Posez une question et recueillez les réponses au fil du temps. Chacun répond via son propre lien, quand il le souhaite.", + "createKindPokerHint": "Estimez un backlog avec votre équipe, en direct. Tout le monde rejoint un lien partagé et vote en même temps.", "pollTypeDates": "Sondage de dates", "pollTypeQuestion": "Sondage à question", "pollTypeDatesHint": "Trouvez une date : les invités répondent pour chaque date proposée.", @@ -186,6 +191,11 @@ "landingIntroTypes": "Trouvez une date, comptez qui vient à un événement, tranchez n’importe quelle question avec vos propres options, classez les choix, ou répartissez des points sur vos préférés.", "landingExamplesTitle": "Essayez ici", "landingExamplesHint": "Ce ne sont que des exemples. Touchez une réponse et le décompte bouge ; rien n'est enregistré.", + "landingPokerBadge": "Nouveau", + "landingPokerTitle": "Vous estimez en équipe ?", + "landingPokerIntro": "Le planning poker est l'autre outil ici, et il fonctionne à l'inverse : tout le monde participe en même temps. Partagez un lien, estimez un élément ensemble et retournez toutes les cartes au même instant.", + "landingPokerExampleLabel": "Tour d’exemple", + "landingPokerReveal": "Retourner les cartes", "landingSampleDatesTitle": "Dîner d'été à la maison", "landingSampleRsvpTitle": "Anniversaire de Nora", "landingSampleQuestionTitle": "Où partir en voyage en famille ?", @@ -215,5 +225,63 @@ "codePromptHint": "Ce sondage est protégé. Saisis le code d'administration de l'e-mail que tu as reçu.", "codePromptLabel": "Code d'administration", "codePromptSubmit": "Déverrouiller", - "codePromptError": "Ce code ne correspond pas" + "codePromptError": "Ce code ne correspond pas", + "pokerAppName": "Planning poker", + "pokerCreateTitle": "Ouvrir une salle de planning poker", + "pokerCreateLead": "Estimez un backlog ensemble, en direct. Sans inscription, partagez juste le lien.", + "pokerRoomNameLabel": "Nom de la salle", + "pokerRoomNamePlaceholder": "p. ex. Affinage du sprint 12", + "pokerCreateButton": "Créer la salle", + "pokerControllerLinkLabel": "Votre lien de contrôleur (à garder privé)", + "pokerJoinLinkLabel": "Partagez ce lien avec l’équipe", + "pokerControllerBadge": "Contrôleur", + "pokerObserverBadge": "Observateur", + "pokerNameLabel": "Votre nom", + "pokerNamePlaceholder": "p. ex. Alex", + "pokerJoinButton": "Entrer dans la salle", + "pokerJoinAsObserver": "Entrer comme observateur (regarder, sans voter)", + "pokerControllerEstimates": "Je veux estimer aussi", + "pokerPhaseWaiting": "En attente du prochain élément", + "pokerPhaseVoting": "Le vote est ouvert", + "pokerPhaseRevealed": "Votes révélés", + "pokerNextItemLabel": "Prochain élément", + "pokerNextItemPlaceholder": "Titre ou identifiant du ticket", + "pokerOpenVoting": "Ouvrir le vote", + "pokerReveal": "Révéler les votes", + "pokerWaitingOn": "En attente de {names}", + "pokerRevote": "Revoter", + "pokerRecordEstimate": "Enregistrer l’estimation", + "pokerCloseRoom": "Fermer la salle", + "pokerEmailHint": "Vous recevrez le lien privé du salon maintenant, et les résultats à sa fermeture.", + "pokerEmailLinkSubject": "Votre salon de planning poker : {title}", + "pokerEmailLinkIntro": "Voici le lien privé de votre salon de planning poker « {title} ». Vous recevrez les résultats à sa fermeture.", + "pokerEmailControllerLinkLabel": "Lien de contrôle", + "pokerEmailSecretWarning": "Toute personne disposant de ce lien contrôle le salon : gardez-le pour vous.", + "pokerEmailSummarySubject": "Résultats : {title}", + "pokerEmailSummaryIntro": "Votre salon de planning poker « {title} » est fermé. Voici vos estimations.", + "pokerEmailSummaryEmpty": "Le salon a été fermé sans qu'aucun élément soit décidé.", + "pokerClosedNotice": "Cette salle est fermée.", + "pokerRosterHeading": "Qui est là", + "pokerVoted": "A voté", + "pokerThinking": "Réfléchit", + "pokerYourCard": "Votre carte", + "pokerPickACard": "Choisissez votre carte", + "pokerHiddenNotice": "Les cartes restent cachées jusqu’à la révélation.", + "pokerWaitingForController": "En attente que le contrôleur ouvre le prochain élément.", + "pokerObservingNotice": "Vous observez cette salle.", + "pokerSignalAgree": "La salle est d’accord.", + "pokerSignalClose": "Presque, à un pas d’écart.", + "pokerSignalSpread": "Dispersé. Le moment d’en parler.", + "pokerBreakHint": "Quelqu’un a besoin d’une pause.", + "pokerSuggested": "Estimation suggérée : {value}", + "pokerResultsHeading": "Estimé jusqu’ici", + "pokerNoResults": "Rien d’estimé pour l’instant.", + "pokerFinalEstimateLabel": "Estimation finale", + "pokerSplit": "Trop gros, à découper", + "pokerSkip": "Passer", + "pokerCardQuestion": "Besoin de plus d’infos", + "pokerCardInfinity": "Trop gros pour estimer", + "pokerCardCoffee": "J’ai besoin d’une pause", + "pokerErrorNoRoomName": "Donnez un nom à la salle.", + "pokerErrorNoName": "Entrez votre nom pour rejoindre." } diff --git a/migrations/0011_planning_poker.sql b/migrations/0011_planning_poker.sql new file mode 100644 index 0000000..a90114d --- /dev/null +++ b/migrations/0011_planning_poker.sql @@ -0,0 +1,69 @@ +-- Planning poker (openspec/specs/planning-poker): a real-time, controller-run +-- estimation room. Real-time is D1-backed (no Durable Object): clients short- +-- poll a state endpoint, so the live session lives in these tables too. +-- +-- Durable skeleton: poker_rooms + poker_rounds (the room, its ordered items, +-- each item's final estimate - the only durable artifact of a decided item). +-- Live/transient state: the room's phase + active round + rev counter, +-- poker_participants (heartbeat-presence roster), and poker_votes (the active +-- item's votes, cleared on finalize/re-vote). +-- +-- Additive: no existing table is touched, nothing to backfill. +-- Two unguessable capability tokens per room mirror organizer/share tokens: +-- controller_token (private, facilitates) and join_token (shared, participants). + +CREATE TABLE poker_rooms ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + -- Reserved for future decks; only the modified-Fibonacci deck ships today. + deck TEXT NOT NULL DEFAULT 'fibonacci', + controller_token TEXT NOT NULL UNIQUE, + join_token TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), + -- Live phase of the current item. waiting = between items. + phase TEXT NOT NULL DEFAULT 'waiting' CHECK (phase IN ('waiting', 'voting', 'revealed')), + -- The item being voted/revealed; NULL while waiting. + active_round_id TEXT REFERENCES poker_rounds(id), + -- Bumped on every mutation so a state poll can cheaply detect change. + rev INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE poker_rounds ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES poker_rooms(id), + -- The story/ticket label or id the controller typed for this item. + title TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + -- The recorded deck value (or a split/skip marker); NULL until the controller + -- decides the item. + final_estimate TEXT, + decided_at TEXT +); + +CREATE INDEX idx_poker_rounds_room ON poker_rounds(room_id, sort_order); + +-- One seat per named participant. Presence is derived from last_seen_at (a seat +-- is "present" within a short window, refreshed by each state poll). id is the +-- cookie-carried per-browser id so a refresh resumes the same seat. +CREATE TABLE poker_participants ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES poker_rooms(id), + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'estimator' CHECK (role IN ('estimator', 'observer')), + -- 1 on the controller's own seat (the facilitator); 0 for everyone else. + is_controller INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT NOT NULL +); + +CREATE INDEX idx_poker_participants_room ON poker_participants(room_id); + +-- The active item's votes. card is text: a deck numeral ('0'..'100') or a +-- special ('?', 'infinity', 'coffee'). Transient - deleted on finalize/re-vote. +CREATE TABLE poker_votes ( + round_id TEXT NOT NULL REFERENCES poker_rounds(id), + participant_id TEXT NOT NULL REFERENCES poker_participants(id), + card TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (round_id, participant_id) +); diff --git a/migrations/0012_poker_room_email.sql b/migrations/0012_poker_room_email.sql new file mode 100644 index 0000000..72e8f7b --- /dev/null +++ b/migrations/0012_poker_room_email.sql @@ -0,0 +1,11 @@ +-- Optional controller email on a planning-poker room +-- (openspec/specs/planning-poker "Room email"). +-- +-- Unlike the async poll's organizer email - which is used once at creation and +-- never stored - a room's address must persist: the second email (the results +-- summary) is sent when the room closes, arbitrarily later. It lives only as +-- long as the room row does. +-- +-- Additive, nullable, no backfill: existing rooms simply have no email and +-- send nothing. +ALTER TABLE poker_rooms ADD COLUMN email TEXT; diff --git a/migrations/0013_poker_room_locale.sql b/migrations/0013_poker_room_locale.sql new file mode 100644 index 0000000..32e8d3b --- /dev/null +++ b/migrations/0013_poker_room_locale.sql @@ -0,0 +1,15 @@ +-- A planning-poker room's language (openspec/specs/planning-poker "Room +-- language"). +-- +-- Rooms previously had no language at all: every /poker page fell back to the +-- base locale because the routes carry no language segment, even though all +-- poker strings are translated. Now that a room is created from the localized +-- create page, it records the language chosen there and renders in it for +-- everyone who joins - exactly how a poll's `locale` column already works. +-- +-- Not a URL segment: the controller hands one join link to the whole team, so +-- the language must travel with the room, not with whoever copied the link. +-- +-- Additive, defaulted: existing rooms read as the base locale, nothing to +-- backfill. No CHECK - validated at the form boundary like the poll's locale. +ALTER TABLE poker_rooms ADD COLUMN locale TEXT NOT NULL DEFAULT 'en'; diff --git a/migrations/0014_poker_room_accent.sql b/migrations/0014_poker_room_accent.sql new file mode 100644 index 0000000..078fd0b --- /dev/null +++ b/migrations/0014_poker_room_accent.sql @@ -0,0 +1,13 @@ +-- A planning-poker room's highlighter (openspec/specs/planning-poker "Room +-- highlighter"). +-- +-- Rooms used to be the one capability with a hardcoded accent - every room was +-- blue. That was a leftover from poker living on its own page: now that rooms +-- are created from the same page as polls, they take the same organizer-picked +-- highlighter, so the two tools reach feature parity and the create form has +-- one fewer special case. +-- +-- Additive, defaulted to the old hardcoded value so rooms that predate the +-- column render exactly as they did. Validated at the form boundary like the +-- events table's `accent` - no CHECK. +ALTER TABLE poker_rooms ADD COLUMN accent TEXT NOT NULL DEFAULT 'blue'; diff --git a/openspec/changes/archive/2026-07-24-add-planning-poker/.openspec.yaml b/openspec/changes/archive/2026-07-24-add-planning-poker/.openspec.yaml new file mode 100644 index 0000000..5e6d53a --- /dev/null +++ b/openspec/changes/archive/2026-07-24-add-planning-poker/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-24 diff --git a/openspec/changes/archive/2026-07-24-add-planning-poker/design.md b/openspec/changes/archive/2026-07-24-add-planning-poker/design.md new file mode 100644 index 0000000..c52b617 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-add-planning-poker/design.md @@ -0,0 +1,202 @@ +# Design: Planning poker + +## Context + +The product is an async, capability-URL date poll on SvelteKit + Cloudflare +Workers + D1, no accounts, no real-time. Planning poker is synchronous: +many participants in one room, votes cast and revealed together, state +changing many times per minute and reflected live for everyone. + +A Durable Object per room (WebSocket push) was the first design. It was +rejected on a concrete constraint: `@sveltejs/adapter-cloudflare` exports +only the SvelteKit worker's `default` and overwrites `main` on every build, +so exporting a DO class needs a custom worker-entry/wrangler workaround that +fights the adapter and changes how the single-worker e2e harness boots. That +fragility is not worth it for a solo feature. Instead the **live session +state lives in D1** (the room's phase, a per-room roster with heartbeat +presence, and per-participant votes for the active item) and clients stay +current by **short-polling a JSON state endpoint (~1s)**. This keeps +planning poker entirely inside the existing stack — one worker, D1, the same +Playwright harness — at ~1s update latency, which reads as live (votes +appear, the reveal flips together). PROJECT.md's "DOs are overkill" note +holds: this stays a D1 app. + +## Goals / Non-Goals + +**Goals:** + +- Free, instant room creation on the existing capability-URL model: + a private controller link and a shared join link, cookie-remembered + participant identity — no accounts. +- A clear per-item state machine (waiting → voting → revealed) driven only + by the controller, with votes hidden until a synchronized reveal. +- Fibonacci deck plus ? / ∞ / ☕ specials; an agree / close / spread + agreement signal that advises without deciding. +- Live-feeling propagation to every present participant (~1s), with reconnect + and refresh resuming the same seat. +- A durable results log (items + final estimates) that survives the live + session, seedable and assertable in D1 for e2e. +- Vote privacy enforced by the server: card values are never sent to other + seats before the reveal. + +**Non-Goals:** + +- Vote timers, emoji reactions, multiple or custom decks, backlog/ticket + import, persistent named teams — separate future changes. +- Any login or account. Metsa/OIDC unlock is out of scope here. +- Keeping per-participant votes after an item is decided. Votes are transient + coordination state (cleared on finalize/re-vote); only the controller's + final estimate is a durable artifact. +- WebSocket push / true real-time. ~1s polling is the deliberate transport + (see Context); a DO/WS upgrade can come later if scale ever demands it. +- Reworking any async event capability. Zero shared tables or routes. + +## Decisions + +- **Own tables, not a `poll_type`.** Planning poker shares nothing with + `events`/`date_options`/`invitees`/`responses` (no dated options, no async + per-invitee answers, a live phase instead of an open/closed status). A new + `poll_type` variant would inherit a data model it cannot use. New tables in + migration `0011_planning_poker.sql`: + - `poker_rooms(id, title, deck, controller_token, join_token, status, +created_at)` — `deck` is `'fibonacci'` for now (column reserved for + future decks); `status` ∈ {open, closed}; two unguessable base62 tokens + (≥128 bits) generated the existing way, mirroring + organizer_token/share_token. + - `poker_rounds(id, room_id, title, sort_order, final_estimate, +decided_at)` — one row per estimation item. `title` is the story/ticket + label or id the controller typed. `final_estimate` (text, the recorded + deck value or a free split/skip marker) and `decided_at` are NULL until + the controller records the estimate. The live phase and votes live in the + live-state tables below, not on the round. +- **Live state lives in D1; clients short-poll.** Three more tables carry the + transient session: + - `poker_rooms` also holds `phase` ∈ {waiting, voting, revealed}, an + `active_round_id` (the item being voted/revealed, NULL when waiting), and + a `rev` counter bumped on every mutation so a poll can cheaply detect + change. + - `poker_participants(id, room_id, name, role, is_controller, last_seen_at)` + — one seat per named participant. `role` ∈ {estimator, observer}; + `is_controller` marks the facilitator's own seat. Presence is derived: a + seat is "present" when `last_seen_at` is within a short window, refreshed + by each poll (heartbeat). `id` is the cookie-carried per-browser id. + - `poker_votes(round_id, participant_id, card, updated_at)` — the active + item's votes, `card` as text (`'0'`..`'100'` / `'?'` / `'infinity'` / + `'coffee'`). Cleared on finalize and re-vote (transient, not durable). + A `GET …/state` endpoint returns a snapshot; a `POST …/command` endpoint + applies one action. Clients re-fetch state every ~1s. +- **The token is the path credential; the server authorizes every call.** The + state and command endpoints sit under the token route (`/poker/c/{token}` + for the controller, `/poker/j/{token}` for participants), exactly like the + `/e`, `/r`, `/s` pages. Each load/endpoint resolves the token to a room and + a role in D1 and reveals nothing on an unknown token. Control commands are + refused unless the caller holds the controller token; the join token grants + only join + vote + heartbeat. +- **State machine (per item), controller-only transitions:** + - `waiting` — no active item, or the next item queued. Participants see + "waiting for the next item." + - `voting` — the controller opened the current item. Participants pick or + change a card; the state snapshot exposes only _who_ has voted (a + face-down card / check), never the value. Late joiners can vote until + reveal. + - `revealed` — the controller revealed; every vote flips face-up at once. + The snapshot now carries the votes, the distribution, and the agreement + signal. Discussion happens in this phase; the controller then either + re-opens voting (a re-vote clears votes back to `voting`) or records the + final estimate (→ item decided, room returns to `waiting` for the next + item). Only the controller token may drive `open` / `reveal` / `revote` / + `finalize` / `next`; a participant's command is refused. + The controller **facilitates by default but may opt in as an estimator** — + when they do their vote is hidden, revealed, and counted like any other + seat; facilitation powers are independent of whether they vote. +- **Vote privacy is enforced server-side.** While `phase = voting` the state + snapshot carries only a `hasVoted` boolean per seat (plus, to the caller, + their own card echoed back); no other seat's card value is included until + `phase = revealed`. A crafted client cannot read hidden votes because the + endpoint never sends them. A vote command arriving when the phase is not + `voting` is rejected. +- **Agreement signal (advisory, computed on reveal).** Over the _numeric_ + votes only, by deck index (`0 1 2 3 5 8 13 20 40 100` → indices 0..9): + - **agree** — at least one numeric vote, all numeric votes equal, and no ∞. + The equal value pre-fills the suggested estimate. + - **close** — numeric index span == 1 (votes on two adjacent deck cards), + no ∞. Suggests the room is nearly there; controller picks one. + - **spread** — numeric index span ≥ 2 ("more than one step of difference"), + OR any ∞ present, OR no numeric votes at all. Flags "discuss / re-vote / + split." ∞ always forces spread (someone thinks it is too big to size). + `?` and `☕` never count toward the numeric span. `☕` additionally raises a + separate, advisory "someone needs a break" hint. **The controller always + records the final estimate** — any deck value, or a split/skip — the signal + only advises and pre-fills; it never auto-decides. +- **Reconnect & identity.** Participants name themselves on the join page; a + cookie (same pattern as the `/s` → `/r` cookie) carries a stable per-browser + participant id so a refresh or a resumed poll rejoins the same seat rather + than spawning a duplicate. Every state poll refreshes `last_seen_at` + (heartbeat); a seat whose heartbeat lapses beyond the presence window drops + from the live roster without affecting the durable record. The first + snapshot after a (re)load carries the full room (phase, roster, votes if + revealed, results log) so a late or returning client renders immediately. +- **Deck values are canonical, labels are localized.** The deck is fixed + numeric values plus ?/∞/☕; only the _labels/aria_ around them are Paraglide + strings. `∞`, `☕`, `?` render with Lucide-consistent iconography (per + DESIGN.md additions), the numerals in the typewriter face. +- **Results log.** The controller console and a room results view render the + decided items with their final estimates from D1 (server-rendered on first + load, then kept current by the same poll). Closing the room sets + `poker_rooms.status = 'closed'`; a closed room refuses new votes and + commands and shows the final log. + +## New DESIGN.md rules (landed in this change) + +- **Card deck:** a row/grid of hand-drawn-radius paper cards (the control + radius family), the numeral centered in the mono face; the selected card + lifts and takes an ink border on the `--hl` tint (reusing the selector's + active-face convention). Special cards ?, ∞, ☕ use Lucide icons + (help-circle / infinity / coffee) sized to sit in the type. +- **Face-down / reveal:** during `voting` a cast vote shows as a face-down + card (paper back, no value) with a small check; **reveal flips all cards + face-up together** with a short transform-only flip (~240ms ease-out, + honoring reduced motion — opacity fade only). This is the signature + interaction for this capability, analogous to the three-state selector. +- **Roster:** a compact list of named seats with a "has voted" tick during + voting; the controller seat is marked. Reuses pill/badge conventions, + no new token. +- **Agreement signal:** a `NoticeBanner`-style strip — highlighter tint + + `--hl` dot for **agree**, neutral for **close**, and the diagonal + `ink-hatch` / `bad` tone for **spread** (borrowing the existing "no" + semantics), plus a quiet coffee hint. No new color tokens; the strip reuses + the documented tones. + +## Risks / Trade-offs + +- **[~1s latency, not instant push]** → accepted; at a room's pace (a reveal + every minute or two) 1s reads as live. The poll interval is a single + constant to tune, and the transport can move to a DO/WS later without any + data-model change (the live-state tables already model everything push + would). +- **[Extra D1 writes: a vote change + a heartbeat per poll]** → room volume is + tiny (one small team per room) and writes are single-row upserts; nothing + like the aggregation load the async product handles. The `rev` counter lets + the state read stay a couple of indexed lookups. +- **[Stale seats linger in the roster]** → presence is heartbeat-windowed, so + a closed tab drops out after the window lapses; the durable record is + untouched either way. +- **[Someone games the reveal]** → hidden votes are never sent before + `revealed`, so there is nothing on the client to peek at; privacy is a + server property (the state endpoint omits values), not a UI one. +- **[Controller closes their tab mid-session]** → control is tied to the + controller _token_, not a live connection; reopening the controller link + resumes control. The room never becomes unfacilitatable. +- **[Real-time is hard to e2e]** → nothing new to boot: Playwright drives two + browser contexts (controller + participant) against the same single-worker + `wrangler dev` + local D1, polling the real state endpoint, so the live + path is exercised end to end. + +## Migration Plan + +1. Land migration `0011_planning_poker.sql` (additive new tables) — safe + before code deploys; no existing table touched, nothing to backfill. +2. Deploy the Worker. No new bindings, no new secrets, no `wrangler.toml` + change. +3. Rollback: revert the deploy; the new tables are inert and referenced by + nothing in the async product. diff --git a/openspec/changes/archive/2026-07-24-add-planning-poker/proposal.md b/openspec/changes/archive/2026-07-24-add-planning-poker/proposal.md new file mode 100644 index 0000000..2ac39be --- /dev/null +++ b/openspec/changes/archive/2026-07-24-add-planning-poker/proposal.md @@ -0,0 +1,114 @@ +# Planning poker (real-time story-point estimation) + +## Why + +Every capability the tool ships today is an **asynchronous** poll: the +organizer sets options, participants answer whenever, results aggregate over +time. Agile teams have a different, synchronous need — sitting together +(remote or in a room) to estimate a backlog live, one item at a time, with +everyone revealing at once so nobody anchors on the loudest voice. That is +planning poker, and no async poll type can express it: it is stateful, +multi-party, and only interesting in real time. + +This change adds a **planning-poker room** — a shared, controller-run space +where a team estimates a sequence of items on a Fibonacci deck, votes hidden +until a synchronized reveal, with the tool flagging when the room agrees and +when it needs to talk. It keeps the product's core promise (no logins, the +link is the credential, free and instant) and extends the "poll" brand from +"collect answers over time" to "estimate together, now." + +It stays inside the product's existing stack. A Durable Object with WebSocket +push was considered and rejected on a concrete constraint (the SvelteKit +Cloudflare adapter exports only its own worker and overwrites `main`, so +shipping a DO fights the build and the single-worker e2e harness). Instead the +live session state lives in D1 — the room's phase, a heartbeat-presence +roster, and per-participant votes — and clients stay current by short-polling +a JSON state endpoint (~1s). PROJECT.md's "DOs are overkill" note holds: this +remains a D1 app, and ~1s updates read as live for a room's pace. + +## What Changes + +- **New room, not a new poll type.** Planning poker shares almost nothing + with the events/date_options/responses model (no dated options, no + async per-invitee answers), so it lives in its own tables and routes + rather than being forced into `poll_type`. Creating a room is free and + instant, landing the creator on a controller console. +- **Two capability links per room**, mirroring the existing token model: a + private **controller** link (facilitates the room) and a shared **join** + link anyone can enter through, naming themselves. A cookie remembers a + participant's identity so a refresh or reconnect resumes their seat. +- **One controller drives a small state machine per item:** `waiting` + (between items) → `voting` (cards face down, everyone casting) → + `revealed` (all cards flip up at once, discussion happens here). At most + one item is active at a time; the controller opens voting, reveals, + re-votes, or records the final estimate and moves on. +- **Modified Fibonacci deck** (`0 1 2 3 5 8 13 20 40 100`) plus three special cards: **?** + (need more info), **∞** (too big to estimate, split it), and **☕** + (I need a break). Votes stay hidden during `voting` — participants only + see _who_ has voted, never _what_ — and flip simultaneously on reveal. +- **Agreement signal on reveal.** The room computes whether the numeric + votes **agree** (all the same card), are **close** (within one adjacent + deck step), or are a **spread** (more than one step apart, any ∞, or no + numeric votes at all). Agreement pre-fills the suggested estimate; a + spread flags "discuss." The controller is always authoritative — the + signal advises, the controller records the final number (or splits/skips). + ☕ surfaces as a "someone needs a break" hint, advisory only. +- **Live for everyone.** Every phase change, join/leave, vote-cast tick, and + reveal shows up for all present participants within about a second, via a + JSON state endpoint each client short-polls — no manual refresh. +- **Durable record in D1.** The room, its ordered items, and each item's + final estimate persist so the controller keeps a running results log and + can revisit a closed room. The live phase, roster, and in-flight votes also + live in D1 (their own tables) but are transient — votes clear once an item + is decided; only the final estimate is a durable artifact. +- **Easy, clear UX.** One deck, one big reveal, one clear "does the room + agree?" answer. No timers, reactions, or backlog import in this change. + +## Capabilities + +### New Capabilities + +- `planning-poker`: a real-time, controller-run estimation room over a + Fibonacci deck (with ?/∞/☕ special cards). Free capability-URL creation + (controller + join links, cookie identity), a per-item waiting → voting → + revealed state machine with hidden votes and a synchronized reveal, an + agree/close/spread agreement signal, controller-recorded final estimates + persisted as a room results log, and live (~1s) propagation to all present + participants via D1-backed state that clients short-poll. + +### Modified Capabilities + +- None. The async event/poll capabilities and all their flows are untouched. + +## Impact + +- **First live layer, still on D1.** No Durable Object, no WebSocket, no new + binding or secret. Live coordination is D1-backed and clients short-poll a + state endpoint; ~1s update latency is the deliberate trade for staying in + the single-worker stack. +- **D1 migration** `0011_planning_poker.sql`: additive new tables + `poker_rooms` and `poker_rounds` (durable skeleton + final estimates) plus + the live-state tables `poker_participants` and `poker_votes`, and a phase / + active-round / rev counter on the room. No change to existing tables; + nothing to backfill. +- **New routes:** create-a-room, controller console, participant join page, + and per-role `state` (GET snapshot) + `command` (POST action) endpoints + under the token path. +- **New security surface:** control actions (open/reveal/finalize/next) + require the controller token; voting requires only the join token + a named + identity. Every load and endpoint authorizes the token against D1 and + reveals nothing on an unknown token; the state endpoint omits hidden vote + values before reveal. Token pages stay `noindex`, same discipline as the + existing `/e`, `/r`, `/s` pages. +- **New Paraglide strings** for the deck, phases, agreement signal, special + cards, join/roster, and results log, in all five locales. +- **New DESIGN.md rules** for the card deck, the face-down/face-up reveal + flip, the roster, and the agreement signal — landed in this change per the + design invariant. +- **E2E:** Playwright drives two browser contexts (controller + participant) + against the real Worker + local D1 on `:8787` (unchanged harness), polling + the real state endpoint; rooms are seedable directly via + `openspec/specs/support/db.ts` with an `e2e-poker-*` token family. +- **Non-goals (deferred):** per-vote timers, emoji reactions, multiple/ + custom decks, backlog/ticket import, persistent named teams, and any + account or login. "Free without limits" positioning is unchanged. diff --git a/openspec/changes/archive/2026-07-24-add-planning-poker/specs/planning-poker/spec.md b/openspec/changes/archive/2026-07-24-add-planning-poker/specs/planning-poker/spec.md new file mode 100644 index 0000000..c738fe8 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-add-planning-poker/specs/planning-poker/spec.md @@ -0,0 +1,303 @@ +# planning-poker Specification + +## Purpose + +A real-time, controller-run estimation room. A team sizes a sequence of +items on a Fibonacci deck, one item at a time: everyone casts a hidden vote, +the controller reveals all votes together, and the room sees whether it +agrees or needs to discuss. One person controls the room over a private link; +everyone else joins through a shared link and names themselves. The room, its +items, and each item's final estimate persist; the live phase and in-flight +votes are ephemeral coordination state. + +## Requirements + +### Requirement: Create a planning-poker room + +The system SHALL let anyone create a planning-poker room for free and +instantly, without an account. Creating a room SHALL generate two unguessable +capability tokens — a private **controller** token and a shared **join** +token — and SHALL land the creator on the controller console. A new room +SHALL start with status "open", the Fibonacci deck, and no decided items. + +#### Scenario: Create a room + +- GIVEN a visitor on the create-a-room page +- WHEN they name the room and submit +- THEN the system creates a room with status "open" +- AND generates an unguessable controller token and a separate join token +- AND redirects them to the controller console showing an empty results log + and a shareable join link + +#### Scenario: Controller and join links are distinct capabilities + +- GIVEN a created room +- WHEN the controller link and the join link are compared +- THEN they are different unguessable tokens +- AND the join link never grants control actions + +### Requirement: Join a room and be remembered + +A participant SHALL join a room through the shared join link by entering a +display name, after which they appear in the room's live roster. The system +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. + +#### Scenario: Join by naming yourself + +- GIVEN a visitor on a room's join page +- WHEN they enter a display name and join +- THEN they appear in the live roster under that name +- AND every already-connected participant sees the new seat appear live + +#### Scenario: Refresh resumes the same seat + +- GIVEN a participant who has joined and been remembered by their browser +- WHEN they refresh the page or reconnect after a dropped connection +- THEN they rejoin under the same identity +- AND no duplicate seat is created for them + +#### Scenario: Joining a closed room + +- GIVEN a room whose status is "closed" +- WHEN a visitor opens the join link +- THEN they see the final results log +- AND they are not offered a way to cast a vote + +### Requirement: Controller drives the per-item phases + +The room SHALL move one item at a time through three phases — +**waiting**, **voting**, and **revealed** — and only the controller SHALL +change phase. From waiting the controller SHALL open voting on an item; from +voting the controller SHALL reveal; from revealed the controller SHALL either +re-open voting (a re-vote) or record the final estimate and return the room +to waiting. At most one item SHALL be active at a time. A control action +attempted by a non-controller participant SHALL be ignored. + +#### Scenario: Open voting on an item + +- GIVEN a controller on a room in the waiting phase +- WHEN they name the next item and open voting +- THEN the room enters the voting phase for that item +- AND every connected participant sees the voting view live + +#### Scenario: Re-vote after revealing + +- GIVEN a room in the revealed phase +- WHEN the controller re-opens voting for the same item +- THEN the room returns to the voting phase +- AND every participant's previous vote for that item is cleared + +#### Scenario: Participant cannot drive phases + +- GIVEN a participant (not the controller) in a room +- WHEN a reveal or open-voting action arrives from that participant's client +- THEN the room's phase does not change + +### Requirement: Cast a hidden vote + +While the phase is voting, an estimator SHALL cast a vote by picking one card +from the modified Fibonacci deck (`0 1 2 3 5 8 13 20 40 100`) or a special +card, and SHALL be able to change it until the reveal. The controller MAY +also take part as an estimator; when they do, their vote counts and is +hidden and revealed like any other. Until the reveal the system SHALL show +only **which** seats have voted, never **what** any seat voted. A vote +message that arrives when the phase is not voting SHALL be rejected. + +#### Scenario: Cast and change a vote + +- GIVEN an estimator in a room in the voting phase +- WHEN they pick a card and then pick a different card before the reveal +- THEN their vote is recorded as the later card +- AND their seat shows as "voted" to everyone without exposing the value + +#### Scenario: Votes stay hidden until reveal + +- GIVEN several estimators who have voted in the voting phase +- WHEN another participant inspects what is sent to their client +- THEN no card value for any other seat is present before the reveal +- AND only the "has voted" state per seat is visible + +#### Scenario: Late joiner can still vote + +- GIVEN a room already in the voting phase +- WHEN a new participant joins and picks a card before the reveal +- THEN their vote is recorded and their seat shows as "voted" + +#### Scenario: Controller votes as an estimator + +- GIVEN a controller who has chosen to take part as an estimator +- WHEN they pick a card in the voting phase +- THEN their vote is hidden until reveal like any other seat +- AND it is included in the distribution and agreement signal on reveal + +#### Scenario: Vote after reveal is rejected + +- GIVEN a room in the revealed phase +- WHEN a vote message arrives from a participant's client +- THEN it is rejected and no vote is recorded or changed + +### Requirement: Synchronized reveal + +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. + +#### Scenario: Reveal flips all votes at once + +- GIVEN a room in the voting phase where several seats have voted +- WHEN the controller reveals +- THEN every participant sees all cast cards face-up together +- AND the distribution of votes across the deck is shown + +### Requirement: Special cards + +The deck SHALL include three special cards: **?** (need more info), **∞** +(too big to estimate), and **☕** (I need a break). The special cards SHALL be +castable like any card and SHALL be shown on reveal, but SHALL NOT count as +numeric estimates. A **∞** vote SHALL force the agreement signal to a spread. +A **☕** vote SHALL raise an advisory "someone needs a break" hint without +affecting the numeric agreement. + +#### Scenario: Infinity forces a spread + +- GIVEN a room where the numeric votes would otherwise agree +- WHEN at least one participant has voted ∞ and the controller reveals +- THEN the agreement signal is a spread +- AND the ∞ vote is shown but excluded from the numeric distribution + +#### Scenario: Coffee raises a break hint + +- GIVEN a room in the voting phase +- WHEN a participant votes ☕ and the controller reveals +- THEN a "someone needs a break" hint is shown +- AND the ☕ vote does not change the numeric agreement signal + +### Requirement: Agreement signal + +On reveal the system SHALL classify the numeric votes as **agree**, +**close**, or **spread**, considering only numeric cards by their position on +the deck. It SHALL be **agree** when there is at least one numeric vote, all +numeric votes are the same card, and no ∞ is present; **close** when the +numeric votes span exactly one adjacent deck step with no ∞; and **spread** +when they span more than one step, any ∞ is present, or there are no numeric +votes at all. On **agree** the system SHALL pre-fill the agreed value as the +suggested estimate. The signal SHALL be advisory only and SHALL NOT record an +estimate on its own. + +#### Scenario: Room agrees + +- GIVEN a room in the voting phase where every numeric vote is the card "5" + and no one voted ∞ +- WHEN the controller reveals +- THEN the signal is "agree" +- AND "5" is pre-filled as the suggested final estimate + +#### Scenario: Close but not equal + +- GIVEN votes on the two adjacent cards "3" and "5" and no ∞ +- WHEN the controller reveals +- THEN the signal is "close" + +#### Scenario: More than one step apart is a spread + +- GIVEN votes on "3" and "13" (more than one deck step apart) +- WHEN the controller reveals +- THEN the signal is "spread" + +#### Scenario: No numeric votes is a spread + +- GIVEN a room where every cast vote is a special card (? / ∞ / ☕) +- WHEN the controller reveals +- THEN the signal is "spread" +- AND no value is pre-filled as the suggested estimate + +### Requirement: Controller records the final estimate + +The controller SHALL be authoritative over the final estimate: from the +revealed phase they SHALL record a final estimate for the item — accepting +the suggestion, choosing any other deck value, or marking the item as split +or skipped. Recording an estimate SHALL decide the item, persist it to the +durable results log, and return the room to the waiting phase for the next +item. A decided item's estimate SHALL survive after the live session ends. + +#### Scenario: Record the suggested estimate + +- GIVEN a revealed room with an "agree" signal suggesting "5" +- WHEN the controller records the final estimate +- THEN the item is decided with estimate "5" +- AND it appears in the room's results log +- AND the room returns to the waiting phase + +#### Scenario: Controller overrides the suggestion + +- GIVEN a revealed room suggesting "5" +- WHEN the controller records "8" instead after discussion +- THEN the item is decided with estimate "8" + +#### Scenario: Decided estimate persists + +- GIVEN a room with one decided item +- WHEN the room's results are loaded fresh from durable storage +- THEN the decided item and its final estimate are present + +### Requirement: Live propagation + +Every phase change, join, leave, vote-cast tick, reveal, and recorded +estimate SHALL propagate to all present participants live, without a manual +refresh. A newly loaded client SHALL promptly receive the current room state +(phase, roster, revealed votes if any, and results log). + +#### Scenario: A phase change reaches everyone live + +- GIVEN two participants connected to the same room +- WHEN the controller opens voting +- THEN both participants' views switch to the voting phase without reloading + +#### Scenario: Connecting mid-session shows current state + +- GIVEN a room already in the revealed phase with a results log +- WHEN a new participant connects +- THEN they immediately see the revealed votes and the existing results log + +#### Scenario: A leaving participant drops from the live roster + +- GIVEN two connected participants +- WHEN one closes their connection +- THEN the other sees that seat removed from the live roster +- AND the durable results log is unaffected + +### Requirement: Control actions require the controller token + +Only the holder of the controller token SHALL be able to open voting, +reveal, re-vote, record an estimate, or close the room. The shared join token +SHALL grant joining and voting only. The server SHALL authorize the token +before a connection is treated as the controller. + +#### Scenario: Join token cannot control the room + +- GIVEN a participant connected with the join token +- WHEN a control action is issued from their client +- THEN the server does not perform it and the room state is unchanged + +#### Scenario: Controller token controls the room + +- GIVEN a client connected with the controller token +- WHEN they open voting on an item +- THEN the room enters the voting phase + +### Requirement: Close the room + +The controller SHALL be able to close the room, setting its status to +"closed". A closed room SHALL refuse new votes and phase changes and SHALL +present the final results log. + +#### Scenario: Closing ends estimation + +- GIVEN an open room with decided items +- WHEN the controller closes the room +- THEN the room status becomes "closed" +- AND participants see the final results log and cannot cast votes diff --git a/openspec/changes/archive/2026-07-24-add-planning-poker/tasks.md b/openspec/changes/archive/2026-07-24-add-planning-poker/tasks.md new file mode 100644 index 0000000..79d6f67 --- /dev/null +++ b/openspec/changes/archive/2026-07-24-add-planning-poker/tasks.md @@ -0,0 +1,90 @@ +# Tasks: add-planning-poker + +Transport note: shipped D1-backed (short-polled state endpoint), not a Durable +Object, after the adapter-cloudflare DO-export constraint (see design.md). + +## 1. Data layer + +- [x] 1.1 Migration `0011_planning_poker.sql`: durable `poker_rooms` + + `poker_rounds`, plus the live-state tables `poker_participants` + + `poker_votes` and the room's `phase` / `active_round_id` / `rev`. Additive + only, nothing to backfill +- [x] 1.2 Row types (`PokerRoomRow`, `PokerRoundRow`, `PokerParticipantRow`, + `PokerVoteRow`, `RoomPhase`, `ParticipantRole`) and a self-contained + `pokerProvider` (create room, resolve by token, roster/votes/results reads, + vote + controller commands, each bumping `rev`) +- [x] 1.3 Token generation reuses the existing base62 ≥128-bit helper for both + `controller_token` and `join_token` +- [x] 1.4 Rooms + rounds in the e2e seed helper + (`openspec/specs/support/db.ts`), seedable open/closed, `e2e-poker-*` + token/id family (delete-then-insert, idempotent, FK-safe) + +## 2. Real-time layer (D1-backed) + +- [x] 2.1 `GET /poker/api/[token]/state`: viewer snapshot; each poll doubles as + a heartbeat. `POST /poker/api/[token]/command`: one action + (join/vote/heartbeat/leave + controller open/reveal/revote/finalize/close) +- [x] 2.2 Live state in D1: room phase + active round, `poker_participants` + (heartbeat presence), `poker_votes` (active item, cleared on + finalize/re-vote) +- [x] 2.3 Snapshot on load: full room state (phase, roster, revealed votes if + any, results log); client short-polls ~1s (`RoomClient`) +- [x] 2.4 Vote privacy in `buildSnapshot`: during `voting` only per-seat + `hasVoted` (+ the caller's own card echoed), never other cards, until + `revealed`. Unit-tested +- [x] 2.5 Role enforcement: control commands controller-token-only (403 + otherwise); `castVote` guarded in SQL (voting phase + registered estimator); + `finalize` writes `final_estimate`/`decided_at` to the round +- [x] 2.6 Presence-windowed roster: a lapsed heartbeat drops the seat from the + live roster; durable record untouched + +## 3. Agreement signal + deck + +- [x] 3.1 Canonical deck: numeric `0 1 2 3 5 8 13 20 40 100` (modified + Fibonacci) plus specials `?`, `∞`, `☕` +- [x] 3.2 Signal computation (`agree`/`close`/`spread`) by deck-index span; `∞` + forces spread; `☕` raises the break hint; only `agree` pre-fills a + suggestion. Unit-tested + +## 4. Routes + +- [x] 4.1 Create-a-room page + action: free/instant, generate tokens, 303 to + the controller console +- [x] 4.2 Controller console (`/poker/c/{token}`): validate token → room; + open item, reveal, re-vote, record estimate (suggestion pre-filled on + `agree`), close; live via the poll; server-rendered shell +- [x] 4.3 Participant join page (`/poker/j/{token}`): name yourself (cookie + identity, observer toggle), deck to vote, face-down/reveal view, live roster + - signal; closed room shows results only, no voting +- [x] 4.4 Token is the path credential; every load/endpoint authorizes it in + D1 and 404s on unknown; token pages `noindex` + +## 5. Design & messages + +- [x] 5.1 Card deck UI (paper cards, selected lifts + ink border on `--hl`; + specials as Lucide icons), face-down back + synchronized reveal flip + (transform-only ~240ms, reduced-motion = opacity), roster with presence + + voted state, agreement-signal strip, break hint +- [x] 5.2 `openspec/specs/DESIGN.md` updated with the new rules (fixed blue + poker accent, card deck, reveal flip, roster, agreement signal) +- [x] 5.3 Paraglide strings for deck/aria, phases, special cards, join/roster, + signal + break hint, results, close in all five locales (no em-dashes) + +## 6. Specs & tests + +- [x] 6.1 Sync the delta spec into `openspec/specs/planning-poker/spec.md` + (new capability; main spec + colocated Playwright test) +- [x] 6.2 `openspec/specs/planning-poker/planning-poker.spec.ts`: Playwright + tests, two/three browser contexts against the real Worker + local D1 on + `:8787`, polling the real state endpoint. Covers create → console; join + + roster live; refresh resumes seat; closed room = results only; open voting + live; participant cannot drive phases; votes hidden until reveal + flip; + agree + record + persist; spread; ∞ forces spread; close ends estimation. + `e2e-poker-*` family; DB asserts via `expect.poll` + +## 7. Deploy + +- [x] 7.1 Migration `0011` applies locally (`bun run d1:migrate`); no new + binding or secret. Remote migration + deploy is the release step +- [x] 7.2 `bun run check` (0/0), `bun run lint` (clean), `bun run test` (128), + `bun run test:e2e` planning-poker (11 pass) diff --git a/openspec/changes/archive/2026-07-25-unify-poker-creation/.openspec.yaml b/openspec/changes/archive/2026-07-25-unify-poker-creation/.openspec.yaml new file mode 100644 index 0000000..3a03821 --- /dev/null +++ b/openspec/changes/archive/2026-07-25-unify-poker-creation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-25 diff --git a/openspec/changes/archive/2026-07-25-unify-poker-creation/design.md b/openspec/changes/archive/2026-07-25-unify-poker-creation/design.md new file mode 100644 index 0000000..9af1d18 --- /dev/null +++ b/openspec/changes/archive/2026-07-25-unify-poker-creation/design.md @@ -0,0 +1,137 @@ +## Context + +Planning poker landed as a self-contained capability: its own tables, its own +provider, its own routes under `/poker`, its own fixed blue accent. Nothing +links to it. `/poker/new` carries `noindex` and is reachable only by typing the +URL, so in practice the capability is invisible. + +Two structural facts drive this design: + +- **Poker shares no field with a poll.** No options, no invitees, no timezone, + no answering mode, no choice toggles, no highlighter. It writes to different + tables through a different provider. The only field in common is a title. +- **Poker has no locale.** `poker_rooms` has no locale column, and `/poker/*` + has no `[[lang=locale]]` segment, so `hooks.server` falls back to the base + locale and every room renders in English — even though all 49 poker strings + are already translated into all five languages. + +The second fact is latent today but becomes visible the moment creation moves +onto the localized create page: a visitor would pick Dansk, fill in a Danish +form, and land on an English console. + +## Goals / Non-Goals + +**Goals:** + +- Planning poker is discoverable from the landing page and reads as a sibling + tool, not a sixth poll type. +- One create page for both tools, sharing the same chrome, so the two look and + feel like one product. +- A room renders in the language it was created in. +- No regression to poll creation — the existing form is untouched behind the + choice. + +**Non-Goals:** + +- Merging poker into the events data model. `poll_type` stays a five-value + enum; poker keeps its own tables and provider. +- Making the landing example a real multi-player demo. It is a one-tap reveal + toy, the same class of thing as the existing poll examples. +- Changing the room's fixed accent, the deck, or any live-session behavior. +- Shipping planning poker to production. That is a separate release step (see + Migration Plan). + +## Decisions + +**A chooser on the create page, not a sixth poll type.** The two candidates +were making `poker` a value in `POLL_TYPES` alongside the five poll types, or +branching above the poll form. The poll type picker's contract is "the type +decides what the rest of the form asks for" — but every poll type still asks +for options, a mode, participants, and a highlighter, and poker asks for none +of them. Adding it there would thread a dead branch through the type union, +the form-boundary validator, the create action, and the events table's +`poll_type` column, for a value that never reaches that table. Branching one +level above keeps the poll form and its validation exactly as they are and +keeps poker's insert on its own provider. Cost: one more decision before the +form, on a page whose first control is already a radio group — the shape is +familiar, so the added step is cheap. + +**The choice rides a query parameter, not a separate route.** Keeping both +flows on `/[[lang=locale]]/create` is what buys poker the language segment, +the hreflang alternates, the browser-language hint, and the indexability that +poll creation already has. The choice is reflected in the URL the same way the +highlighter already is, so a "start a room" link is just the create page with a +query - which is what the landing call-to-action points at. The standalone +`/poker/new` is deleted rather than redirected: it was never released, linked, +or indexed, so there is no link in the wild it would rescue. Two form actions +on one page — the existing poll action and a room action — rather than one +action that branches, so neither validator has to know about the other's +fields. + +**Room language is a column, not a URL segment.** The alternative was giving +`/poker/*` its own `[[lang=locale]]` segment. That fails for the join link: the +controller shares one URL with the whole team, and a language baked into the +path would be a language the _controller's browser_ chose, not the room's. +Storing the locale on the room and calling the existing request-locale hook +from each poker load mirrors exactly how a poll's locale already works — the +poll's language comes from its row, not its URL — so poker stops being the +odd one out. The column is additive with a base-locale default; existing rows +read as English with nothing to backfill. + +**Planning poker leads the landing body.** The poll examples are what a visitor +came for and will find regardless; planning poker is the capability nobody is +searching for, so burying it under five example cards would keep it invisible +in a new way. It sits directly under the poll call-to-action, badged as new, +between solid rules — a harder break than the dashed rule the poll sections +use, because the thing below it is a different tool. + +**The landing section keeps its own accent.** The landing page's highlighter +picker restyles the whole page through `data-accent`. Poker's accent is fixed +by DESIGN.md. Scoping `data-accent="blue"` to the poker section makes the +picker's reach stop at the section boundary — which is the point: the visual +break is what tells a visitor this is a different tool. Consequence: the +poker call-to-action must not carry the `?accent=` query the poll one does. +This is a new design rule (an accent island inside an accent-picking page) and +lands in DESIGN.md with this change. + +**The example is a reveal, not a round.** Poker's signature interaction per +DESIGN.md is the synchronized reveal, and it is the one part of the tool that +reads in a single tap: four face-down cards turn face-up together and an +agreement read-out appears. Simulating a full round — join, name yourself, +vote, reveal — would be a second implementation of the live client for a toy. +The example reuses the existing card and signal surfaces so it stays correct +by construction if either changes. + +## Risks / Trade-offs + +- **The create page grows a second decision before the first field.** → + The choice reuses the radio-card pattern the poll type picker already + established, and the poll branch is pre-selected, so the poll flow costs one + glance and no clicks. +- **Landing page grows a fifth concern (pitch, poker, examples, pickers, + hint).** → The poker section leads the body behind solid rules on both sides, + so it reads as a separate tool rather than a poll section; the hero pitch and + poll call-to-action above it are untouched. +- **Poker strings now render in five languages for the first time in a real + flow.** They were translated but never exercised, so this change is where any + bad translation or overflowing label surfaces. → The colocated suite covers + the console in a non-base language. + +## Migration Plan + +1. Additive migration adds the room language column, defaulting to the base + locale. Existing rooms keep working and render in English. +2. Rollback is a redeploy of the previous Worker; the extra column is inert to + older code. + +Separately, and outside this change: **planning poker has never been released.** +`main` contains no poker files, so production serves 404 for every `/poker` +URL, and the remote database has never had the planning-poker migration +applied. Going live is merge → apply migrations remotely → deploy, and it must +happen before or with this change, or the landing page will advertise a tool +that 404s. + +## Open Questions + +- Should the results log of a closed room be indexable? Out of scope here — it + stays a token page, non-indexable with the rest. diff --git a/openspec/changes/archive/2026-07-25-unify-poker-creation/proposal.md b/openspec/changes/archive/2026-07-25-unify-poker-creation/proposal.md new file mode 100644 index 0000000..e80765b --- /dev/null +++ b/openspec/changes/archive/2026-07-25-unify-poker-creation/proposal.md @@ -0,0 +1,68 @@ +## Why + +Planning poker ships as a working capability that nothing links to: the site's +landing page pitches only the async polls, and rooms are created on a separate, +non-indexed page reachable only by typing its URL. A visitor has no way to +discover the tool exists. Planning poker is also the one capability that never +renders in the visitor's language — rooms always fall back to the base locale, +even though every string is already translated into all five. + +## What Changes + +- The landing page presents planning poker as a **sibling tool**, not a sixth + poll type: its own section leading the page body — above the poll examples, + badged as new, since nobody arrives looking for it — in the tool's own fixed + highlighter (so the landing highlighter picker leaves it alone), with a short + non-persisting example of the reveal and its own call-to-action. +- Room creation moves onto the **same create page as polls**, behind a + first-choice "what are you making" between a poll and a planning-poker room. + Picking the room reduces the form to a room name; picking a poll leaves + today's form untouched. +- **BREAKING (URL):** the standalone room-creation page is removed outright. It + was never released, never linked, and never indexed, so there is no link in + the wild to preserve. +- Room creation becomes **indexable** like poll creation; the controller and + join pages stay non-indexable like every other token page. +- A room **records the language chosen when it was created** and renders in it + for everyone who joins — the controller console, the join page, and the + results log. + +Three defects found while putting the tool in front of visitors, fixed here +because the landing page is about to advertise it: + +- **Reveal no longer cuts a round short.** It is held until every estimator + present has voted, and the room names who it is waiting on. Observers and + dropped-out seats never hold it up. +- **The recorded estimate stays inside what the room voted.** Only the deck + numerals between the lowest and highest card cast are offered, so a round + that split 3/8 cannot be recorded as 40. +- **Enter submits the room's text fields.** Naming yourself, naming yourself as + an estimating controller, and naming the next item were all mouse-only. + +## Capabilities + +### New Capabilities + +None. Both affected areas already have specs. + +### Modified Capabilities + +- `landing-page`: new requirement for the planning-poker section and its + example; the indexability requirement extends to name room creation + (indexable) and the room's token pages (not). +- `planning-poker`: the create-a-room requirement moves creation onto the + shared create page behind an explicit choice, and gains room language — + chosen at creation, rendered for every participant. + +## Impact + +- Landing page and create page: a new section, a new first choice, and the + copy for both in all five languages. +- Room storage gains a language column; a new migration, additive, nothing to + backfill (existing rooms read as the base locale). +- The standalone room-creation route is removed. +- Colocated Playwright suites for both capabilities grow; the poker suite's + room seeding gains the new column. +- Deployment note, outside this change: planning poker is not live — the + capability has never been released to production and the remote database has + never had the planning-poker migration applied. diff --git a/openspec/changes/archive/2026-07-25-unify-poker-creation/specs/landing-page/spec.md b/openspec/changes/archive/2026-07-25-unify-poker-creation/specs/landing-page/spec.md new file mode 100644 index 0000000..7211ac7 --- /dev/null +++ b/openspec/changes/archive/2026-07-25-unify-poker-creation/specs/landing-page/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: Planning poker on the landing page + +The landing page SHALL present planning poker as a tool distinct from the +polls, not as another poll type: it SHALL appear in its own section ahead of +the poll examples, marked as a new capability, explain that it is a live, +real-time way for a team to estimate together, and offer its own +call-to-action leading to room creation. The section SHALL show a non-persisting +example of the reveal — face-down cards that turn face-up together on a tap, +with the resulting agreement read-out — and the section SHALL make clear the +cards are an example. All of its copy SHALL render in the page's language. + +#### Scenario: Visitor sees planning poker as its own tool + +- GIVEN a visitor on the landing page +- WHEN the page renders +- THEN a section ahead of the poll examples explains planning poker as a live + team estimation tool, marked as new +- AND that section offers its own call-to-action for starting a room + +#### Scenario: Planning poker call-to-action keeps the page language + +- GIVEN a visitor on the Danish landing page +- WHEN they follow the planning-poker call-to-action +- THEN room creation renders in Danish + +#### Scenario: Reveal example turns the cards over + +- GIVEN a visitor on the landing page +- WHEN they tap the planning-poker example +- THEN the face-down cards turn face-up together showing their values +- AND the example's agreement read-out appears + +#### Scenario: Example answers are not persisted + +- GIVEN a visitor who revealed the planning-poker example +- WHEN they reload the landing page +- THEN the example is back to face-down +- AND no room, participant, or vote was recorded anywhere + +#### Scenario: Planning poker call-to-action carries the picked highlighter + +- GIVEN a visitor on the landing page who picked pink +- WHEN they follow the planning-poker call-to-action +- THEN room creation starts on pink + +## MODIFIED Requirements + +### Requirement: Marketing pages indexable, token pages not + +The landing page SHALL be indexable by search engines, and so SHALL both +creation flows — poll creation and planning-poker room creation. Pages reached through a +capability token (organizer dashboard, response pages, the shared open-mode +page, the planning-poker controller console, the planning-poker join page) +SHALL instruct search engines not to index them. + +#### Scenario: Landing page is indexable + +- GIVEN the landing page in any language +- WHEN the page renders +- THEN it carries no instruction blocking search engine indexing + +#### Scenario: Room creation is indexable + +- GIVEN the create page with planning-poker room creation chosen +- WHEN the page renders +- THEN it carries no instruction blocking search engine indexing + +#### Scenario: Token pages are not indexable + +- GIVEN a valid organizer, response, or shared link +- WHEN its page renders +- THEN the page instructs search engines not to index it + +#### Scenario: Planning-poker token pages are not indexable + +- GIVEN a valid planning-poker controller or join link +- WHEN its page renders +- THEN the page instructs search engines not to index it diff --git a/openspec/changes/archive/2026-07-25-unify-poker-creation/specs/planning-poker/spec.md b/openspec/changes/archive/2026-07-25-unify-poker-creation/specs/planning-poker/spec.md new file mode 100644 index 0000000..4b266f7 --- /dev/null +++ b/openspec/changes/archive/2026-07-25-unify-poker-creation/specs/planning-poker/spec.md @@ -0,0 +1,267 @@ +## ADDED Requirements + +### Requirement: Room language + +A room SHALL record the language chosen when it was created, and SHALL render +in that language for everyone — the controller console, the join page, the +voting view, and the results log — regardless of any visitor's browser +language. The language SHALL be fixed at creation. A room created before rooms +recorded a language SHALL render in the base language. + +#### Scenario: Room renders in the language it was created in + +- GIVEN a room created with Danish chosen +- WHEN the controller opens the console +- THEN the console renders in Danish + +#### Scenario: Participants see the creator's language, not their own + +- GIVEN a room created with Danish chosen +- WHEN a participant whose browser prefers French opens the join link +- THEN the join page and voting view render in Danish + +### Requirement: Room highlighter + +A room SHALL take a highlighter chosen when it was created, from the same set a +poll offers, and SHALL wear it for everyone — the controller console, the join +page, the voting view, and the results log. Planning poker SHALL therefore +offer the same highlighter choice as poll creation, at the same point in the +same flow. The highlighter SHALL be fixed at creation. A room created before +rooms recorded a highlighter SHALL keep the appearance it had. + +#### Scenario: Room wears the highlighter it was created with + +- GIVEN a visitor creating a room who picks the pink highlighter +- WHEN they land on the controller console +- THEN the console renders in pink + +#### Scenario: Participants see the room's highlighter + +- GIVEN a room created with the pink highlighter +- WHEN a participant opens the join link +- THEN the join page renders in pink + +#### Scenario: Room creation offers the same highlighters as poll creation + +- GIVEN a visitor on the create page +- WHEN they switch between making a poll and making a room +- THEN the same highlighter choice is offered either way +- AND the pick carries across the switch + +### Requirement: Room email + +Room creation SHALL offer the same optional email field poll creation does. An +address that is not a valid email address SHALL be rejected with an explanation +and SHALL NOT create the room. When a room is created with an address the +system SHALL send that address two transactional emails: one immediately, +carrying the room's title and its private controller link with a warning that +the link is secret; and one when the room is closed, carrying the room's title +and every decided item with its final estimate, in the order they were decided. +Unlike a poll organizer's address, a room's address SHALL be stored — the +closing summary is sent arbitrarily later — and SHALL live no longer than the +room. The stored address SHALL NOT be disclosed to any client. Delivery SHALL +be best-effort: a failure SHALL NOT prevent, delay, or roll back creating the +room, closing it, or any other room action. A room closed with no decided items +SHALL still send a summary, saying that nothing was decided. + +#### Scenario: Creating a room with an address mails the room's link + +- GIVEN a visitor creating a room who entered their email address +- WHEN the room is created +- THEN they land on the controller console exactly as without an address +- AND an email is sent to that address carrying the room's title, the private + controller link, and a warning to keep the link secret + +#### Scenario: Closing the room mails the results + +- GIVEN a room created with an address, with two decided items +- WHEN the controller closes the room +- THEN an email is sent to that address carrying the room's title and both + items with their final estimates + +#### Scenario: Closing a room that decided nothing + +- GIVEN a room created with an address and no decided items +- WHEN the controller closes the room +- THEN the email says that nothing was decided + +#### Scenario: Create without an address + +- GIVEN a visitor creating a room who leaves the email field empty +- WHEN the room is created +- THEN no address is stored and no email is ever sent for that room + +#### Scenario: Reject an invalid address + +- GIVEN a visitor creating a room who typed text that is not a valid email + address +- WHEN they submit +- THEN the submission is rejected with a validation message +- AND no room is created + +#### Scenario: The stored address is never disclosed + +- GIVEN a room created with an address +- WHEN anyone inspects what is sent to their client +- THEN the address is absent + +### Requirement: A closed room stops offering its join link + +Once a room is closed the controller console SHALL NOT offer the shared join +link for copying, since the link no longer admits anyone. + +#### Scenario: Join link disappears on close + +- GIVEN a controller on an open room showing the shareable join link +- WHEN they close the room +- THEN the join link is no longer offered + +### Requirement: Reveal waits for everyone present + +The controller SHALL NOT be able to reveal while an estimator who is present in +the room has not yet cast a card, and the room SHALL name who it is still +waiting on. Observers never cast a card and SHALL never hold a reveal up; +neither SHALL a seat that has dropped out of the room, so one absentee cannot +deadlock a round. With no present estimator at all the reveal SHALL stay +unavailable. + +#### Scenario: Reveal is held while someone has not voted + +- GIVEN a room in the voting phase with two estimators, one of whom has voted +- WHEN the controller looks at the reveal control +- THEN it is unavailable +- AND the room names the estimator it is still waiting on + +#### Scenario: Reveal frees up on the last vote + +- GIVEN a room in the voting phase where all but one estimator have voted +- WHEN the last estimator casts a card +- THEN the reveal becomes available to the controller + +#### Scenario: Observers never hold up a reveal + +- GIVEN a room where every estimator has voted and an observer is watching +- WHEN the controller looks at the reveal control +- THEN it is available + +#### Scenario: A dropped participant does not deadlock the round + +- GIVEN a room in the voting phase where one estimator has stopped being + present without voting and everyone still present has voted +- WHEN the controller looks at the reveal control +- THEN it is available + +### Requirement: Recorded estimate stays within what was voted + +On reveal the controller SHALL be offered, as the final estimate, only the deck +numerals from the lowest numeral cast through the highest, inclusive — never a +value the room did not bracket. A unanimous round SHALL offer only the agreed +numeral. Special cards SHALL NOT widen the range; when no numeral was cast at +all the whole deck SHALL be offered. The non-numeric outcomes (recording a +split, or skipping the item) SHALL remain available regardless. + +#### Scenario: Offered estimates span only the votes cast + +- GIVEN a revealed round whose numeric votes were "3" and "8" +- WHEN the controller records the estimate +- THEN the numerals offered are 3, 5, and 8 +- AND no numeral outside that span is offered + +#### Scenario: A unanimous round offers only its own value + +- GIVEN a revealed round where every numeric vote was "5" +- WHEN the controller records the estimate +- THEN "5" is the only numeral offered + +#### Scenario: Special cards do not widen the range + +- GIVEN a revealed round whose votes were "2", "3", and ∞ +- WHEN the controller records the estimate +- THEN the numerals offered are 2 and 3 + +#### Scenario: No numeric votes offers the whole deck + +- GIVEN a revealed round where every vote was a special card +- WHEN the controller records the estimate +- THEN the full deck of numerals is offered + +### Requirement: Keyboard submits the room's text entries + +Every single-line text entry in a room SHALL be submittable from the keyboard: +pressing Enter in the field SHALL do the same thing as its button. This covers +naming yourself to take a seat, naming yourself as an estimating controller, +and naming the next item. Submitting an empty field SHALL do nothing. + +#### Scenario: Enter joins the room + +- GIVEN a visitor on a room's join page +- WHEN they type a display name and press Enter in the name field +- THEN they take a seat under that name, exactly as if they had pressed the + join button + +#### Scenario: Enter opens voting on the next item + +- GIVEN a controller on a room in the waiting phase +- WHEN they type an item name and press Enter in the item field +- THEN the room enters the voting phase for that item + +#### Scenario: Enter on an empty field does nothing + +- GIVEN a visitor on a room's join page with an empty name field +- WHEN they press Enter in the field +- THEN no seat is taken and the page stays as it is + +## MODIFIED Requirements + +### Requirement: Create a planning-poker room + +The system SHALL let anyone create a planning-poker room for free and +instantly, without an account. Room creation SHALL live on the same create page +as poll creation, which SHALL open by asking what the visitor is making — a +poll or a planning-poker room — and SHALL ask only for a room name once a room +is chosen, plus the language and highlighter every created thing carries. Because a room's look is fixed, room creation SHALL NOT offer a +highlighter choice; because a room has a language, it SHALL offer a language +choice. Creating a room SHALL generate two unguessable capability tokens — a +private **controller** token and a shared **join** token — and SHALL land the +creator on the controller console. A new room SHALL start with status "open", +the Fibonacci deck, and no decided items. Submitting without a room name SHALL +be rejected with an explanation and SHALL NOT create a room. + +#### Scenario: Create a room + +- GIVEN a visitor on the create page who chose to make a planning-poker room +- WHEN they name the room and submit +- THEN the system creates a room with status "open" +- AND generates an unguessable controller token and a separate join token +- AND redirects them to the controller console showing an empty results log + and a shareable join link + +#### Scenario: Choosing a room asks only for a room name + +- GIVEN a visitor on the create page +- WHEN they choose to make a planning-poker room +- THEN the form asks for a room name, a language, a highlighter, and the same + optional email address poll creation asks for +- AND it no longer asks for anything that belongs only to a poll — poll type, + dates, options, answering mode, or participants + +#### Scenario: Choosing a poll leaves poll creation unchanged + +- GIVEN a visitor on the create page who chose a planning-poker room +- WHEN they switch back to making a poll +- THEN the full poll form is offered again, exactly as specified in + event-management + +#### Scenario: Room without a name is rejected + +- GIVEN a visitor on the create page who chose to make a planning-poker room +- WHEN they submit without naming the room +- THEN the page explains that a room name is required +- AND no room is created + +#### Scenario: Controller and join links are distinct capabilities + +- GIVEN a created room +- WHEN the controller link and the join link are compared +- THEN they are different unguessable tokens +- AND the join link never grants control actions diff --git a/openspec/changes/archive/2026-07-25-unify-poker-creation/tasks.md b/openspec/changes/archive/2026-07-25-unify-poker-creation/tasks.md new file mode 100644 index 0000000..1767b79 --- /dev/null +++ b/openspec/changes/archive/2026-07-25-unify-poker-creation/tasks.md @@ -0,0 +1,90 @@ +## 1. Live-session fixes (done ahead of the rest — reported against the running tool) + +- [x] 1.1 Add `canReveal` / `pendingVoters` to the snapshot logic: reveal is + held until every present estimator has voted; observers and absent seats + never count. Unit-tested. +- [x] 1.2 Disable the reveal control on the controller console while it is held, + and caption it with who the room is waiting on (`pokerWaitingOn`, five + languages). +- [x] 1.3 Add `estimateChoices` to the deck logic: the final-estimate numerals + are the deck slice from the lowest cast numeral to the highest, whole deck + when no numeral was cast. Unit-tested. +- [x] 1.4 Offer only those numerals on the console's record-estimate row; split + and skip stay unconditional. +- [x] 1.5 Wrap the three room text entries (join name, controller name, next + item) in real forms so Enter submits; buttons become `type="submit"`. +- [x] 1.6 Hide the shareable join link on the console once the room is closed. +- [x] 1.7 Room email: additive migration for the stored address, provider + setter, snapshot reports only that one is set, controller-only command + that stores it and mails the room link, and a closing summary of every + decided item. Composers unit-tested, including HTML escaping of + controller-typed item titles. +- [x] 1.8 Add colocated e2e coverage for the new planning-poker requirements + (reveal held / freed / observer / dropped seat; estimate range; Enter + submits; join link hidden on close; email attach + rejection + the + address never reaching a client). + +## 2. Room language + +- [x] 2.1 Additive migration: room language column, base-locale default. +- [x] 2.2 Room creation takes a language and stores it; mock provider and the + e2e room seeding helper carry the new column. +- [x] 2.3 Each poker page load resolves the room's language through the existing + request-locale hook, so the console, join page, and results log render in + it regardless of the viewer's browser. +- [x] 2.4 E2E: a room created in Danish renders in Danish for a viewer whose + browser prefers something else. + +## 3. Creation moves onto the create page + +- [x] 3.1 Add the "what are you making" choice above the poll form — poll + preselected — reusing the existing radio-card pattern; reflect the choice + in the URL alongside the highlighter. +- [x] 3.2 Poker branch renders room name only: no poll type, dates, options, + mode, participants, or highlighter picker; keep the language picker. +- [x] 3.3 Add the room-creation form action beside the existing poll action; + empty room name is rejected with a localized explanation. +- [x] 3.4 Delete the standalone room-creation route (never released, so nothing + to preserve); creation is now the create page, indexable like poll + creation rather than `noindex`. +- [x] 3.5 Copy for the choice and the poker branch in all five languages. +- [x] 3.6 E2E: create a room from the create page, switching back restores the + poll form, empty name rejected, indexability. + +## 3b. Feature parity + +- [x] 3b.1 Rooms take an organizer-picked highlighter like polls (migration, + provider, create form, every room page) instead of a hardcoded blue. +- [x] 3b.2 Room email moves onto the create form, matching the poll's optional + organizer email; the console's separate email box is gone along with its + command and snapshot flag. + +## 4. Landing page + +- [x] 4.1 Add the planning-poker section after the poll examples, scoped to the + tool's own fixed accent so the landing highlighter picker stops at its + boundary; its call-to-action carries the language but not the highlighter. +- [x] 4.2 Build the reveal example: face-down cards that turn face-up together + on a tap with the agreement read-out, reusing the existing card and signal + surfaces, persisting nothing. +- [x] 4.3 Section and example copy in all five languages. +- [x] 4.4 E2E: section present, call-to-action keeps the language, tap reveals, + reload resets, highlighter pick leaves the section alone. + +## 5. Documentation and drift + +- [x] 5.1 DESIGN.md: rooms now take an organizer-picked highlighter (was + hardcoded blue), plus the landing section's rules, new badge, and reveal + example. +- [x] 5.2 PROJECT.md: planning poker was absent entirely — added its purpose, + token kinds, data model, routes, and the D1-vs-Durable-Object note; create + route now documents both branches. +- [ ] 5.3 Sync both delta specs into the main specs and archive the change. + +## 6. Release (outside the change, but blocking the landing page) + +- [ ] 6.1 Merge planning poker to the default branch — production currently + serves 404 for every poker URL. +- [ ] 6.2 Apply outstanding migrations to the remote database; it has never had + the planning-poker tables. +- [ ] 6.3 Deploy, then verify a created room's join link resolves in production. diff --git a/openspec/specs/DESIGN.md b/openspec/specs/DESIGN.md index cc26956..a84922c 100644 --- a/openspec/specs/DESIGN.md +++ b/openspec/specs/DESIGN.md @@ -192,6 +192,38 @@ theme token instead. `×strokes`, with the words in `sr-only` text. - Prominent date displays capitalize the weekday via CSS (`capitalize`); running text keeps the poll language's own casing (Danish lowercase). +- **Planning poker** (the real-time estimation rooms) takes an organizer-picked + highlighter exactly as a poll does — picked on the create page, stored on the + room, worn by every one of its pages, so `--hl` resolves for the card tint. + Rooms created before rooms carried a highlighter stay blue, the value the + capability was hardcoded to. Its surfaces: + - **Cards** are paper cards on the control-radius family (`h-19 w-14`, or + `h-12 w-9` in the roster), the numeral centered in the mono face; a + selected/picked card takes an ink border on the `--hl` tint and lifts + (reusing the selector's active-face convention). The three special cards + render as Lucide icons in place of a numeral: `CircleQuestionMark` + (need info), `Infinity` (too big), `Coffee` (a break); each carries an + accessible label since the glyph is the value. + - **Face-down back:** a cast-but-hidden vote shows as a blank card back (a + faint centered dot, value withheld) with an `sr-only` "voted" label, + never the number, until the reveal. + - **Roster** is a column of bordered `card-alt` seat rows: a presence dot + (`good` present, `ink-faint` away), the name, then pill badges (solid-ink + for the controller, bordered for an observer) and the seat's voting + status. Reuses the pill/badge conventions, no new token. + - **Landing section.** Planning poker leads the landing page's body, between + solid `ink-faint` rules - a harder break than the dashed rule separating + poll sections, because what follows is a different tool. It opens with the + solid-ink pill (the roster's controller-badge shape) reading "new", since + nobody arrives looking for the capability. Its example is a single + reveal: four face-down cards that turn face-up together on one tap, with + the agreement strip below. No new tokens - it reuses the room's own card + and signal surfaces, so it cannot drift from the real thing. + - **Agreement signal** is a `NoticeBanner`-style strip in three tones, + reusing documented colors: highlighter tint + `--hl` dot for **agree** + (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. ## Motion @@ -212,6 +244,12 @@ decorative. Animate transform and opacity only — the result bar grows with a is the positive answer and takes the `--hl` face instead of the wash. - **List entrance → staggered**, ~35ms between cards on first load. Orchestrate, don't dump. +- **Planning-poker reveal** is the capability's signature interaction: on + reveal every seat's card flips face-up together with a short transform-only + scale-in (~240ms, from 0.8), the analogue of the three-state selector's + glide. Reduced motion drops the transform (duration 0). The live view + otherwise updates in place from the ~1s poll without animating (a value + seen constantly), per the "don't animate what's seen dozens of times" rule. - **Accent switch is live and animated:** picking a highlighter updates the page immediately (create previews on the form; the dashboard previews like the language picker, rolled back on cancel), and `--hl` is a registered diff --git a/openspec/specs/PROJECT.md b/openspec/specs/PROJECT.md index 19f9fb3..19ce2a7 100644 --- a/openspec/specs/PROJECT.md +++ b/openspec/specs/PROJECT.md @@ -2,11 +2,21 @@ ## Purpose -Let one organizer collect preferred dates from participants. The organizer +Two tools sharing one site, one design language, and one create page. + +**Polls** (the original): let one organizer collect preferred dates from participants. The organizer supplies the candidate dates; recipients only choose among them. Distribution is by the organizer copying each recipient's link and sending it themselves (email, text, etc.). No participant accounts. +**Planning poker**: a real-time, controller-run estimation room. A team sizes +items on a Fibonacci deck one at a time - everyone casts a hidden vote, the +controller reveals them together, the room sees whether it agrees. One person +controls the room over a private link; everyone else joins through a shared +link and names themselves. Asynchronous where polls are, live where polls are +not - but the same capability-URL model, the same paper look, the same five +languages, the same organizer-picked highlighter. + ## Stack - SvelteKit with `@sveltejs/adapter-cloudflare` @@ -25,6 +35,8 @@ for this write volume. - `organizer_token` - full management of one event (private, never shared) - `invitee_token` - respond as one invitee on one event (`/r/…`) - `share_token` - open-mode shared link anyone can respond through (`/s/…`) + - `controller_token` - full control of one planning-poker room (private) + - `join_token` - the room's shared link; join + vote only, never control - Tokens are unguessable and never listed publicly. Treat the organizer link as a secret. Decision: capability URL only for v1 - no passphrase. Mitigate leak risk by keeping tokens out of logs, referrers, and analytics. @@ -84,6 +96,36 @@ for this write volume. the system appended when the organizer added an option (the needs-confirmation marker the response page flags until resubmit) +### Planning poker (D1) + +- `poker_rooms(id, title, deck, controller_token, join_token, status, phase, +active_round_id, rev, locale, accent, email, created_at)` + - self-contained: shares no table with the events model, so it has its own + provider rather than extending the poll one + - status ∈ {open, closed}; phase ∈ {waiting, voting, revealed} - the live + phase of the current item, `waiting` between items + - `rev` bumps on every mutation so a state poll cheaply detects change + - locale / accent - the language and highlighter picked at creation, worn by + every one of the room's pages for everyone. Not URL segments: one join link + serves the whole team, so both travel with the room + - email - the controller's optional address. Stored, unlike the poll + organizer's, because the results summary is sent when the room closes +- `poker_rounds(id, room_id, title, sort_order, final_estimate, decided_at)` - + the items and each one's recorded estimate; the only durable artifact of a + decided item +- `poker_participants(id, room_id, name, role, is_controller, last_seen_at)` - + the roster. Presence is derived from `last_seen_at` against a 15s window, not + stored; the client's ~1s state poll doubles as the heartbeat (the write is + throttled to once per 5s per seat) +- `poker_votes(round_id, participant_id, card, updated_at)` - the active item's + votes, cleared on finalize and re-vote. Never leaves the server before the + reveal + +Real-time is D1-backed rather than a Durable Object: clients short-poll a state +endpoint. At this volume the polling fits inside the Workers plan's included +requests and D1 writes, so the reason to move to a DO would be write throughput +and push latency, not cost. + ## Routes - `/` landing page: explains the product, interactive per-poll-type examples @@ -92,22 +134,31 @@ for this write volume. `/create`); language switcher + hreflang alternates; no browser-language redirect, only a dismissible hint. Marketing pages are indexable; token pages (`/e`, `/r`, `/s`) declare noindex -- `/create` create a new event (title, description, language, poll type, mode; - dates and timezone or 2+ text options per type; participants in assigned - mode) +- `/create` create a new event or a planning-poker room. Opens by asking which: + a poll (title, description, language, highlighter, poll type, mode; dates and + timezone or 2+ text options per type; participants in assigned mode) or a + room (name, language, highlighter, optional email - nothing else). Same + per-language URLs as the landing page; `?make=poker` opens on the room branch - `/e/{organizer_token}` organizer dashboard: options, people/results, mode + language, shared link (open mode) - `/r/{invitee_token}` recipient response page (assigned invitee, or an open submitter's personal edit link) - `/s/{share_token}` open-mode shared page: name yourself, answer, submit. A cookie remembers the browser so a revisit edits its own answer via `/r` +- `/poker/c/{controller_token}` controller console: drive the phases, record + estimates, hand out the join link, close the room +- `/poker/j/{join_token}` participant page: name yourself, vote, watch the + reveal +- `/poker/api/{token}/state` and `/poker/api/{token}/command` - the live loop's + read and write endpoints; the token in the path is the only credential ## Conventions - Mutations use SvelteKit form actions; token is validated in every load/action. - Language: polls render in Danish, German, English, Spanish, or French, chosen per poll (the `locale` column) at creation and changeable on the - dashboard. Base locale is English. User-facing strings live in + dashboard. A planning-poker room's language works the same way but is fixed + at creation - one join link serves everyone, so it cannot follow a URL. Base locale is English. User-facing strings live in `messages/{da,de,en,es,fr}.json`, compiled to typed `m.*()` via Paraglide. Weekdays/months render in the poll's language with that language's conventional casing (Danish lowercase; others keep Intl's diff --git a/openspec/specs/landing-page/landing.spec.ts b/openspec/specs/landing-page/landing.spec.ts index 7755188..c6fe4a2 100644 --- a/openspec/specs/landing-page/landing.spec.ts +++ b/openspec/specs/landing-page/landing.spec.ts @@ -1,6 +1,14 @@ import { test, expect } from '@playwright/test'; import { m } from '../../../src/lib/paraglide/messages'; -import { d1, seedDateOption, seedEvent, seedInvitee, wipeEvent } from '../support/db'; +import { + d1, + seedDateOption, + seedEvent, + seedInvitee, + seedRoom, + wipeEvent, + wipeRoom +} from '../support/db'; // The landing page: pitch + create call-to-action, per-language URLs, the // client-only example cards, the browser-language hint, and the indexability @@ -267,3 +275,101 @@ test('token pages instruct search engines not to index', async ({ page }) => { await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); } }); + +// --- Requirement: Planning poker on the landing page --- + +test('visitor sees planning poker as its own tool', async ({ page }) => { + await page.goto('/'); + const poker = page.getByTestId('landing-poker'); + await expect(poker.getByText(m.landingPokerTitle())).toBeVisible(); + await expect(poker.getByText(m.landingPokerBadge())).toBeVisible(); + await expect(poker.getByText(m.landingPokerIntro())).toBeVisible(); + // Its own call-to-action, separate from the poll one. + await expect(poker.getByRole('link', { name: m.pokerCreateTitle() })).toBeVisible(); + + // Ahead of the poll examples on the page, not buried under them. + const pokerBox = await poker.boundingBox(); + const examplesBox = await page.getByText(m.landingExamplesHint()).boundingBox(); + expect(pokerBox?.y ?? 0).toBeLessThan(examplesBox?.y ?? 0); +}); + +test('planning poker call-to-action keeps the page language', async ({ page }) => { + await page.goto('/da'); + await page.getByTestId('landing-poker').getByRole('link').click(); + // Danish create page, opened on the room branch. + await expect(page).toHaveURL(/\/da\/create\?make=poker$/); + await expect( + page.getByRole('heading', { name: m.pokerCreateTitle({}, { locale: 'da' }) }) + ).toBeVisible(); +}); + +test('reveal example turns the cards over', async ({ page }) => { + await page.goto('/'); + const poker = page.getByTestId('landing-poker'); + // Face-down: no card value is on the page yet. + await expect(poker.getByLabel('8')).toHaveCount(0); + + await poker.getByRole('button', { name: m.landingPokerReveal() }).click(); + + // Face-up: the seat's card now shows its value (the distribution below + // repeats it, hence first()), and the agreement read-out appears - 5,5,8,5 + // spans exactly one deck step, so "close". + await expect(poker.getByLabel('8').first()).toBeVisible(); + await expect(poker.getByText(m.pokerSignalClose())).toBeVisible(); +}); + +test('planning poker example answers are not persisted', async ({ page }) => { + // Before/after, not absolute zero: these tables are shared with the poker + // suite, which leaves its own rooms and seats behind. + const before = ['poker_rooms', 'poker_participants', 'poker_votes'].map(rows); + await page.goto('/'); + await page + .getByTestId('landing-poker') + .getByRole('button', { name: m.landingPokerReveal() }) + .click(); + await expect(page.getByTestId('landing-poker').getByLabel('8').first()).toBeVisible(); + + await page.reload(); + // Back to face-down, and nothing was recorded. + await expect(page.getByTestId('landing-poker').getByLabel('8')).toHaveCount(0); + await expect( + page.getByTestId('landing-poker').getByRole('button', { name: m.landingPokerReveal() }) + ).toBeVisible(); + expect(['poker_rooms', 'poker_participants', 'poker_votes'].map(rows)).toEqual(before); +}); + +test('planning poker call-to-action carries the picked highlighter', async ({ page }) => { + await page.goto('/'); + // Pick pink: the whole page follows, the poker section included (a room takes + // an organizer-picked highlighter just like a poll). + await page.getByRole('radio', { name: m.accentPink() }).check(); + await expect(page.getByTestId('landing-root')).toHaveAttribute('data-accent', 'pink'); + + await page.getByTestId('landing-poker').getByRole('link').click(); + await expect(page).toHaveURL(/accent=pink/); + await expect(page).toHaveURL(/make=poker/); + await expect(page.getByRole('radio', { name: m.accentPink() })).toBeChecked(); +}); + +test('room creation is indexable', async ({ page }) => { + await page.goto('/create?make=poker'); + await expect(page.getByRole('heading', { name: m.pokerCreateTitle() })).toBeVisible(); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); +}); + +test('planning-poker token pages are not indexable', async ({ page }) => { + const ROOM = 'e2e-landing-room'; + wipeRoom(ROOM); + seedRoom({ + id: ROOM, + title: 'Landing noindex room (e2e)', + controllerToken: 'e2e-landing-ctok', + joinToken: 'e2e-landing-jtok' + }); + + for (const path of ['/poker/c/e2e-landing-ctok', '/poker/j/e2e-landing-jtok']) { + await page.goto(path); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); + } + wipeRoom(ROOM); +}); diff --git a/openspec/specs/landing-page/spec.md b/openspec/specs/landing-page/spec.md index b534bbf..2b62473 100644 --- a/openspec/specs/landing-page/spec.md +++ b/openspec/specs/landing-page/spec.md @@ -173,9 +173,11 @@ it hidden for the rest of the visit. ### Requirement: Marketing pages indexable, token pages not -The landing and create pages SHALL be indexable by search engines. Pages -reached through a capability token (organizer dashboard, response pages, the -shared open-mode page) SHALL instruct search engines not to index them. +The landing page SHALL be indexable by search engines, and so SHALL both +creation flows — poll creation and planning-poker room creation. Pages reached through a +capability token (organizer dashboard, response pages, the shared open-mode +page, the planning-poker controller console, the planning-poker join page) +SHALL instruct search engines not to index them. #### Scenario: Landing page is indexable @@ -183,8 +185,65 @@ shared open-mode page) SHALL instruct search engines not to index them. - WHEN the page renders - THEN it carries no instruction blocking search engine indexing +#### Scenario: Room creation is indexable + +- GIVEN the create page with planning-poker room creation chosen +- WHEN the page renders +- THEN it carries no instruction blocking search engine indexing + #### Scenario: Token pages are not indexable - GIVEN a valid organizer, response, or shared link - WHEN its page renders - THEN the page instructs search engines not to index it + +#### Scenario: Planning-poker token pages are not indexable + +- GIVEN a valid planning-poker controller or join link +- WHEN its page renders +- THEN the page instructs search engines not to index it + +### Requirement: Planning poker on the landing page + +The landing page SHALL present planning poker as a tool distinct from the +polls, not as another poll type: it SHALL appear in its own section ahead of +the poll examples, marked as a new capability, explain that it is a live, +real-time way for a team to estimate together, and offer its own +call-to-action leading to room creation. The section SHALL show a non-persisting +example of the reveal — face-down cards that turn face-up together on a tap, +with the resulting agreement read-out — and the section SHALL make clear the +cards are an example. All of its copy SHALL render in the page's language. + +#### Scenario: Visitor sees planning poker as its own tool + +- GIVEN a visitor on the landing page +- WHEN the page renders +- THEN a section ahead of the poll examples explains planning poker as a live + team estimation tool, marked as new +- AND that section offers its own call-to-action for starting a room + +#### Scenario: Planning poker call-to-action keeps the page language + +- GIVEN a visitor on the Danish landing page +- WHEN they follow the planning-poker call-to-action +- THEN room creation renders in Danish + +#### Scenario: Reveal example turns the cards over + +- GIVEN a visitor on the landing page +- WHEN they tap the planning-poker example +- THEN the face-down cards turn face-up together showing their values +- AND the example's agreement read-out appears + +#### Scenario: Example answers are not persisted + +- GIVEN a visitor who revealed the planning-poker example +- WHEN they reload the landing page +- THEN the example is back to face-down +- AND no room, participant, or vote was recorded anywhere + +#### Scenario: Planning poker call-to-action carries the picked highlighter + +- GIVEN a visitor on the landing page who picked pink +- WHEN they follow the planning-poker call-to-action +- THEN room creation starts on pink diff --git a/openspec/specs/planning-poker/planning-poker.spec.ts b/openspec/specs/planning-poker/planning-poker.spec.ts new file mode 100644 index 0000000..2c09441 --- /dev/null +++ b/openspec/specs/planning-poker/planning-poker.spec.ts @@ -0,0 +1,491 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { m } from '../../../src/lib/paraglide/messages'; +import { + roomEmail, + roomIdByControllerToken, + roomJoinToken, + roomResults, + roomStatus, + seedRoom, + wipeRoom +} from '../support/db'; + +// Planning poker: a real-time, controller-run estimation room +// (openspec/specs/planning-poker). Real-time is D1-backed and clients short- +// poll, so assertions wait for the ~1s loop via Playwright's auto-retrying +// expect. Two browser contexts stand in for the controller and a participant. +// English browser so bare m.*() matches the page (rooms seeded without a +// language read as the base locale). +test.use({ locale: 'en-US', timezoneId: 'Europe/Copenhagen' }); + +// One seeded room per test, reset in beforeEach. Own e2e-poker-* family. +const ROOM = 'e2e-poker-room'; +const CTOK = 'e2e-poker-ctok'; +const JTOK = 'e2e-poker-jtok'; + +function seedFresh() { + wipeRoom(ROOM); + seedRoom({ id: ROOM, title: 'Sprint 12 (e2e)', controllerToken: CTOK, joinToken: JTOK }); +} +test.beforeEach(() => { + // Realtime flows drive up to three browser contexts (controller + two + // participants) each polling ~1s, so they run well past the 30s default. + test.setTimeout(90_000); + seedFresh(); +}); +test.afterAll(() => wipeRoom(ROOM)); + +// Open a participant in its own context (own cookie = own seat) and join. +async function joinParticipant( + browser: Browser, + name: string, + opts: { observer?: boolean } = {} +): Promise<{ page: Page; close: () => Promise }> { + const ctx = await browser.newContext({ locale: 'en-US', timezoneId: 'Europe/Copenhagen' }); + const page = await ctx.newPage(); + await page.goto(`/poker/j/${JTOK}`); + await page.getByLabel(m.pokerNameLabel()).fill(name); + if (opts.observer) await page.getByRole('checkbox').check(); + await page.getByRole('button', { name: m.pokerJoinButton() }).click(); + return { page, close: () => ctx.close() }; +} + +async function openController( + browser: Browser +): Promise<{ page: Page; close: () => Promise }> { + const ctx = await browser.newContext({ locale: 'en-US', timezoneId: 'Europe/Copenhagen' }); + const page = await ctx.newPage(); + await page.goto(`/poker/c/${CTOK}`); + return { page, close: () => ctx.close() }; +} + +// --- Requirement: Create a planning-poker room --- + +test('creating a room lands on the controller console with a join link', async ({ page }) => { + await page.goto('/create?make=poker'); + await page.getByLabel(m.pokerRoomNameLabel()).fill('Backlog grooming'); + await page.getByRole('button', { name: m.pokerCreateButton() }).click(); + + await expect(page).toHaveURL(/\/poker\/c\//); + await expect(page.getByRole('heading', { name: 'Backlog grooming' })).toBeVisible(); + // The shareable join link is shown. + await expect(page.getByText(m.pokerJoinLinkLabel())).toBeVisible(); +}); + +// --- Requirement: Join a room and be remembered --- + +test('joining by name appears in the roster for everyone live', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + + // The controller sees Alice's seat appear without reloading. + await expect(c.page.getByText('Alice')).toBeVisible(); + await expect(p.page.getByText('Alice')).toBeVisible(); + + await p.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. + await expect(p.page.getByRole('button', { name: m.pokerJoinButton() })).toBeHidden(); + + await p.page.reload(); + // Still seated after reload (no name form), exactly one Alice. + await expect(p.page.getByRole('button', { name: m.pokerJoinButton() })).toBeHidden(); + await expect(p.page.getByText('Alice')).toHaveCount(1); + + await p.close(); +}); + +test('a closed room shows the final log and offers no vote', async ({ browser }) => { + // Pre-close the room by seeding it closed. + wipeRoom(ROOM); + seedRoom({ + id: ROOM, + title: 'Sprint 12 (e2e)', + controllerToken: CTOK, + joinToken: JTOK, + status: 'closed' + }); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto(`/poker/j/${JTOK}`); + + await expect(page.getByText(m.pokerClosedNotice())).toBeVisible(); + await expect(page.getByLabel(m.pokerNameLabel())).toBeHidden(); + + await ctx.close(); +}); + +// --- Requirement: Controller drives the per-item phases + Live propagation --- + +test('opening voting reaches a joined participant live', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-42 login'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + // Both flip to the voting phase; the participant sees the item and a deck. + await expect(p.page.getByTestId('poker-active-item')).toHaveText('PROJ-42 login'); + await expect(p.page.getByText(m.pokerPickACard())).toBeVisible(); + + await p.close(); + await c.close(); +}); + +test('a participant has no way to drive the phase (no reveal control)', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-9'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + await expect(p.page.getByTestId('poker-active-item')).toBeVisible(); + + // The participant view never offers the controller's reveal action. + await expect(p.page.getByRole('button', { name: m.pokerReveal() })).toHaveCount(0); + + await p.close(); + await c.close(); +}); + +// --- Requirement: Cast a hidden vote + Synchronized reveal --- + +test('votes stay hidden until the reveal, then flip face-up', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-42'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + // Alice votes 5 from the deck. + await p.page.getByTestId('poker-active-item').waitFor(); + await p.page.getByRole('button', { name: '5', exact: true }).click(); + + // Alice shows as present in the controller's roster, but her number is not + // shown while voting (privacy: card values are withheld before reveal). + const roster = c.page.locator('section', { hasText: m.pokerRosterHeading() }); + await expect(roster.getByText('Alice')).toBeVisible(); + await expect(roster.getByText('5', { exact: true })).toHaveCount(0); + + // Reveal: her card is now face-up in the roster. + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + await expect(roster.getByText('5', { exact: true })).toBeVisible(); + + await p.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 ({ + browser +}) => { + const c = await openController(browser); + const a = await joinParticipant(browser, 'Alice'); + const b = await joinParticipant(browser, 'Bob'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-42'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + for (const pg of [a.page, b.page]) { + await pg.getByTestId('poker-active-item').waitFor(); + await pg.getByRole('button', { name: '5', exact: true }).click(); + } + + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + await expect(c.page.getByText(m.pokerSignalAgree())).toBeVisible(); + + // Record the estimate from the finalize row, then it lands in the results log. + await c.page + .getByTestId('poker-finalize') + .getByRole('button', { name: '5', exact: true }) + .click(); + await expect(c.page.getByTestId('poker-results').getByText('PROJ-42')).toBeVisible(); + await expect.poll(() => roomResults(ROOM)).toEqual([{ title: 'PROJ-42', estimate: '5' }]); + + await a.close(); + await b.close(); + await c.close(); +}); + +test('more than one deck step apart is a spread', async ({ browser }) => { + const c = await openController(browser); + const a = await joinParticipant(browser, 'Alice'); + const b = await joinParticipant(browser, 'Bob'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-77'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + await a.page.getByTestId('poker-active-item').waitFor(); + await a.page.getByRole('button', { name: '3', exact: true }).click(); + await b.page.getByTestId('poker-active-item').waitFor(); + await b.page.getByRole('button', { name: '13', exact: true }).click(); + + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + await expect(c.page.getByText(m.pokerSignalSpread())).toBeVisible(); + + await a.close(); + await b.close(); + await c.close(); +}); + +test('an infinity vote forces a spread even when the numbers agree', async ({ browser }) => { + const c = await openController(browser); + const a = await joinParticipant(browser, 'Alice'); + const b = await joinParticipant(browser, 'Bob'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-88'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + await a.page.getByTestId('poker-active-item').waitFor(); + await a.page.getByRole('button', { name: '5', exact: true }).click(); + await b.page.getByTestId('poker-active-item').waitFor(); + // The infinity card, by its accessible label. + await b.page.getByRole('button', { name: m.pokerCardInfinity() }).click(); + + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + await expect(c.page.getByText(m.pokerSignalSpread())).toBeVisible(); + + await a.close(); + await b.close(); + await c.close(); +}); + +// --- Requirement: Close the room --- + +test('closing the room ends estimation for participants', async ({ browser }) => { + const c = await openController(browser); + const p = await joinParticipant(browser, 'Alice'); + + await c.page.getByRole('button', { name: m.pokerCloseRoom() }).click(); + + await expect(p.page.getByText(m.pokerClosedNotice())).toBeVisible(); + await expect.poll(() => roomStatus(ROOM)).toBe('closed'); + + await p.close(); + await c.close(); +}); + +// --- Requirement: Create a planning-poker room (on the shared create page) --- + +test('choosing a room asks only for a room name', async ({ page }) => { + await page.goto('/create?make=poker'); + await expect(page.getByLabel(m.pokerRoomNameLabel())).toBeVisible(); + + // None of the poll form's own fields are mounted, so none of them post. + await expect(page.getByText(m.fieldPollType())).toHaveCount(0); + await expect(page.getByText(m.datesSection())).toHaveCount(0); + await expect(page.getByText(m.optionsSection())).toHaveCount(0); + await expect(page.getByText(m.fieldMode())).toHaveCount(0); + // Parity with polls: the same highlighter choice is offered. + await expect(page.getByRole('radio', { name: m.accentPink() })).toBeVisible(); +}); + +test('choosing a poll leaves poll creation unchanged', async ({ page }) => { + await page.goto('/create?make=poker'); + await expect(page.getByLabel(m.pokerRoomNameLabel())).toBeVisible(); + + // Switch back: the full poll form returns. + await page.getByRole('radio', { name: m.createKindPoll() }).check(); + await expect(page.getByText(m.fieldPollType())).toBeVisible(); + await expect(page.getByText(m.fieldMode())).toBeVisible(); + await expect(page.getByRole('radio', { name: m.accentPink() })).toBeVisible(); + await expect(page.getByLabel(m.pokerRoomNameLabel())).toHaveCount(0); +}); + +test('a room wears the highlighter it was created with', async ({ page }) => { + await page.goto('/create?make=poker&accent=pink'); + await page.getByLabel(m.pokerRoomNameLabel()).fill('Pink room (e2e)'); + await page.getByRole('button', { name: m.pokerCreateButton() }).click(); + + await expect(page).toHaveURL(/\/poker\/c\//); + await expect(page.getByTestId('poker-console')).toHaveAttribute('data-accent', 'pink'); + + // And every participant sees the same, not their own preference. + const roomId = roomIdByControllerToken(page.url().split('/').pop() as string); + await page.goto(`/poker/j/${roomJoinToken(roomId)}`); + await expect(page.getByTestId('poker-room')).toHaveAttribute('data-accent', 'pink'); +}); + +test('a room without a name is rejected', async ({ page }) => { + await page.goto('/create?make=poker'); + // The submit stays disabled until the room is named, so no room can be made. + await expect(page.getByRole('button', { name: m.pokerCreateButton() })).toBeDisabled(); + await expect(page).toHaveURL(/\/create/); +}); + +// --- Requirement: Room language --- + +test('a room renders in the language it was created in', async ({ page }) => { + await page.goto('/da/create?make=poker'); + await page.getByLabel(m.pokerRoomNameLabel({}, { locale: 'da' })).fill('Sprint 13 (e2e)'); + await page.getByRole('button', { name: m.pokerCreateButton({}, { locale: 'da' }) }).click(); + + await expect(page).toHaveURL(/\/poker\/c\//); + // The console is Danish even though the browser asks for English. + await expect(page.getByText(m.pokerRosterHeading({}, { locale: 'da' }))).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('lang', 'da'); +}); + +test('participants see the creator language, not their own', async ({ page, browser }) => { + await page.goto('/da/create?make=poker'); + await page.getByLabel(m.pokerRoomNameLabel({}, { locale: 'da' })).fill('Sprint 14 (e2e)'); + await page.getByRole('button', { name: m.pokerCreateButton({}, { locale: 'da' }) }).click(); + await expect(page).toHaveURL(/\/poker\/c\//); + + const roomId = roomIdByControllerToken(page.url().split('/').pop() as string); + const ctx = await browser.newContext({ locale: 'fr-FR' }); + const p = await ctx.newPage(); + await p.goto(`/poker/j/${roomJoinToken(roomId)}`); + + // A French browser still gets the room's Danish. + await expect(p.getByLabel(m.pokerNameLabel({}, { locale: 'da' }))).toBeVisible(); + await ctx.close(); +}); + +// --- Requirement: Reveal waits for everyone present --- + +test('the reveal is held while someone has not voted, and frees up on the last vote', async ({ + browser +}) => { + const c = await openController(browser); + const a = await joinParticipant(browser, 'Alice'); + const b = await joinParticipant(browser, 'Bob'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-100'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + await a.page.getByTestId('poker-active-item').waitFor(); + await a.page.getByRole('button', { name: '5', exact: true }).click(); + + // Bob has not voted: reveal is unavailable and the room names who it waits on. + await expect(c.page.getByRole('button', { name: m.pokerReveal() })).toBeDisabled(); + await expect(c.page.getByTestId('poker-reveal-blocked')).toContainText('Bob'); + + await b.page.getByTestId('poker-active-item').waitFor(); + await b.page.getByRole('button', { name: '5', exact: true }).click(); + + await expect(c.page.getByRole('button', { name: m.pokerReveal() })).toBeEnabled(); + + await a.close(); + await b.close(); + await c.close(); +}); + +test('observers never hold up a reveal', async ({ browser }) => { + const c = await openController(browser); + const a = await joinParticipant(browser, 'Alice'); + const o = await joinParticipant(browser, 'Olive', { observer: true }); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-101'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + await a.page.getByTestId('poker-active-item').waitFor(); + await a.page.getByRole('button', { name: '5', exact: true }).click(); + + // Every estimator has voted; the watching observer does not block it. + await expect(c.page.getByRole('button', { name: m.pokerReveal() })).toBeEnabled(); + + await a.close(); + await o.close(); + await c.close(); +}); + +// --- Requirement: Recorded estimate stays within what was voted --- + +test('offered estimates span only the votes cast', async ({ browser }) => { + const c = await openController(browser); + const a = await joinParticipant(browser, 'Alice'); + const b = await joinParticipant(browser, 'Bob'); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-102'); + await c.page.getByRole('button', { name: m.pokerOpenVoting() }).click(); + + await a.page.getByTestId('poker-active-item').waitFor(); + await a.page.getByRole('button', { name: '3', exact: true }).click(); + await b.page.getByTestId('poker-active-item').waitFor(); + await b.page.getByRole('button', { name: '8', exact: true }).click(); + + await c.page.getByRole('button', { name: m.pokerReveal() }).click(); + + // 3 through 8 inclusive, and nothing outside that span. + const finalize = c.page.getByTestId('poker-finalize'); + for (const n of ['3', '5', '8']) + await expect(finalize.getByRole('button', { name: n, exact: true })).toBeVisible(); + for (const n of ['0', '1', '2', '13', '20', '40', '100']) + await expect(finalize.getByRole('button', { name: n, exact: true })).toHaveCount(0); + + await a.close(); + await b.close(); + await c.close(); +}); + +// --- Requirement: Keyboard submits the room's text entries --- + +test('enter joins the room and opens voting on the next item', async ({ browser }) => { + const ctx = await browser.newContext({ locale: 'en-US', timezoneId: 'Europe/Copenhagen' }); + const p = await ctx.newPage(); + await p.goto(`/poker/j/${JTOK}`); + + // Empty field: Enter does nothing, the join form stays. + await p.getByLabel(m.pokerNameLabel()).press('Enter'); + await expect(p.getByRole('button', { name: m.pokerJoinButton() })).toBeVisible(); + + await p.getByLabel(m.pokerNameLabel()).fill('Alice'); + await p.getByLabel(m.pokerNameLabel()).press('Enter'); + await expect(p.getByRole('button', { name: m.pokerJoinButton() })).toBeHidden(); + await expect(p.getByText('Alice')).toBeVisible(); + + // Same on the console's item field. + const c = await openController(browser); + await c.page.getByLabel(m.pokerNextItemLabel()).fill('PROJ-103'); + await c.page.getByLabel(m.pokerNextItemLabel()).press('Enter'); + await expect(c.page.getByTestId('poker-active-item')).toHaveText('PROJ-103'); + + await c.close(); + await ctx.close(); +}); + +// --- Requirement: A closed room stops offering its join link --- + +test('the join link disappears when the room closes', async ({ browser }) => { + const c = await openController(browser); + await expect(c.page.getByText(m.pokerJoinLinkLabel())).toBeVisible(); + + await c.page.getByRole('button', { name: m.pokerCloseRoom() }).click(); + + await expect(c.page.getByText(m.pokerJoinLinkLabel())).toHaveCount(0); + await c.close(); +}); + +// --- Requirement: Room email --- + +test('creating a room with an address stores it for the closing summary', async ({ page }) => { + await page.goto('/create?make=poker'); + await page.getByLabel(m.pokerRoomNameLabel()).fill('Emailed room (e2e)'); + await page.getByLabel(m.fieldOrganizerEmail()).fill('controller@example.com'); + await page.getByRole('button', { name: m.pokerCreateButton() }).click(); + await expect(page).toHaveURL(/\/poker\/c\//); + + const roomId = roomIdByControllerToken(page.url().split('/').pop() as string); + expect(roomEmail(roomId)).toBe('controller@example.com'); + + // Stored, but never sent back to any client. + const state = await page.request.get(`/poker/api/${page.url().split('/').pop()}/state`); + expect(await state.text()).not.toContain('controller@example.com'); +}); + +test('creating a room without an address stores none', async ({ page }) => { + await page.goto('/create?make=poker'); + await page.getByLabel(m.pokerRoomNameLabel()).fill('No-email room (e2e)'); + await page.getByRole('button', { name: m.pokerCreateButton() }).click(); + await expect(page).toHaveURL(/\/poker\/c\//); + + const roomId = roomIdByControllerToken(page.url().split('/').pop() as string); + expect(roomEmail(roomId)).toBe(null); +}); + +test('an invalid address is rejected and creates no room', async ({ page }) => { + await page.goto('/create?make=poker'); + await page.getByLabel(m.pokerRoomNameLabel()).fill('Bad-email room (e2e)'); + await page.getByLabel(m.fieldOrganizerEmail()).fill('not-an-email'); + await page.getByRole('button', { name: m.pokerCreateButton() }).click(); + + await expect(page.getByText(m.errorInvalidEmail())).toBeVisible(); + await expect(page).toHaveURL(/\/create/); +}); diff --git a/openspec/specs/planning-poker/spec.md b/openspec/specs/planning-poker/spec.md new file mode 100644 index 0000000..a89195a --- /dev/null +++ b/openspec/specs/planning-poker/spec.md @@ -0,0 +1,543 @@ +# planning-poker Specification + +## Purpose + +A real-time, controller-run estimation room. A team sizes a sequence of +items on a Fibonacci deck, one item at a time: everyone casts a hidden vote, +the controller reveals all votes together, and the room sees whether it +agrees or needs to discuss. One person controls the room over a private link; +everyone else joins through a shared link and names themselves. The room, its +items, and each item's final estimate persist; the live phase and in-flight +votes are ephemeral coordination state. + +## Requirements + +### Requirement: Create a planning-poker room + +The system SHALL let anyone create a planning-poker room for free and +instantly, without an account. Room creation SHALL live on the same create page +as poll creation, which SHALL open by asking what the visitor is making — a +poll or a planning-poker room — and SHALL ask only for a room name once a room +is chosen, plus the language and highlighter every created thing carries. Because a room's look is fixed, room creation SHALL NOT offer a +highlighter choice; because a room has a language, it SHALL offer a language +choice. Creating a room SHALL generate two unguessable capability tokens — a +private **controller** token and a shared **join** token — and SHALL land the +creator on the controller console. A new room SHALL start with status "open", +the Fibonacci deck, and no decided items. Submitting without a room name SHALL +be rejected with an explanation and SHALL NOT create a room. + +#### Scenario: Create a room + +- GIVEN a visitor on the create page who chose to make a planning-poker room +- WHEN they name the room and submit +- THEN the system creates a room with status "open" +- AND generates an unguessable controller token and a separate join token +- AND redirects them to the controller console showing an empty results log + and a shareable join link + +#### Scenario: Choosing a room asks only for a room name + +- GIVEN a visitor on the create page +- WHEN they choose to make a planning-poker room +- THEN the form asks for a room name, a language, a highlighter, and the same + optional email address poll creation asks for +- AND it no longer asks for anything that belongs only to a poll — poll type, + dates, options, answering mode, or participants + +#### Scenario: Choosing a poll leaves poll creation unchanged + +- GIVEN a visitor on the create page who chose a planning-poker room +- WHEN they switch back to making a poll +- THEN the full poll form is offered again, exactly as specified in + event-management + +#### Scenario: Room without a name is rejected + +- GIVEN a visitor on the create page who chose to make a planning-poker room +- WHEN they submit without naming the room +- THEN the page explains that a room name is required +- AND no room is created + +#### Scenario: Controller and join links are distinct capabilities + +- GIVEN a created room +- WHEN the controller link and the join link are compared +- THEN they are different unguessable tokens +- AND the join link never grants control actions + +### Requirement: Join a room and be remembered + +A participant SHALL join a room through the shared join link by entering a +display name, after which they appear in the room's live roster. The system +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. + +#### Scenario: Join by naming yourself + +- GIVEN a visitor on a room's join page +- WHEN they enter a display name and join +- THEN they appear in the live roster under that name +- AND every already-connected participant sees the new seat appear live + +#### Scenario: Refresh resumes the same seat + +- GIVEN a participant who has joined and been remembered by their browser +- WHEN they refresh the page or reconnect after a dropped connection +- THEN they rejoin under the same identity +- AND no duplicate seat is created for them + +#### Scenario: Joining a closed room + +- GIVEN a room whose status is "closed" +- WHEN a visitor opens the join link +- THEN they see the final results log +- AND they are not offered a way to cast a vote + +### Requirement: Controller drives the per-item phases + +The room SHALL move one item at a time through three phases — +**waiting**, **voting**, and **revealed** — and only the controller SHALL +change phase. From waiting the controller SHALL open voting on an item; from +voting the controller SHALL reveal; from revealed the controller SHALL either +re-open voting (a re-vote) or record the final estimate and return the room +to waiting. At most one item SHALL be active at a time. A control action +attempted by a non-controller participant SHALL be ignored. + +#### Scenario: Open voting on an item + +- GIVEN a controller on a room in the waiting phase +- WHEN they name the next item and open voting +- THEN the room enters the voting phase for that item +- AND every connected participant sees the voting view live + +#### Scenario: Re-vote after revealing + +- GIVEN a room in the revealed phase +- WHEN the controller re-opens voting for the same item +- THEN the room returns to the voting phase +- AND every participant's previous vote for that item is cleared + +#### Scenario: Participant cannot drive phases + +- GIVEN a participant (not the controller) in a room +- WHEN a reveal or open-voting action arrives from that participant's client +- THEN the room's phase does not change + +### Requirement: Cast a hidden vote + +While the phase is voting, an estimator SHALL cast a vote by picking one card +from the modified Fibonacci deck (`0 1 2 3 5 8 13 20 40 100`) or a special +card, and SHALL be able to change it until the reveal. The controller MAY +also take part as an estimator; when they do, their vote counts and is +hidden and revealed like any other. Until the reveal the system SHALL show +only **which** seats have voted, never **what** any seat voted. A vote +message that arrives when the phase is not voting SHALL be rejected. + +#### Scenario: Cast and change a vote + +- GIVEN an estimator in a room in the voting phase +- WHEN they pick a card and then pick a different card before the reveal +- THEN their vote is recorded as the later card +- AND their seat shows as "voted" to everyone without exposing the value + +#### Scenario: Votes stay hidden until reveal + +- GIVEN several estimators who have voted in the voting phase +- WHEN another participant inspects what is sent to their client +- THEN no card value for any other seat is present before the reveal +- AND only the "has voted" state per seat is visible + +#### Scenario: Late joiner can still vote + +- GIVEN a room already in the voting phase +- WHEN a new participant joins and picks a card before the reveal +- THEN their vote is recorded and their seat shows as "voted" + +#### Scenario: Controller votes as an estimator + +- GIVEN a controller who has chosen to take part as an estimator +- WHEN they pick a card in the voting phase +- THEN their vote is hidden until reveal like any other seat +- AND it is included in the distribution and agreement signal on reveal + +#### Scenario: Vote after reveal is rejected + +- GIVEN a room in the revealed phase +- WHEN a vote message arrives from a participant's client +- THEN it is rejected and no vote is recorded or changed + +### Requirement: Synchronized reveal + +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. + +#### Scenario: Reveal flips all votes at once + +- GIVEN a room in the voting phase where several seats have voted +- WHEN the controller reveals +- THEN every participant sees all cast cards face-up together +- AND the distribution of votes across the deck is shown + +### Requirement: Special cards + +The deck SHALL include three special cards: **?** (need more info), **∞** +(too big to estimate), and **☕** (I need a break). The special cards SHALL be +castable like any card and SHALL be shown on reveal, but SHALL NOT count as +numeric estimates. A **∞** vote SHALL force the agreement signal to a spread. +A **☕** vote SHALL raise an advisory "someone needs a break" hint without +affecting the numeric agreement. + +#### Scenario: Infinity forces a spread + +- GIVEN a room where the numeric votes would otherwise agree +- WHEN at least one participant has voted ∞ and the controller reveals +- THEN the agreement signal is a spread +- AND the ∞ vote is shown but excluded from the numeric distribution + +#### Scenario: Coffee raises a break hint + +- GIVEN a room in the voting phase +- WHEN a participant votes ☕ and the controller reveals +- THEN a "someone needs a break" hint is shown +- AND the ☕ vote does not change the numeric agreement signal + +### Requirement: Agreement signal + +On reveal the system SHALL classify the numeric votes as **agree**, +**close**, or **spread**, considering only numeric cards by their position on +the deck. It SHALL be **agree** when there is at least one numeric vote, all +numeric votes are the same card, and no ∞ is present; **close** when the +numeric votes span exactly one adjacent deck step with no ∞; and **spread** +when they span more than one step, any ∞ is present, or there are no numeric +votes at all. On **agree** the system SHALL pre-fill the agreed value as the +suggested estimate. The signal SHALL be advisory only and SHALL NOT record an +estimate on its own. + +#### Scenario: Room agrees + +- GIVEN a room in the voting phase where every numeric vote is the card "5" + and no one voted ∞ +- WHEN the controller reveals +- THEN the signal is "agree" +- AND "5" is pre-filled as the suggested final estimate + +#### Scenario: Close but not equal + +- GIVEN votes on the two adjacent cards "3" and "5" and no ∞ +- WHEN the controller reveals +- THEN the signal is "close" + +#### Scenario: More than one step apart is a spread + +- GIVEN votes on "3" and "13" (more than one deck step apart) +- WHEN the controller reveals +- THEN the signal is "spread" + +#### Scenario: No numeric votes is a spread + +- GIVEN a room where every cast vote is a special card (? / ∞ / ☕) +- WHEN the controller reveals +- THEN the signal is "spread" +- AND no value is pre-filled as the suggested estimate + +### Requirement: Controller records the final estimate + +The controller SHALL be authoritative over the final estimate: from the +revealed phase they SHALL record a final estimate for the item — accepting +the suggestion, choosing any other deck value, or marking the item as split +or skipped. Recording an estimate SHALL decide the item, persist it to the +durable results log, and return the room to the waiting phase for the next +item. A decided item's estimate SHALL survive after the live session ends. + +#### Scenario: Record the suggested estimate + +- GIVEN a revealed room with an "agree" signal suggesting "5" +- WHEN the controller records the final estimate +- THEN the item is decided with estimate "5" +- AND it appears in the room's results log +- AND the room returns to the waiting phase + +#### Scenario: Controller overrides the suggestion + +- GIVEN a revealed room suggesting "5" +- WHEN the controller records "8" instead after discussion +- THEN the item is decided with estimate "8" + +#### Scenario: Decided estimate persists + +- GIVEN a room with one decided item +- WHEN the room's results are loaded fresh from durable storage +- THEN the decided item and its final estimate are present + +### Requirement: Live propagation + +The room SHALL propagate every phase change, join, leave, vote-cast tick, +reveal, and recorded estimate to all present participants live, without a +manual refresh. A newly loaded client SHALL promptly receive the current room state +(phase, roster, revealed votes if any, and results log). + +#### Scenario: A phase change reaches everyone live + +- GIVEN two participants connected to the same room +- WHEN the controller opens voting +- THEN both participants' views switch to the voting phase without reloading + +#### Scenario: Connecting mid-session shows current state + +- GIVEN a room already in the revealed phase with a results log +- WHEN a new participant connects +- THEN they immediately see the revealed votes and the existing results log + +#### Scenario: A leaving participant drops from the live roster + +- GIVEN two connected participants +- WHEN one closes their connection +- THEN the other sees that seat removed from the live roster +- AND the durable results log is unaffected + +### Requirement: Control actions require the controller token + +Only the holder of the controller token SHALL be able to open voting, +reveal, re-vote, record an estimate, or close the room. The shared join token +SHALL grant joining and voting only. The server SHALL authorize the token +before a connection is treated as the controller. + +#### Scenario: Join token cannot control the room + +- GIVEN a participant connected with the join token +- WHEN a control action is issued from their client +- THEN the server does not perform it and the room state is unchanged + +#### Scenario: Controller token controls the room + +- GIVEN a client connected with the controller token +- WHEN they open voting on an item +- THEN the room enters the voting phase + +### Requirement: Close the room + +The controller SHALL be able to close the room, setting its status to +"closed". A closed room SHALL refuse new votes and phase changes and SHALL +present the final results log. + +#### Scenario: Closing ends estimation + +- GIVEN an open room with decided items +- WHEN the controller closes the room +- THEN the room status becomes "closed" +- AND participants see the final results log and cannot cast votes + +### Requirement: Room language + +A room SHALL record the language chosen when it was created, and SHALL render +in that language for everyone — the controller console, the join page, the +voting view, and the results log — regardless of any visitor's browser +language. The language SHALL be fixed at creation. A room created before rooms +recorded a language SHALL render in the base language. + +#### Scenario: Room renders in the language it was created in + +- GIVEN a room created with Danish chosen +- WHEN the controller opens the console +- THEN the console renders in Danish + +#### Scenario: Participants see the creator's language, not their own + +- GIVEN a room created with Danish chosen +- WHEN a participant whose browser prefers French opens the join link +- THEN the join page and voting view render in Danish + +### Requirement: Room highlighter + +A room SHALL take a highlighter chosen when it was created, from the same set a +poll offers, and SHALL wear it for everyone — the controller console, the join +page, the voting view, and the results log. Planning poker SHALL therefore +offer the same highlighter choice as poll creation, at the same point in the +same flow. The highlighter SHALL be fixed at creation. A room created before +rooms recorded a highlighter SHALL keep the appearance it had. + +#### Scenario: Room wears the highlighter it was created with + +- GIVEN a visitor creating a room who picks the pink highlighter +- WHEN they land on the controller console +- THEN the console renders in pink + +#### Scenario: Participants see the room's highlighter + +- GIVEN a room created with the pink highlighter +- WHEN a participant opens the join link +- THEN the join page renders in pink + +#### Scenario: Room creation offers the same highlighters as poll creation + +- GIVEN a visitor on the create page +- WHEN they switch between making a poll and making a room +- THEN the same highlighter choice is offered either way +- AND the pick carries across the switch + +### Requirement: Room email + +Room creation SHALL offer the same optional email field poll creation does. An +address that is not a valid email address SHALL be rejected with an explanation +and SHALL NOT create the room. When a room is created with an address the +system SHALL send that address two transactional emails: one immediately, +carrying the room's title and its private controller link with a warning that +the link is secret; and one when the room is closed, carrying the room's title +and every decided item with its final estimate, in the order they were decided. +Unlike a poll organizer's address, a room's address SHALL be stored — the +closing summary is sent arbitrarily later — and SHALL live no longer than the +room. The stored address SHALL NOT be disclosed to any client. Delivery SHALL +be best-effort: a failure SHALL NOT prevent, delay, or roll back creating the +room, closing it, or any other room action. A room closed with no decided items +SHALL still send a summary, saying that nothing was decided. + +#### Scenario: Creating a room with an address mails the room's link + +- GIVEN a visitor creating a room who entered their email address +- WHEN the room is created +- THEN they land on the controller console exactly as without an address +- AND an email is sent to that address carrying the room's title, the private + controller link, and a warning to keep the link secret + +#### Scenario: Closing the room mails the results + +- GIVEN a room created with an address, with two decided items +- WHEN the controller closes the room +- THEN an email is sent to that address carrying the room's title and both + items with their final estimates + +#### Scenario: Closing a room that decided nothing + +- GIVEN a room created with an address and no decided items +- WHEN the controller closes the room +- THEN the email says that nothing was decided + +#### Scenario: Create without an address + +- GIVEN a visitor creating a room who leaves the email field empty +- WHEN the room is created +- THEN no address is stored and no email is ever sent for that room + +#### Scenario: Reject an invalid address + +- GIVEN a visitor creating a room who typed text that is not a valid email + address +- WHEN they submit +- THEN the submission is rejected with a validation message +- AND no room is created + +#### Scenario: The stored address is never disclosed + +- GIVEN a room created with an address +- WHEN anyone inspects what is sent to their client +- THEN the address is absent + +### Requirement: A closed room stops offering its join link + +Once a room is closed the controller console SHALL NOT offer the shared join +link for copying, since the link no longer admits anyone. + +#### Scenario: Join link disappears on close + +- GIVEN a controller on an open room showing the shareable join link +- WHEN they close the room +- THEN the join link is no longer offered + +### Requirement: Reveal waits for everyone present + +The controller SHALL NOT be able to reveal while an estimator who is present in +the room has not yet cast a card, and the room SHALL name who it is still +waiting on. Observers never cast a card and SHALL never hold a reveal up; +neither SHALL a seat that has dropped out of the room, so one absentee cannot +deadlock a round. With no present estimator at all the reveal SHALL stay +unavailable. + +#### Scenario: Reveal is held while someone has not voted + +- GIVEN a room in the voting phase with two estimators, one of whom has voted +- WHEN the controller looks at the reveal control +- THEN it is unavailable +- AND the room names the estimator it is still waiting on + +#### Scenario: Reveal frees up on the last vote + +- GIVEN a room in the voting phase where all but one estimator have voted +- WHEN the last estimator casts a card +- THEN the reveal becomes available to the controller + +#### Scenario: Observers never hold up a reveal + +- GIVEN a room where every estimator has voted and an observer is watching +- WHEN the controller looks at the reveal control +- THEN it is available + +#### Scenario: A dropped participant does not deadlock the round + +- GIVEN a room in the voting phase where one estimator has stopped being + present without voting and everyone still present has voted +- WHEN the controller looks at the reveal control +- THEN it is available + +### Requirement: Recorded estimate stays within what was voted + +On reveal the controller SHALL be offered, as the final estimate, only the deck +numerals from the lowest numeral cast through the highest, inclusive — never a +value the room did not bracket. A unanimous round SHALL offer only the agreed +numeral. Special cards SHALL NOT widen the range; when no numeral was cast at +all the whole deck SHALL be offered. The non-numeric outcomes (recording a +split, or skipping the item) SHALL remain available regardless. + +#### Scenario: Offered estimates span only the votes cast + +- GIVEN a revealed round whose numeric votes were "3" and "8" +- WHEN the controller records the estimate +- THEN the numerals offered are 3, 5, and 8 +- AND no numeral outside that span is offered + +#### Scenario: A unanimous round offers only its own value + +- GIVEN a revealed round where every numeric vote was "5" +- WHEN the controller records the estimate +- THEN "5" is the only numeral offered + +#### Scenario: Special cards do not widen the range + +- GIVEN a revealed round whose votes were "2", "3", and ∞ +- WHEN the controller records the estimate +- THEN the numerals offered are 2 and 3 + +#### Scenario: No numeric votes offers the whole deck + +- GIVEN a revealed round where every vote was a special card +- WHEN the controller records the estimate +- THEN the full deck of numerals is offered + +### Requirement: Keyboard submits the room's text entries + +Every single-line text entry in a room SHALL be submittable from the keyboard: +pressing Enter in the field SHALL do the same thing as its button. This covers +naming yourself to take a seat, naming yourself as an estimating controller, +and naming the next item. Submitting an empty field SHALL do nothing. + +#### Scenario: Enter joins the room + +- GIVEN a visitor on a room's join page +- WHEN they type a display name and press Enter in the name field +- THEN they take a seat under that name, exactly as if they had pressed the + join button + +#### Scenario: Enter opens voting on the next item + +- GIVEN a controller on a room in the waiting phase +- WHEN they type an item name and press Enter in the item field +- THEN the room enters the voting phase for that item + +#### Scenario: Enter on an empty field does nothing + +- GIVEN a visitor on a room's join page with an empty name field +- WHEN they press Enter in the field +- THEN no seat is taken and the page stays as it is diff --git a/openspec/specs/support/db.ts b/openspec/specs/support/db.ts index 803e179..45c1dd2 100644 --- a/openspec/specs/support/db.ts +++ b/openspec/specs/support/db.ts @@ -238,3 +238,100 @@ export function selectedOptionIds(eventId: string): string[] { `SELECT id FROM date_options WHERE event_id = ${lit(eventId)} AND selected = 1 ORDER BY sort_order` ).results.map((r) => r.id as string); } + +// --- Planning poker (openspec/specs/planning-poker). Durable skeleton only: +// rooms + rounds + final estimates. Live phase/votes are held by the real-time +// layer, not D1, so there is nothing to seed for them. Own e2e-poker-* id/token +// family. --- + +export interface RoomSeed { + id: string; + title: string; + controllerToken: string; + joinToken: string; + status?: 'open' | 'closed'; // omit → column default 'open' + deck?: string; // omit → column default 'fibonacci' + locale?: string; // omit → 'en', the base locale + accent?: string; // omit → 'blue', what rooms were hardcoded to + createdAt?: string; +} +export interface RoundSeed { + id: string; + roomId: string; + title: string; + sortOrder: number; + finalEstimate?: string | null; // omit → NULL (not yet decided) + decidedAt?: string | null; +} + +export function seedRoom(r: RoomSeed) { + d1( + `INSERT INTO poker_rooms (id, title, deck, controller_token, join_token, status, locale, accent, created_at) VALUES + (${lit(r.id)}, ${lit(r.title)}, ${lit(r.deck ?? 'fibonacci')}, ${lit(r.controllerToken)}, ${lit(r.joinToken)}, ${lit(r.status ?? 'open')}, ${lit(r.locale ?? 'en')}, ${lit(r.accent ?? 'blue')}, ${lit(r.createdAt ?? NOW)});` + ); +} + +export function seedRound(r: RoundSeed) { + d1( + `INSERT INTO poker_rounds (id, room_id, title, sort_order, final_estimate, decided_at) VALUES + (${lit(r.id)}, ${lit(r.roomId)}, ${lit(r.title)}, ${r.sortOrder}, ${lit(r.finalEstimate ?? null)}, ${lit(r.decidedAt ?? null)});` + ); +} + +// Delete a room and all its children in FK order (votes, participants, rounds, +// room). Accepts one id or several. Idempotent - safe to call before every seed. +export function wipeRoom(roomId: string | string[]) { + const ids = (Array.isArray(roomId) ? roomId : [roomId]).map(lit).join(', '); + d1( + // Break the rooms<->rounds cycle first (active_round_id references a round), + // then delete children in FK order. + `UPDATE poker_rooms SET active_round_id = NULL WHERE id IN (${ids}); + DELETE FROM poker_votes WHERE round_id IN (SELECT id FROM poker_rounds WHERE room_id IN (${ids})); + DELETE FROM poker_participants WHERE room_id IN (${ids}); + DELETE FROM poker_rounds WHERE room_id IN (${ids}); + DELETE FROM poker_rooms WHERE id IN (${ids});` + ); +} + +// --- Read helpers (assertions) --- + +export function roomStatus(roomId: string): string { + return d1(`SELECT status FROM poker_rooms WHERE id = ${lit(roomId)}`).results[0].status as string; +} + +// Resolve a room id from its controller token - creation flows only know the +// token they landed on. +export function roomIdByControllerToken(token: string): string { + return d1(`SELECT id FROM poker_rooms WHERE controller_token = ${lit(token)}`).results[0] + .id as string; +} + +// A room's shared join token - creation flows only know the controller token +// they landed on, and the console's copy row is not a reliable place to read it. +export function roomJoinToken(roomId: string): string { + return d1(`SELECT join_token FROM poker_rooms WHERE id = ${lit(roomId)}`).results[0] + .join_token as string; +} + +// The controller address attached to a room, or null when none is. The address +// is stored (the closing summary is sent later) but never leaves the server, so +// tests read it here rather than from any client payload. +export function roomEmail(roomId: string): string | null { + return (d1(`SELECT email FROM poker_rooms WHERE id = ${lit(roomId)}`).results[0].email ?? + null) as string | null; +} + +// Decided items with their recorded estimate, in display order. Undecided +// rounds are absent (final_estimate IS NULL). +export function roomResults(roomId: string): { title: string; estimate: string }[] { + return d1( + `SELECT title, final_estimate FROM poker_rounds WHERE room_id = ${lit(roomId)} AND final_estimate IS NOT NULL ORDER BY sort_order` + ).results.map((r) => ({ title: r.title as string, estimate: r.final_estimate as string })); +} + +// Every round title for a room in order (decided or not) - for roster/queue asserts. +export function roundTitles(roomId: string): string[] { + return d1( + `SELECT title FROM poker_rounds WHERE room_id = ${lit(roomId)} ORDER BY sort_order` + ).results.map((r) => r.title as string); +} diff --git a/src/lib/components/organisms/LandingPoker.svelte b/src/lib/components/organisms/LandingPoker.svelte new file mode 100644 index 0000000..1b0f79f --- /dev/null +++ b/src/lib/components/organisms/LandingPoker.svelte @@ -0,0 +1,88 @@ + + +
+
+ +
+ {m.landingPokerBadge()} + +
+

+ {m.landingPokerIntro()} +

+
+ +
+
+ {m.landingPokerExampleLabel()} +
+ +
+ {#each SEATS as seat (seat.name)} +
+ + {seat.name} +
+ {/each} +
+ + {#if signal && distribution} + + {:else} + + {/if} +
+ + + + {m.pokerCreateTitle()} + +
diff --git a/src/lib/components/poker/Card.svelte b/src/lib/components/poker/Card.svelte new file mode 100644 index 0000000..96c17f8 --- /dev/null +++ b/src/lib/components/poker/Card.svelte @@ -0,0 +1,47 @@ + + + + {#if faceDown || card === null} + + + {:else if isSpecialCard(card)} + {#if card === '?'} + {:else if card === 'infinity'} + {:else}{/if} + {:else} + {card} + {/if} + diff --git a/src/lib/components/poker/Deck.svelte b/src/lib/components/poker/Deck.svelte new file mode 100644 index 0000000..a5c2a4e --- /dev/null +++ b/src/lib/components/poker/Deck.svelte @@ -0,0 +1,28 @@ + + +
+ {#each DECK as card (cardToText(card))} + + {/each} +
diff --git a/src/lib/components/poker/Roster.svelte b/src/lib/components/poker/Roster.svelte new file mode 100644 index 0000000..796ee7e --- /dev/null +++ b/src/lib/components/poker/Roster.svelte @@ -0,0 +1,68 @@ + + +
+ +
    + {#each seats as seat (seat.id)} +
  • + + {seat.name} + + {#if seat.isController} + {m.pokerControllerBadge()} + {/if} + {#if seat.role === 'observer'} + {m.pokerObserverBadge()} + {/if} + + {#if seat.role === 'estimator'} + {#if phase === 'revealed' && cardByPid.has(seat.id)} + {#key cardByPid.get(seat.id)} + + {/key} + {:else if phase === 'voting'} + {#if seat.hasVoted} + + {m.pokerVoted()} + {:else} + {m.pokerThinking()} + {/if} + {/if} + {/if} +
  • + {/each} +
+
diff --git a/src/lib/components/poker/Signal.svelte b/src/lib/components/poker/Signal.svelte new file mode 100644 index 0000000..9ef52d2 --- /dev/null +++ b/src/lib/components/poker/Signal.svelte @@ -0,0 +1,59 @@ + + +
+
+ + {label} + {#if signal.suggestion !== null} + {m.pokerSuggested({ value: signal.suggestion })} + {/if} +
+ + {#if signal.needsBreak} +
+ + {m.pokerBreakHint()} +
+ {/if} + +
+ {#each shown as d (String(d.card))} +
+ + ×{d.count} +
+ {/each} +
+
diff --git a/src/lib/components/templates/CreatePage.svelte b/src/lib/components/templates/CreatePage.svelte index 004a861..413fad4 100644 --- a/src/lib/components/templates/CreatePage.svelte +++ b/src/lib/components/templates/CreatePage.svelte @@ -23,7 +23,15 @@ import { fly, slide } from 'svelte/transition'; import AccentPicker from '$lib/components/atoms/AccentPicker.svelte'; import LanguagePicker from '$lib/components/atoms/LanguagePicker.svelte'; - import type { Accent, DateOption, Locale, Participant, PollMode, PollType } from '$lib/types'; + import type { + Accent, + CreateKind, + DateOption, + Locale, + Participant, + PollMode, + PollType + } from '$lib/types'; import { HIGHLIGHT_BUDGET_DEFAULT, isTextPollType } from '$lib/types'; // The create action's fail() payload; null on first render / success. @@ -33,16 +41,27 @@ form, suggestedLocale, suggestedAccent = 'yellow', + suggestedKind = 'poll', hintLocale = null }: { form: { error?: string } | null; suggestedLocale: Locale; suggestedAccent?: Accent; + // Which of the two things the page opens on, from the ?make= query. + suggestedKind?: CreateKind; // Browser-preferred language when it differs from the form's: offered as // a dismissible hint, never a redirect. hintLocale?: Locale | null; } = $props(); + // The first decision: a poll or a planning-poker room. A room shares this + // page's chrome and language but none of the poll's fields, so it branches + // here rather than being a sixth poll type. + // svelte-ignore state_referenced_locally + let kind = $state(suggestedKind); + const poker = $derived(kind === 'poker'); + let roomName = $state(''); + // Start empty; dates and participants are added via the same fill-then-add // cards the dashboard uses. let title = $state(''); @@ -127,7 +146,7 @@ document.documentElement.lang = next; // Keep the URL on the picked language's create route (shallow - no // reload, the {#key locale} re-render does the work). - replaceState(createUrl(next, accent), {}); + replaceState(createUrl(next, accent, kind), {}); } locale = next; } @@ -135,7 +154,14 @@ // The highlighter pick syncs the ?accent= query the same way, so the // landing-page choice and a reload both keep it. function pickAccent(next: Accent) { - if (browser) replaceState(createUrl(locale, next), {}); + if (browser) replaceState(createUrl(locale, next, kind), {}); + } + + // Switching branch is reflected in the URL the same way, so a reload — or the + // landing page's "start a room" link — lands on the same branch. + function pickKind(next: CreateKind) { + kind = next; + if (browser) replaceState(createUrl(locale, accent, next), {}); } // The hint is dismissible for the session (same key as the landing page's @@ -186,7 +212,7 @@ before our localized error can render. -->

- {m.createTitle()} + {poker ? m.pokerCreateTitle() : m.createTitle()}

- +
- {m.fieldPollType()} + {m.fieldCreateKind()} - - {#each [{ value: 'dates', label: m.pollTypeDates() }, { value: 'rank', label: m.pollTypeRank() }, { value: 'question', label: m.pollTypeQuestion() }, { value: 'highlight', label: m.pollTypeHighlight() }, { value: 'rsvp', label: m.pollTypeRsvp() }] as opt (opt.value)} - {@const active = pollType === opt.value} + {#each [{ value: 'poll', label: m.createKindPoll() }, { value: 'poker', label: m.createKindPoker() }] as opt (opt.value)} + {@const active = kind === opt.value} {/each} - {#key pollType} + {#key kind}

- {pollType === 'question' - ? m.pollTypeQuestionHint() - : pollType === 'rsvp' - ? m.pollTypeRsvpHint() - : pollType === 'rank' - ? m.pollTypeRankHint() - : pollType === 'highlight' - ? m.pollTypeHighlightHint() - : m.pollTypeDatesHint()} + {poker ? m.createKindPokerHint() : m.createKindPollHint()}

{/key}
-
- -
+ {#if poker} + +
+ +
-
- -
+ +
+ +

{m.pokerEmailHint()}

+
- {#if textType} -
- -

{m.optionsHint()}

- - {#if pollType === 'highlight'} -
- -

{m.budgetHint()}

-
+
+ {#if form?.error} +
{form.error}
{/if} -
- {:else if pollType === 'rsvp'} -
- -

{m.dateHintRsvp()}

- {@render timezoneField()} - +
{:else} + +
+ + {m.fieldPollType()} + + + {#each [{ value: 'dates', label: m.pollTypeDates() }, { value: 'rank', label: m.pollTypeRank() }, { value: 'question', label: m.pollTypeQuestion() }, { value: 'highlight', label: m.pollTypeHighlight() }, { value: 'rsvp', label: m.pollTypeRsvp() }] as opt (opt.value)} + {@const active = pollType === opt.value} + + {/each} + {#key pollType} +

+ {pollType === 'question' + ? m.pollTypeQuestionHint() + : pollType === 'rsvp' + ? m.pollTypeRsvpHint() + : pollType === 'rank' + ? m.pollTypeRankHint() + : pollType === 'highlight' + ? m.pollTypeHighlightHint() + : m.pollTypeDatesHint()} +

+ {/key} +
+ +
+ +
+
- -

{m.datesHint()}

- {@render timezoneField()} - +
- {/if} - - {#if noToggles} - - - {:else} -
- {m.fieldChoices()} -

{m.choicesHint()}

- - - - -
- {/if} + {#if noToggles} + + + {:else} +
+ {m.fieldChoices()} +

{m.choicesHint()}

+ + + + +
+ {/if} -
- - {m.fieldMode()} - - {#each [{ value: 'open', label: m.modeOpen() }, { value: 'assigned', label: m.modeAssigned() }] as opt (opt.value)} - {@const active = pollMode === opt.value} -
+ + {#if pollMode === 'assigned'} +
+ toast.show(m.linkCopied())} /> - - - - {opt.label} - - {/each} - {#key pollMode} -

- {pollMode === 'open' ? m.modeOpenHint() : m.modeAssignedHint()} -

- {/key} - +
+ {/if} - {#if pollMode === 'assigned'}
- toast.show(m.linkCopied())} - /> + +

{m.organizerEmailHint()}

- {/if} -
- -

{m.organizerEmailHint()}

-
- -
- {#if form?.error} -
{form.error}
- {/if} - {#if !valid} - -
- {!title.trim() - ? m.errorNoTitle() - : textType - ? m.errorTooFewOptions() - : pollType === 'rsvp' - ? m.errorRsvpOneDate() - : m.errorNoDates()} -
- {/if} - -
+
+ {#if form?.error} +
{form.error}
+ {/if} + {#if !valid} + +
+ {!title.trim() + ? m.errorNoTitle() + : textType + ? m.errorTooFewOptions() + : pollType === 'rsvp' + ? m.errorRsvpOneDate() + : m.errorNoDates()} +
+ {/if} + +
+ {/if}
diff --git a/src/lib/data/poker.ts b/src/lib/data/poker.ts new file mode 100644 index 0000000..061409b --- /dev/null +++ b/src/lib/data/poker.ts @@ -0,0 +1,331 @@ +// D1 access for planning poker (openspec/specs/planning-poker). Self-contained: +// planning poker shares no tables with the events model, so it has its own +// provider rather than extending DataProvider. Real-time is D1-backed - the +// live phase, roster, and votes are rows here, kept current by clients that +// short-poll the state endpoint. This is the only place poker SQL lives. + +import type { + PokerParticipantRow, + PokerRoomRow, + PokerRoundRow, + PokerVoteRow, + ParticipantRole, + Accent, + Locale +} from '$lib/types'; +import { id, newToken } from './shared'; + +function mapRoom(r: Record): PokerRoomRow { + return { + id: r.id as string, + title: r.title as string, + deck: r.deck as string, + controllerToken: r.controller_token as string, + joinToken: r.join_token as string, + status: r.status as PokerRoomRow['status'], + phase: r.phase as PokerRoomRow['phase'], + activeRoundId: (r.active_round_id as string | null) ?? null, + rev: r.rev as number, + email: (r.email as string | null) ?? null, + locale: r.locale as PokerRoomRow['locale'], + accent: r.accent as PokerRoomRow['accent'], + createdAt: r.created_at as string + }; +} + +function mapRound(r: Record): PokerRoundRow { + return { + id: r.id as string, + roomId: r.room_id as string, + title: r.title as string, + sortOrder: r.sort_order as number, + finalEstimate: (r.final_estimate as string | null) ?? null, + decidedAt: (r.decided_at as string | null) ?? null + }; +} + +function mapParticipant(r: Record): PokerParticipantRow { + return { + id: r.id as string, + roomId: r.room_id as string, + name: r.name as string, + role: r.role as ParticipantRole, + isController: r.is_controller === 1, + lastSeenAt: r.last_seen_at as string + }; +} + +function mapVote(r: Record): PokerVoteRow { + return { + roundId: r.round_id as string, + participantId: r.participant_id as string, + card: r.card as string, + updatedAt: r.updated_at as string + }; +} + +export interface CreateRoomResult { + roomId: string; + controllerToken: string; + joinToken: string; +} + +export interface PokerProvider { + createRoom( + title: string, + locale: Locale, + accent: Accent, + email: string | null + ): Promise; + getRoomByControllerToken(token: string): Promise; + getRoomByJoinToken(token: string): Promise; + getRoundById(roundId: string): Promise; + listParticipants(roomId: string): Promise; + listVotes(roundId: string): Promise; + /** Decided items in display order - the durable results log. */ + listResults(roomId: string): Promise<{ title: string; estimate: string }[]>; + + // --- Participant actions --- + /** Upsert a seat by id (cookie-carried), refreshing its name/role and heartbeat. */ + joinRoom( + roomId: string, + participantId: string, + name: string, + role: ParticipantRole, + isController: boolean + ): Promise; + /** Refresh presence only. */ + heartbeat(participantId: string): Promise; + /** Explicit leave: drop the seat now (graceful close), rather than waiting out presence. */ + 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; + + // --- Controller actions (each bumps rev) --- + /** Create the next item and open voting on it. */ + openRound(roomId: string, title: string): Promise; + /** waiting/revealed → voting is refused; only voting reveals. */ + reveal(roomId: string): Promise; + /** Re-open voting on the active item, clearing its votes. */ + revote(roomId: string): Promise; + /** Record the final estimate, clear votes, return to waiting. */ + finalize(roomId: string, estimate: string): Promise; + /** End the session: status closed, votes cleared, results kept. */ + closeRoom(roomId: string): Promise; +} + +export function pokerProvider(db: D1Database): PokerProvider { + const room = async (where: string, token: string): Promise => { + const r = await db.prepare(`SELECT * FROM poker_rooms WHERE ${where} = ?`).bind(token).first(); + return r ? mapRoom(r) : null; + }; + + // A room is required for most mutations; caller already resolved it by token. + const bumpRev = (roomId: string) => + db.prepare(`UPDATE poker_rooms SET rev = rev + 1 WHERE id = ?`).bind(roomId); + + return { + async createRoom(title: string, locale: Locale, accent: Accent, email: string | null) { + const roomId = id('room'); + const controllerToken = newToken(); + const joinToken = newToken(); + await db + .prepare( + `INSERT INTO poker_rooms (id, title, deck, controller_token, join_token, status, phase, active_round_id, rev, locale, accent, email, created_at) + VALUES (?, ?, 'fibonacci', ?, ?, 'open', 'waiting', NULL, 0, ?, ?, ?, datetime('now'))` + ) + .bind(roomId, title, controllerToken, joinToken, locale, accent, email) + .run(); + return { roomId, controllerToken, joinToken }; + }, + + getRoomByControllerToken: (t) => room('controller_token', t), + getRoomByJoinToken: (t) => room('join_token', t), + + async getRoundById(roundId) { + const r = await db.prepare(`SELECT * FROM poker_rounds WHERE id = ?`).bind(roundId).first(); + return r ? mapRound(r) : null; + }, + + async listParticipants(roomId) { + const { results } = await db + .prepare(`SELECT * FROM poker_participants WHERE room_id = ? ORDER BY last_seen_at`) + .bind(roomId) + .all(); + return results.map(mapParticipant); + }, + + async listVotes(roundId) { + const { results } = await db + .prepare(`SELECT * FROM poker_votes WHERE round_id = ?`) + .bind(roundId) + .all(); + return results.map(mapVote); + }, + + async listResults(roomId) { + const { results } = await db + .prepare( + `SELECT title, final_estimate FROM poker_rounds + WHERE room_id = ? AND final_estimate IS NOT NULL ORDER BY sort_order` + ) + .bind(roomId) + .all(); + return results.map((r) => ({ + title: r.title as string, + estimate: r.final_estimate as string + })); + }, + + async joinRoom(roomId, participantId, name, role, isController) { + // Insert a new seat or update the existing one (name/role can change, + // heartbeat always refreshes). rev bumps so others see the roster change. + await db.batch([ + db + .prepare( + `INSERT INTO poker_participants (id, room_id, name, role, is_controller, last_seen_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, role = excluded.role, + is_controller = excluded.is_controller, last_seen_at = datetime('now')` + ) + .bind(participantId, roomId, name, role, isController ? 1 : 0), + bumpRev(roomId) + ]); + }, + + async heartbeat(participantId) { + // Every client poll (~1s per seat) lands here, so the write is throttled: + // only refresh a stamp already older than a third of the presence window. + // Worst case a seat's stamp is 5s stale against a 15s window, so presence + // is unaffected - but D1 takes ~5x fewer writes, and D1 is one SQLite + // writer shared with the whole poll product. + // ponytail: 5s hardcoded against PRESENCE_WINDOW_MS's 15s. If the window + // ever moves, derive this from it rather than retuning by hand. + await db + .prepare( + `UPDATE poker_participants SET last_seen_at = datetime('now') + WHERE id = ? AND last_seen_at < datetime('now', '-5 seconds')` + ) + .bind(participantId) + .run(); + }, + + async removeParticipant(roomId, participantId) { + await db.batch([ + db.prepare(`DELETE FROM poker_participants WHERE id = ?`).bind(participantId), + bumpRev(roomId) + ]); + }, + + async castVote(roomId, participantId, card) { + // Guard entirely in SQL: the vote lands only while the room is voting with + // an active round AND the caster is a registered estimator seat in this + // room (so observers and non-joined callers cannot vote). Refresh presence + // at the same time. + await db.batch([ + db + .prepare( + `INSERT INTO poker_votes (round_id, participant_id, card, updated_at) + SELECT r.active_round_id, ?, ?, datetime('now') FROM poker_rooms r + WHERE r.id = ? AND r.phase = 'voting' AND r.active_round_id IS NOT NULL + AND EXISTS (SELECT 1 FROM poker_participants p + WHERE p.id = ? AND p.room_id = r.id AND p.role = 'estimator') + ON CONFLICT(round_id, participant_id) DO UPDATE SET card = excluded.card, updated_at = excluded.updated_at` + ) + .bind(participantId, card, roomId, participantId), + db + .prepare(`UPDATE poker_participants SET last_seen_at = datetime('now') WHERE id = ?`) + .bind(participantId), + bumpRev(roomId) + ]); + }, + + async openRound(roomId, title) { + const roundId = id('round'); + await db.batch([ + db + .prepare( + `INSERT INTO poker_rounds (id, room_id, title, sort_order, final_estimate, decided_at) + VALUES (?, ?, ?, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM poker_rounds WHERE room_id = ?), NULL, NULL)` + ) + .bind(roundId, roomId, title, roomId), + db + .prepare( + `UPDATE poker_rooms SET phase = 'voting', active_round_id = ?, rev = rev + 1 + WHERE id = ? AND status = 'open'` + ) + .bind(roundId, roomId) + ]); + }, + + async reveal(roomId) { + await db + .prepare( + `UPDATE poker_rooms SET phase = 'revealed', rev = rev + 1 + WHERE id = ? AND phase = 'voting'` + ) + .bind(roomId) + .run(); + }, + + async revote(roomId) { + await db.batch([ + db + .prepare( + `DELETE FROM poker_votes WHERE round_id = (SELECT active_round_id FROM poker_rooms WHERE id = ?)` + ) + .bind(roomId), + db + .prepare( + `UPDATE poker_rooms SET phase = 'voting', rev = rev + 1 + WHERE id = ? AND phase = 'revealed'` + ) + .bind(roomId) + ]); + }, + + async finalize(roomId, estimate) { + await db.batch([ + db + .prepare( + `UPDATE poker_rounds SET final_estimate = ?, decided_at = datetime('now') + WHERE id = (SELECT active_round_id FROM poker_rooms WHERE id = ? AND phase = 'revealed')` + ) + .bind(estimate, roomId), + db + .prepare( + `DELETE FROM poker_votes WHERE round_id = (SELECT active_round_id FROM poker_rooms WHERE id = ?)` + ) + .bind(roomId), + db + .prepare( + `UPDATE poker_rooms SET phase = 'waiting', active_round_id = NULL, rev = rev + 1 + WHERE id = ? AND phase = 'revealed'` + ) + .bind(roomId) + ]); + }, + + async closeRoom(roomId) { + await db.batch([ + db + .prepare( + `DELETE FROM poker_votes WHERE round_id IN (SELECT id FROM poker_rounds WHERE room_id = ?)` + ) + .bind(roomId), + db + .prepare( + `UPDATE poker_rooms SET status = 'closed', phase = 'waiting', active_round_id = NULL, rev = rev + 1 + WHERE id = ?` + ) + .bind(roomId) + ]); + } + }; +} + +// Poker requires D1 (the live session is D1-backed); the mock provider used by +// `vite dev`/unit tests has no DB. Routes call this and handle a null (503). +export function getPokerProvider(platform?: App.Platform): PokerProvider | null { + return platform?.env.DB ? pokerProvider(platform.env.DB) : null; +} diff --git a/src/lib/logic/poker-snapshot.test.ts b/src/lib/logic/poker-snapshot.test.ts new file mode 100644 index 0000000..6b12e1b --- /dev/null +++ b/src/lib/logic/poker-snapshot.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { + buildSnapshot, + canReveal, + pendingVoters, + type RosterSeat, + type SnapshotInput +} from './poker-snapshot'; +import type { PokerParticipantRow, PokerRoomRow, PokerVoteRow, RoomPhase } from '$lib/types'; + +// The snapshot builder is the single enforcement point for vote privacy +// (openspec/specs/planning-poker "Cast a hidden vote"): no other seat's card +// value may appear in the payload before the reveal. + +const room = (phase: RoomPhase): PokerRoomRow => ({ + id: 'e2e-poker-room', + title: 'Sprint 12', + deck: 'fibonacci', + controllerToken: 'ctrl', + joinToken: 'join', + email: null, + locale: 'en', + accent: 'blue', + status: 'open', + phase, + activeRoundId: phase === 'waiting' ? null : 'round-1', + rev: 3, + createdAt: '2026-07-01T00:00:00Z' +}); + +const seat = (id: string, over: Partial = {}): PokerParticipantRow => ({ + id, + roomId: 'e2e-poker-room', + name: id.toUpperCase(), + role: 'estimator', + isController: false, + lastSeenAt: '2026-07-01T00:00:00Z', + ...over +}); + +const vote = (participantId: string, card: string): PokerVoteRow => ({ + roundId: 'round-1', + participantId, + card, + updatedAt: '2026-07-01T00:00:00Z' +}); + +const base = (phase: RoomPhase, votes: PokerVoteRow[], viewer: string | null): SnapshotInput => ({ + room: room(phase), + activeRound: phase === 'waiting' ? null : { id: 'round-1', title: 'Ticket A' }, + participants: [seat('alice'), seat('bob'), seat('carol')], + votes, + results: [], + viewerParticipantId: viewer, + viewerIsController: false, + nowMs: Date.parse('2026-07-01T00:00:00Z'), + presenceWindowMs: 15000 +}); + +describe('buildSnapshot privacy', () => { + it('hides every card value during voting, exposing only hasVoted', () => { + const snap = buildSnapshot(base('voting', [vote('alice', '5'), vote('bob', '8')], 'carol')); + expect(snap.revealed).toBeNull(); + expect(snap.signal).toBeNull(); + expect(snap.distribution).toBeNull(); + // Carol has not voted; she can see who voted, not what. + expect(snap.roster.find((s) => s.id === 'alice')?.hasVoted).toBe(true); + expect(snap.roster.find((s) => s.id === 'carol')?.hasVoted).toBe(false); + // No card leaks anywhere in the serialized payload. + expect(JSON.stringify(snap)).not.toContain('"card"'); + expect(snap.myVote).toBeNull(); + }); + + it('echoes the caller their own card during voting but no one else', () => { + const snap = buildSnapshot(base('voting', [vote('alice', '5'), vote('bob', '8')], 'alice')); + expect(snap.myVote).toBe(5); + // Still nothing about bob's card. + expect(JSON.stringify(snap)).not.toContain('"card":8'); + }); + + it('reveals all cards, the distribution, and the signal once revealed', () => { + const snap = buildSnapshot( + base('revealed', [vote('alice', '5'), vote('bob', '5'), vote('carol', '5')], 'carol') + ); + expect(snap.revealed).toHaveLength(3); + expect(snap.signal?.level).toBe('agree'); + expect(snap.signal?.suggestion).toBe(5); + expect(snap.distribution?.find((d) => d.card === 5)?.count).toBe(3); + }); + + it('derives presence from the heartbeat window', () => { + const input = base('voting', [], 'alice'); + input.participants = [ + seat('alice', { lastSeenAt: '2026-07-01T00:00:00Z' }), // just seen + seat('bob', { lastSeenAt: '2026-06-30T23:59:00Z' }) // 60s stale > 15s window + ]; + const snap = buildSnapshot(input); + expect(snap.roster.find((s) => s.id === 'alice')?.present).toBe(true); + expect(snap.roster.find((s) => s.id === 'bob')?.present).toBe(false); + }); +}); + +describe('canReveal', () => { + const s = (over: Partial): RosterSeat => ({ + id: 'x', + name: 'x', + role: 'estimator', + isController: false, + present: true, + hasVoted: false, + ...over + }); + + it('locks the reveal while a present estimator has not voted', () => { + const roster = [s({ id: 'a', hasVoted: true }), s({ id: 'b', hasVoted: false })]; + expect(canReveal(roster)).toBe(false); + expect(pendingVoters(roster).map((p) => p.id)).toEqual(['b']); + }); + + it('unlocks once every present estimator has voted', () => { + const roster = [s({ id: 'a', hasVoted: true }), s({ id: 'b', hasVoted: true })]; + expect(canReveal(roster)).toBe(true); + expect(pendingVoters(roster)).toEqual([]); + }); + + it('never waits on observers or absent seats', () => { + const roster = [ + s({ id: 'a', hasVoted: true }), + s({ id: 'obs', role: 'observer', hasVoted: false }), + s({ id: 'gone', present: false, hasVoted: false }) + ]; + expect(canReveal(roster)).toBe(true); + expect(pendingVoters(roster)).toEqual([]); + }); + + it('stays locked when nobody can vote', () => { + expect(canReveal([])).toBe(false); + expect(canReveal([s({ role: 'observer' })])).toBe(false); + }); +}); diff --git a/src/lib/logic/poker-snapshot.ts b/src/lib/logic/poker-snapshot.ts new file mode 100644 index 0000000..d10375a --- /dev/null +++ b/src/lib/logic/poker-snapshot.ts @@ -0,0 +1,169 @@ +// Assembles the viewer-facing room snapshot from raw D1 rows +// (openspec/specs/planning-poker). Pure and viewer-aware: it is the single +// place that enforces vote privacy - card values of other seats are never +// included before the reveal. Both the state endpoint and any test build the +// snapshot through here, so the privacy guarantee cannot be bypassed. + +import type { + PokerParticipantRow, + PokerRoomRow, + PokerVoteRow, + ParticipantRole, + RoomPhase, + RoomStatus +} from '$lib/types'; +import { + type AgreementSignal, + type Card, + agreementSignal, + cardFromText, + voteDistribution +} from './poker'; + +// A seat as the roster renders it. During voting only `hasVoted` is exposed +// per seat; the card itself is withheld (privacy). +export interface RosterSeat { + id: string; + name: string; + role: ParticipantRole; + isController: boolean; + present: boolean; + hasVoted: boolean; +} + +/** + * Whether the controller may reveal yet: every estimator who is actually here + * has cast a card, and there is at least one of them. Observers never vote, so + * they never hold the room up; away seats (someone who closed the tab or + * dropped) are skipped too, so one absentee can't deadlock the round. + * + * ponytail: advisory - enforced on the controller's own UI only. The reveal + * command still accepts, because the controller already holds full control of + * the room; there is no adversary to guard against, only a mis-click. + */ +export function pendingVoters(roster: RosterSeat[]): RosterSeat[] { + return roster.filter((s) => s.role === 'estimator' && s.present && !s.hasVoted); +} + +export function canReveal(roster: RosterSeat[]): boolean { + const estimators = roster.filter((s) => s.role === 'estimator' && s.present); + return estimators.length > 0 && estimators.every((s) => s.hasVoted); +} + +export interface RevealedVote { + participantId: string; + name: string; + card: Card; +} + +export interface RoomSnapshot { + status: RoomStatus; + phase: RoomPhase; + rev: number; + activeRound: { id: string; title: string } | null; + roster: RosterSeat[]; + // The caller's own current card (echoed back to them during voting so their + // selection survives a refresh). Null when they have not voted / have no seat. + myVote: Card | null; + // Non-null only when phase is 'revealed': every cast card, face up. + revealed: RevealedVote[] | null; + signal: AgreementSignal | null; + distribution: { card: Card; count: number }[] | null; + // Decided items, in order - the durable results log. + results: { title: string; estimate: string }[]; + 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. + viewerSeated: boolean; + viewerRole: ParticipantRole | null; +} + +export interface SnapshotInput { + room: PokerRoomRow; + activeRound: { id: string; title: string } | null; + participants: PokerParticipantRow[]; + // Votes for the active round only. + votes: PokerVoteRow[]; + // Decided rounds (final_estimate set), in display order. + results: { title: string; estimate: string }[]; + viewerParticipantId: string | null; + viewerIsController: boolean; + nowMs: number; + presenceWindowMs: number; +} + +export function buildSnapshot(input: SnapshotInput): RoomSnapshot { + const { + room, + activeRound, + participants, + votes, + results, + viewerParticipantId, + viewerIsController, + nowMs, + presenceWindowMs + } = input; + + 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) + })); + + // The caller's own vote is always theirs to see (voting or revealed). + const own = viewerParticipantId + ? votes.find((v) => v.participantId === viewerParticipantId) + : undefined; + const myVote = own ? cardFromText(own.card) : null; + + const mySeat = viewerParticipantId + ? participants.find((p) => p.id === viewerParticipantId) + : undefined; + + // Privacy gate: other seats' card values only exist in the payload once the + // controller has revealed. Before that, the roster's hasVoted is all anyone + // (including a crafted client) can read. + let revealed: RevealedVote[] | null = null; + let signal: AgreementSignal | null = null; + let distribution: { card: Card; count: number }[] | null = null; + + if (room.phase === 'revealed') { + const cards: Card[] = []; + revealed = []; + for (const v of votes) { + const card = cardFromText(v.card); + if (card === null) continue; + cards.push(card); + revealed.push({ + participantId: v.participantId, + name: nameById.get(v.participantId) ?? '', + card + }); + } + signal = agreementSignal(cards); + distribution = voteDistribution(cards); + } + + return { + status: room.status, + phase: room.phase, + rev: room.rev, + activeRound, + roster, + myVote, + revealed, + signal, + distribution, + results, + viewerIsController, + viewerSeated: !!mySeat, + viewerRole: mySeat?.role ?? null + }; +} diff --git a/src/lib/logic/poker.test.ts b/src/lib/logic/poker.test.ts new file mode 100644 index 0000000..032d13b --- /dev/null +++ b/src/lib/logic/poker.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { + agreementSignal, + voteDistribution, + estimateChoices, + isCard, + isSpecialCard, + NUMERIC_DECK +} from './poker'; + +// The reveal-time agreement signal (openspec/specs/planning-poker "Agreement +// signal" + "Special cards"): agree / close / spread by deck-index span, with +// infinity forcing a spread, coffee raising an advisory break hint, and only +// agree pre-filling a suggestion. + +describe('agreementSignal', () => { + it('agrees when every numeric vote is the same card and pre-fills it', () => { + const s = agreementSignal([5, 5, 5]); + expect(s.level).toBe('agree'); + expect(s.suggestion).toBe(5); + expect(s.hasInfinity).toBe(false); + expect(s.needsBreak).toBe(false); + }); + + it('is close on two adjacent deck cards, with no suggestion', () => { + // 3 and 5 are adjacent on the deck (indices 3 and 4). + const s = agreementSignal([3, 5, 3]); + expect(s.level).toBe('close'); + expect(s.suggestion).toBeNull(); + }); + + it('is a spread when votes are more than one step apart', () => { + // 3 (index 3) and 13 (index 6) span three steps. + const s = agreementSignal([3, 13]); + expect(s.level).toBe('spread'); + expect(s.suggestion).toBeNull(); + }); + + it('infinity forces a spread even when the numbers would agree', () => { + const s = agreementSignal([5, 5, 'infinity']); + expect(s.level).toBe('spread'); + expect(s.suggestion).toBeNull(); + expect(s.hasInfinity).toBe(true); + }); + + it('is a spread with a null suggestion when there are no numeric votes', () => { + const s = agreementSignal(['?', 'coffee', 'infinity']); + expect(s.level).toBe('spread'); + expect(s.suggestion).toBeNull(); + }); + + it('coffee raises the break hint without changing the numeric agreement', () => { + const s = agreementSignal([8, 8, 'coffee']); + expect(s.level).toBe('agree'); + expect(s.suggestion).toBe(8); + expect(s.needsBreak).toBe(true); + }); + + it('a lone question mark leaves nothing numeric to size', () => { + expect(agreementSignal(['?']).level).toBe('spread'); + }); + + it('treats a single numeric vote as agreement on that value', () => { + const s = agreementSignal([13]); + expect(s.level).toBe('agree'); + expect(s.suggestion).toBe(13); + }); +}); + +describe('voteDistribution', () => { + it('counts votes per card across the whole deck in order', () => { + const dist = voteDistribution([5, 5, 'coffee']); + expect(dist).toHaveLength(NUMERIC_DECK.length + 3); + expect(dist.find((d) => d.card === 5)?.count).toBe(2); + expect(dist.find((d) => d.card === 'coffee')?.count).toBe(1); + expect(dist.find((d) => d.card === 0)?.count).toBe(0); + }); +}); + +describe('card guards', () => { + it('accepts deck numerals and specials, rejects off-deck values', () => { + expect(isCard(8)).toBe(true); + expect(isCard('infinity')).toBe(true); + expect(isCard(7)).toBe(false); // 7 is not on the modified-Fibonacci deck + expect(isCard('banana')).toBe(false); + expect(isSpecialCard('coffee')).toBe(true); + expect(isSpecialCard(5)).toBe(false); + }); +}); + +describe('estimateChoices', () => { + it('brackets the cast numerals, lowest through highest', () => { + expect(estimateChoices([3, 8])).toEqual([3, 5, 8]); + }); + + it('collapses to the single card when the room agreed', () => { + expect(estimateChoices([5, 5, 5])).toEqual([5]); + }); + + it('ignores special cards when bracketing', () => { + expect(estimateChoices([2, 'infinity', 'coffee', 3])).toEqual([2, 3]); + }); + + it('offers the whole deck when nobody played a numeral', () => { + expect(estimateChoices(['?', 'coffee'])).toEqual([...NUMERIC_DECK]); + expect(estimateChoices([])).toEqual([...NUMERIC_DECK]); + }); +}); diff --git a/src/lib/logic/poker.ts b/src/lib/logic/poker.ts new file mode 100644 index 0000000..73ba1a4 --- /dev/null +++ b/src/lib/logic/poker.ts @@ -0,0 +1,129 @@ +// Planning-poker deck and the reveal-time agreement signal +// (openspec/specs/planning-poker). Pure logic, no transport coupling: the same +// computation runs wherever the reveal is assembled (server or client). + +// Modified Fibonacci - the classic commercial planning-poker deck. Anything +// larger than 100 is really an "infinity" (split it) signal, not a number. +export const NUMERIC_DECK = [0, 1, 2, 3, 5, 8, 13, 20, 40, 100] as const; + +// The three non-numeric cards. `?` = need more info, `infinity` = too big to +// estimate, `coffee` = I need a break. Castable like any card, shown on reveal, +// but never counted as a numeric estimate. +export const SPECIAL_CARDS = ['?', 'infinity', 'coffee'] as const; +export type SpecialCard = (typeof SPECIAL_CARDS)[number]; + +// A card is either a numeric deck value or one of the specials. +export type Card = number | SpecialCard; + +// The full deck in display order: numbers first, then the specials. +export const DECK: readonly Card[] = [...NUMERIC_DECK, ...SPECIAL_CARDS]; + +export function isSpecialCard(card: unknown): card is SpecialCard { + return typeof card === 'string' && (SPECIAL_CARDS as readonly string[]).includes(card); +} + +/** True when `card` is a legal card to cast (a deck numeral or a special). */ +export function isCard(card: unknown): card is Card { + return isSpecialCard(card) || (typeof card === 'number' && NUMERIC_DECK.includes(card as never)); +} + +/** + * Canonical text form of a card, as stored in D1 and sent over the wire: + * a numeric card is its decimal string ('5'), a special is its name ('coffee'). + */ +export function cardToText(card: Card): string { + return String(card); +} + +/** Parses stored/wire card text back to a Card, or null if it is not a legal card. */ +export function cardFromText(text: string): Card | null { + if (isSpecialCard(text)) return text; + if (/^\d+$/.test(text)) { + const n = Number(text); + if (NUMERIC_DECK.includes(n as never)) return n; + } + return null; +} + +// agree - at least one numeric vote, all numeric votes equal, no infinity. +// close - the numeric votes span exactly one adjacent deck step, no infinity. +// spread - span of more than one step, any infinity, or no numeric votes. +export type AgreementLevel = 'agree' | 'close' | 'spread'; + +export interface AgreementSignal { + level: AgreementLevel; + // The agreed value, pre-filled as the suggested estimate. Non-null only when + // level is 'agree'; the controller always records the final estimate. + suggestion: number | null; + // At least one infinity was cast (forces a spread - someone thinks it is too + // big to size). + hasInfinity: boolean; + // At least one coffee was cast - an advisory "someone needs a break" hint, + // independent of the numeric agreement. + needsBreak: boolean; +} + +/** + * Classifies a round's cast votes, considering only numeric cards by their + * position on the deck. Advisory only: it never records an estimate, it pre- + * fills a suggestion when (and only when) the room agrees. + */ +export function agreementSignal(votes: readonly Card[]): AgreementSignal { + const hasInfinity = votes.includes('infinity'); + const needsBreak = votes.includes('coffee'); + + // Only real deck numerals count toward the span; `?`/`coffee`/`infinity` + // never do. A vote off the deck (shouldn't happen) is ignored, not trusted. + const indices = votes + .filter((v): v is number => typeof v === 'number') + .map((n) => NUMERIC_DECK.indexOf(n as never)) + .filter((i) => i >= 0); + + if (indices.length === 0) { + // No numeric votes to size (all specials, or no votes) - nothing to agree on. + return { level: 'spread', suggestion: null, hasInfinity, needsBreak }; + } + + if (hasInfinity) { + // Infinity always forces a spread regardless of how the numbers land. + return { level: 'spread', suggestion: null, hasInfinity, needsBreak }; + } + + const span = Math.max(...indices) - Math.min(...indices); + if (span === 0) { + return { level: 'agree', suggestion: NUMERIC_DECK[indices[0]], hasInfinity, needsBreak }; + } + if (span === 1) { + return { level: 'close', suggestion: null, hasInfinity, needsBreak }; + } + return { level: 'spread', suggestion: null, hasInfinity, needsBreak }; +} + +/** + * The numerals the controller may record as the final estimate: the deck slice + * the room actually voted, lowest cast card through highest, inclusive. A room + * that split 3/8 can land on 3, 5, or 8 - never on 40, which nobody argued for. + * Unanimous rounds collapse to the single card. With no numeric votes at all + * (every seat played a special) there is nothing to bracket, so the whole deck + * is offered rather than nothing. + */ +export function estimateChoices(votes: readonly Card[]): readonly number[] { + const indices = votes + .filter((v): v is number => typeof v === 'number') + .map((n) => NUMERIC_DECK.indexOf(n as never)) + .filter((i) => i >= 0); + if (indices.length === 0) return NUMERIC_DECK; + return NUMERIC_DECK.slice(Math.min(...indices), Math.max(...indices) + 1); +} + +/** + * Counts votes per card in deck order, for the reveal's distribution display. + * Cards with no votes are still present (count 0), so the distribution renders + * against the full deck. + */ +export function voteDistribution(votes: readonly Card[]): { card: Card; count: number }[] { + return DECK.map((card) => ({ + card, + count: votes.filter((v) => v === card).length + })); +} diff --git a/src/lib/logic/site-urls.ts b/src/lib/logic/site-urls.ts index 687a595..5e84af8 100644 --- a/src/lib/logic/site-urls.ts +++ b/src/lib/logic/site-urls.ts @@ -4,7 +4,7 @@ import { resolve } from '$app/paths'; import type { ResolvedPathname } from '$app/types'; import { baseLocale } from '$lib/paraglide/runtime'; -import type { Accent, Locale } from '$lib/types'; +import type { Accent, CreateKind, Locale } from '$lib/types'; const params = (l: Locale) => (l === baseLocale ? {} : { lang: l }); @@ -15,6 +15,17 @@ export function landingUrl(locale: Locale, accent: Accent = 'yellow'): ResolvedP return withAccent(resolve('/[[lang=locale]]', params(locale)), accent); } -export function createUrl(locale: Locale, accent: Accent = 'yellow'): ResolvedPathname { - return withAccent(resolve('/[[lang=locale]]/create', params(locale)), accent); +/** + * The create page. `kind` picks which of the two things it opens on: a poll + * (the default) or a planning-poker room. Both take a highlighter, so both + * carry the picked one over. + */ +export function createUrl( + locale: Locale, + accent: Accent = 'yellow', + kind: CreateKind = 'poll' +): ResolvedPathname { + const path = withAccent(resolve('/[[lang=locale]]/create', params(locale)), accent); + if (kind !== 'poker') return path; + return path.includes('?') ? `${path}&make=poker` : `${path}?make=poker`; } diff --git a/src/lib/poker/client.svelte.ts b/src/lib/poker/client.svelte.ts new file mode 100644 index 0000000..659e468 --- /dev/null +++ b/src/lib/poker/client.svelte.ts @@ -0,0 +1,74 @@ +// Client-side live loop for a planning-poker room (openspec/specs/planning-poker). +// Real-time is D1-backed: this short-polls the state endpoint (~1s) and posts +// one-shot commands, applying the fresh snapshot each command returns so the +// acting client updates without waiting for the next tick. The token is the +// path credential; everything else (room id, role) is server-derived. + +import type { RoomSnapshot } from '$lib/logic/poker-snapshot'; + +const POLL_MS = 1000; + +export class RoomClient { + snapshot = $state(null); + // True after a failed fetch, so the UI can show a soft "reconnecting" state. + offline = $state(false); + + #token: string; + #timer: ReturnType | null = null; + #inFlight = false; + + constructor(token: string) { + this.#token = token; + } + + get #base() { + return `/poker/api/${encodeURIComponent(this.#token)}`; + } + + start() { + void this.refresh(); + this.#timer = setInterval(() => void this.refresh(), POLL_MS); + } + + stop() { + if (this.#timer) clearInterval(this.#timer); + this.#timer = null; + } + + async refresh() { + // Never overlap polls; skip a tick if the previous fetch is still running. + if (this.#inFlight) return; + this.#inFlight = true; + try { + const res = await fetch(`${this.#base}/state`, { headers: { accept: 'application/json' } }); + if (res.ok) { + this.snapshot = await res.json(); + this.offline = false; + } + } catch { + this.offline = true; + } finally { + this.#inFlight = false; + } + } + + /** Post one command; apply the returned snapshot immediately. Returns ok. */ + async command(action: string, args: Record = {}): Promise { + try { + const res = await fetch(`${this.#base}/command`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action, ...args }) + }); + if (res.ok) { + this.snapshot = await res.json(); + this.offline = false; + return true; + } + return false; + } catch { + this.offline = true; + return false; + } + } +} diff --git a/src/lib/server/email.test.ts b/src/lib/server/email.test.ts index b5da09f..d670f0a 100644 --- a/src/lib/server/email.test.ts +++ b/src/lib/server/email.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { composeAdminEmail } from './email'; +import { composeAdminEmail, composePokerLinkEmail, composePokerSummaryEmail } from './email'; import { m } from '$lib/paraglide/messages'; const URL = 'https://poll.malpou.io/e/abc123'; @@ -27,3 +27,57 @@ describe('composeAdminEmail', () => { expect(fr.subject).toBe(m.emailSubject({}, { locale: 'fr' })); }); }); + +describe('planning-poker room emails', () => { + const CTRL = 'https://poll.malpou.io/poker/c/abc123'; + + it('the link email carries the room title and the controller link', () => { + const { subject, text, html } = composePokerLinkEmail({ + roomTitle: 'Sprint 12', + controllerUrl: CTRL + }); + expect(subject).toContain('Sprint 12'); + for (const body of [text, html]) { + expect(body).toContain(CTRL); + expect(body).toContain('Sprint 12'); + expect(body).toContain(m.pokerEmailSecretWarning()); + } + }); + + it('the summary carries the title and every decided item with its estimate', () => { + const { subject, text, html } = composePokerSummaryEmail({ + roomTitle: 'Sprint 12', + results: [ + { title: 'CDX-123', estimate: '5' }, + { title: 'CDX-124', estimate: 'split' } + ] + }); + expect(subject).toContain('Sprint 12'); + for (const body of [text, html]) { + expect(body).toContain('CDX-123'); + expect(body).toContain('5'); + expect(body).toContain('CDX-124'); + expect(body).toContain('split'); + } + }); + + it('says so rather than sending an empty list', () => { + const { text } = composePokerSummaryEmail({ roomTitle: 'Sprint 12', results: [] }); + expect(text).toContain(m.pokerEmailSummaryEmpty()); + }); + + it('escapes a controller-typed item title in the html body', () => { + const { html } = composePokerSummaryEmail({ + roomTitle: 'Sprint 12', + results: [{ title: '', estimate: '5' }] + }); + expect(html).not.toContain(' { + const da = composePokerSummaryEmail({ roomTitle: 'S', results: [], locale: 'da' }); + const fr = composePokerSummaryEmail({ roomTitle: 'S', results: [], locale: 'fr' }); + expect(da.subject).not.toBe(fr.subject); + }); +}); diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index d3aad80..6bc2e2f 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -1,4 +1,5 @@ import { m } from '$lib/paraglide/messages'; +import { baseLocale } from '$lib/paraglide/runtime'; import type { Locale } from '$lib/types'; const FROM = 'poll@malpou.io'; @@ -60,3 +61,117 @@ export async function sendAdminEmail( console.error('admin-link email send failed', err); } } + +// --- Planning poker room emails (openspec/specs/planning-poker "Room email") --- +// +// Two transactional emails, both to the controller's own address: the room's +// controller link when they attach it, and the results summary when the room +// closes. Composers are pure so the unit test asserts bodies without a binding. +// +// ponytail: rendered in the base language, like every other planning-poker +// surface today. When a room carries its own language, pass it through here - +// the composers already take a locale. + +export interface PokerLinkEmailInput { + roomTitle: string; + controllerUrl: string; + locale?: Locale; +} + +export function composePokerLinkEmail({ + roomTitle, + controllerUrl, + locale = baseLocale +}: PokerLinkEmailInput): { subject: string; text: string; html: string } { + const subject = m.pokerEmailLinkSubject({ title: roomTitle }, { locale }); + const intro = m.pokerEmailLinkIntro({ title: roomTitle }, { locale }); + const linkLabel = m.pokerEmailControllerLinkLabel({}, { locale }); + const warning = m.pokerEmailSecretWarning({}, { locale }); + + const text = `${intro}\n\n${linkLabel}: ${controllerUrl}\n\n${warning}`; + const html = + `

${intro}

` + + `

${linkLabel}: ${controllerUrl}

` + + `

${warning}

`; + return { subject, text, html }; +} + +export interface PokerSummaryEmailInput { + roomTitle: string; + results: { title: string; estimate: string }[]; + locale?: Locale; +} + +/** + * The closing summary: the room's title and every decided item with its final + * estimate, in the order they were decided. A room closed without deciding + * anything says so rather than sending an empty list. + */ +export function composePokerSummaryEmail({ + roomTitle, + results, + locale = baseLocale +}: PokerSummaryEmailInput): { subject: string; text: string; html: string } { + const subject = m.pokerEmailSummarySubject({ title: roomTitle }, { locale }); + const intro = m.pokerEmailSummaryIntro({ title: roomTitle }, { locale }); + + if (results.length === 0) { + const empty = m.pokerEmailSummaryEmpty({}, { locale }); + return { subject, text: `${intro}\n\n${empty}`, html: `

${intro}

${empty}

` }; + } + + const text = [intro, '', ...results.map((r) => `${r.title}: ${r.estimate}`)].join('\n'); + const html = + `

${intro}

    ` + + results.map((r) => `
  • ${escapeHtml(r.title)}: ${r.estimate}
  • `).join('') + + `
`; + return { subject, text, html }; +} + +// Item titles are controller-typed free text and land inside the HTML body. +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +async function send( + platform: App.Platform | undefined, + to: string, + locale: Locale, + built: { subject: string; text: string; html: string }, + what: string +): Promise { + const binding = platform?.env.EMAIL; + if (!binding) return; + try { + await binding.send({ + to, + from: { email: FROM, name: m.appName({}, { locale }) }, + subject: built.subject, + html: built.html, + text: built.text + }); + } catch (err) { + console.error(`${what} email send failed`, err); + } +} + +/** Best-effort, same discipline as the admin-link email: never throws. */ +export async function sendPokerLinkEmail( + platform: App.Platform | undefined, + input: PokerLinkEmailInput & { to: string } +): Promise { + const locale = input.locale ?? baseLocale; + await send(platform, input.to, locale, composePokerLinkEmail(input), 'poker room link'); +} + +export async function sendPokerSummaryEmail( + platform: App.Platform | undefined, + input: PokerSummaryEmailInput & { to: string } +): Promise { + const locale = input.locale ?? baseLocale; + await send(platform, input.to, locale, composePokerSummaryEmail(input), 'poker room summary'); +} diff --git a/src/lib/server/poker.ts b/src/lib/server/poker.ts new file mode 100644 index 0000000..c2bf334 --- /dev/null +++ b/src/lib/server/poker.ts @@ -0,0 +1,85 @@ +// Server helpers shared by the planning-poker state + command endpoints +// (openspec/specs/planning-poker). Resolves a capability token to a room + the +// caller's authority, and assembles the viewer-aware snapshot. The token is the +// only credential; an unknown token resolves to null and the endpoint 404s. + +import type { Cookies } from '@sveltejs/kit'; +import type { PokerProvider } from '$lib/data/poker'; +import type { PokerRoomRow } from '$lib/types'; +import { buildSnapshot, type RoomSnapshot } from '$lib/logic/poker-snapshot'; + +// A seat is "present" while its heartbeat is within this window. Clients poll +// ~every second, so a closed tab drops out a few polls after it stops (explicit +// leave removes it at once; this is the crash backstop). +export const PRESENCE_WINDOW_MS = 15_000; + +// controller = holds the private controller token (may facilitate + estimate); +// participant = holds the shared join token (join + vote only). +export type Authority = 'controller' | 'participant'; + +export interface ResolvedRoom { + room: PokerRoomRow; + auth: Authority; +} + +/** Resolve a token to its room + authority. Controller token wins if both matched. */ +export async function resolveRoom( + provider: PokerProvider, + token: string +): Promise { + const asController = await provider.getRoomByControllerToken(token); + if (asController) return { room: asController, auth: 'controller' }; + const asParticipant = await provider.getRoomByJoinToken(token); + if (asParticipant) return { room: asParticipant, auth: 'participant' }; + return null; +} + +// The per-browser participant id lives in a cookie scoped to the room, so one +// browser holds a stable seat per room (a refresh resumes it) without the id +// ever appearing in a URL. +export function pidCookieName(roomId: string): string { + return `pk_${roomId}`; +} + +export function readPid(cookies: Cookies, roomId: string): string | null { + return cookies.get(pidCookieName(roomId)) ?? null; +} + +export function writePid(cookies: Cookies, roomId: string, pid: string): void { + cookies.set(pidCookieName(roomId), pid, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 // a day is plenty for one estimation session + }); +} + +/** Fetch the room's live rows and assemble the snapshot for this viewer. */ +export async function assembleSnapshot( + provider: PokerProvider, + room: PokerRoomRow, + viewerPid: string | null, + viewerIsController: boolean +): Promise { + const activeRoundP = room.activeRoundId + ? provider.getRoundById(room.activeRoundId) + : Promise.resolve(null); + const [activeRound, participants, results] = await Promise.all([ + activeRoundP, + provider.listParticipants(room.id), + provider.listResults(room.id) + ]); + const votes = room.activeRoundId ? await provider.listVotes(room.activeRoundId) : []; + + return buildSnapshot({ + room, + activeRound: activeRound ? { id: activeRound.id, title: activeRound.title } : null, + participants, + votes, + results, + viewerParticipantId: viewerPid, + viewerIsController, + nowMs: Date.now(), + presenceWindowMs: PRESENCE_WINDOW_MS + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 9e74cbd..a794c3e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -25,6 +25,13 @@ export type PollMode = 'assigned' | 'open'; // one fixed date, yes/no answers; rank = order text options; highlight = // spend marker strokes on text options. Chosen at creation, immutable after; // validated at the form boundary (no SQL CHECK). +// What the create page is making. Planning-poker rooms share the create page +// and its chrome but nothing else: no options, no invitees, no timezone, no +// mode, no highlighter - so this branches above the poll form rather than +// joining POLL_TYPES. +export const CREATE_KINDS = ['poll', 'poker'] as const; +export type CreateKind = (typeof CREATE_KINDS)[number]; + export const POLL_TYPES = ['dates', 'question', 'rsvp', 'rank', 'highlight'] as const; export type PollType = (typeof POLL_TYPES)[number]; @@ -209,6 +216,72 @@ export interface InviteeView { note: string | null; } +// --- Planning poker (openspec/specs/planning-poker). Durable row shapes only; +// the live phase and in-flight votes are ephemeral coordination state held by +// the real-time layer, never persisted here. --- + +// open = estimating; closed = the controller ended the session (final log only). +export type RoomStatus = 'open' | 'closed'; + +// The live phase of the current item. waiting = between items. +export type RoomPhase = 'waiting' | 'voting' | 'revealed'; + +// estimator = casts votes; observer = watches without voting. +export type ParticipantRole = 'estimator' | 'observer'; + +export interface PokerRoomRow { + id: string; + title: string; + deck: string; // 'fibonacci' today; column reserved for future decks + controllerToken: string; // private, facilitates the room + joinToken: string; // shared, participants enter through it + status: RoomStatus; + phase: RoomPhase; + activeRoundId: string | null; // the item being voted/revealed; null while waiting + rev: number; // bumped on every mutation so a state poll detects change + // Optional controller address. Stored (unlike the poll organizer's, which is + // used once and discarded) because the results summary is sent at close. + email: string | null; + // The language the whole room renders in, fixed at creation - the analogue of + // an event's `locale`. Not a URL segment: one join link serves everyone. + locale: Locale; + // The room's highlighter, picked at creation like a poll's. + accent: Accent; + createdAt: string; +} + +// One seat in a room. Presence is derived from lastSeenAt (heartbeat window), +// not stored. id is the cookie-carried per-browser id (a refresh resumes it). +export interface PokerParticipantRow { + id: string; + roomId: string; + name: string; + role: ParticipantRole; + isController: boolean; + lastSeenAt: string; +} + +// One vote on the active item. card is canonical text (a deck numeral like +// '5' or a special '?'/'infinity'/'coffee'). Transient - cleared on +// finalize/re-vote. +export interface PokerVoteRow { + roundId: string; + participantId: string; + card: string; + updatedAt: string; +} + +// One estimation item. final_estimate/decided_at are null until the controller +// records the estimate (the single durable artifact of a decided item). +export interface PokerRoundRow { + id: string; + roomId: string; + title: string; + sortOrder: number; + finalEstimate: string | null; + decidedAt: string | null; +} + // Per-date aggregate for the results view. notAnswered = invitees − answered, // so a missing responses row reads as "no answer", never "unavailable". export interface DateOptionResult { diff --git a/src/routes/[[lang=locale]]/+page.svelte b/src/routes/[[lang=locale]]/+page.svelte index 1ac4fd5..02242fa 100644 --- a/src/routes/[[lang=locale]]/+page.svelte +++ b/src/routes/[[lang=locale]]/+page.svelte @@ -7,6 +7,7 @@ import LocaleSwap from '$lib/components/atoms/LocaleSwap.svelte'; import SectionHeading from '$lib/components/atoms/SectionHeading.svelte'; import LandingExamples from '$lib/components/organisms/LandingExamples.svelte'; + import LandingPoker from '$lib/components/organisms/LandingPoker.svelte'; import { m } from '$lib/paraglide/messages'; import { langLabel } from '$lib/logic/locales'; import { createUrl, landingUrl } from '$lib/logic/site-urls'; @@ -117,6 +118,15 @@