feat: quick polls in comments that end in a decision

«📊 Añadir una encuesta» adds 2 to 6 options to a new comment. Members,
viewers too, vote by clicking an option (one vote each, which they can
change or take back) and see each option's votes as a bar. Whoever
started it, or an admin, closes it with «Cerrar y decidir»: the most
voted wins (none on a tie) and a «Decidido: …» reply marked as a
decision shows above the ticket. Events of the comment (voted, closed).

API: addComment(…, poll), votePoll, closePoll, Comment.poll;
POST /api/comments/{id}/poll/vote and …/poll/close. MCP: add_comment
poll, vote_poll, close_poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:48:12 +02:00
parent 93d028e870
commit 3a46a236e4
41 changed files with 2144 additions and 77 deletions

View File

@@ -926,3 +926,115 @@ a.ts-key {
font-weight: 600;
cursor: pointer;
}
/* A quick poll in a comment: each option a bar of its share. */
.ts-poll {
display: grid;
gap: 0.375rem;
max-width: 28rem;
margin-top: 0.5rem;
}
.ts-poll__option {
position: relative;
display: flex;
justify-content: space-between;
gap: 1rem;
height: auto !important;
min-width: 0 !important;
padding: 0.5rem 0.75rem !important;
overflow: hidden;
border: 1px solid var(--ts-line-strong) !important;
border-radius: 0.5rem !important;
background: var(--ts-surface) !important;
color: var(--ts-ink);
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
isolation: isolate;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
z-index: -1;
width: var(--share, 0%);
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
transition: width 0.3s ease;
}
&.is-mine {
border-color: var(--ts-brand) !important;
font-weight: 600;
}
&.is-winner::before {
background: color-mix(in srgb, var(--ts-c-green) 22%, transparent);
}
&:disabled {
cursor: default;
}
&:hover:not(:disabled),
&:focus-visible {
border-color: var(--ts-brand) !important;
}
}
.ts-poll__count {
font-family: var(--ts-font-mono);
font-weight: 600;
}
.ts-poll__meta {
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-poll__close {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.ts-poll-editor {
display: grid;
gap: 0.375rem;
margin: 0;
padding: 0.75rem;
border: 1px dashed var(--ts-line-strong);
border-radius: 0.5rem;
legend {
padding: 0 0.25rem;
color: var(--ts-muted);
font-size: 0.8125rem;
}
}
.ts-poll-editor__row {
display: flex;
gap: 0.5rem;
align-items: center;
.ts-input {
flex: 1;
font: inherit;
}
}
.ts-poll-editor__actions {
display: flex;
gap: 1rem;
}

View File

@@ -45,6 +45,7 @@ import {
import {
Attachment,
Comment,
CommentPoll,
DevLink,
Issue,
IssueLink,
@@ -919,6 +920,68 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
[loader]="loadImage"
/>
}
@if (!comment.deleted && comment.poll; as poll) {
<div
class="ts-poll"
role="group"
[attr.aria-label]="
poll.closed ? 'Encuesta cerrada' : 'Encuesta'
"
>
@for (option of poll.options; track $index) {
<button
type="button"
class="ts-poll__option"
[class.is-mine]="votedFor(poll) === $index"
[class.is-winner]="
poll.closed && poll.winner === $index
"
[disabled]="poll.closed || !project.myRole()"
[attr.aria-pressed]="votedFor(poll) === $index"
[style.--share]="share(poll, $index) + '%'"
(click)="
facade.votePoll(
comment.id,
votedFor(poll) === $index ? null : $index
)
"
>
<span class="ts-poll__text">
@if (poll.closed && poll.winner === $index) {
<span aria-hidden="true">🏆</span>
}
{{ option.text }}
</span>
<span
class="ts-poll__count"
[attr.title]="votersOf(option)"
>{{ option.voters.length }}</span
>
</button>
}
<p class="ts-poll__meta">
{{ totalVotes(poll) }}
{{ totalVotes(poll) === 1 ? 'voto' : 'votos' }}
@if (poll.closed) {
· cerrada{{
poll.winner === null ? ', sin ganadora' : ''
}}
} @else if (
comment.author.id === facade.me() ||
project.canManage()
) {
·
<button
type="button"
class="ts-poll__close"
(click)="facade.closePoll(comment.id)"
>
Cerrar y decidir
</button>
}
</p>
</div>
}
@if (
!comment.deleted &&
(comment.reactions.length || project.myRole())
@@ -1144,6 +1207,65 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
[loader]="loadImage"
(submitted)="addComment()"
/>
@if (pollOn()) {
<fieldset class="ts-poll-editor">
<legend>Encuesta (2 a 6 opciones)</legend>
@for (option of pollOptions(); track $index) {
<div class="ts-poll-editor__row">
<input
class="ts-input"
maxlength="80"
[attr.aria-label]="'Opción ' + ($index + 1)"
[placeholder]="'Opción ' + ($index + 1)"
[value]="option"
(input)="
setPollOption($index, $any($event.target).value)
"
/>
@if (pollOptions().length > 2) {
<button
type="button"
class="ts-poll__close"
[attr.aria-label]="
'Quitar la opción ' + ($index + 1)
"
(click)="removePollOption($index)"
>
×
</button>
}
</div>
}
<div class="ts-poll-editor__actions">
@if (pollOptions().length < 6) {
<button
type="button"
class="ts-poll__close"
(click)="pollOptions.set([...pollOptions(), ''])"
>
+ Añadir opción
</button>
}
<button
type="button"
class="ts-poll__close"
(click)="pollOn.set(false)"
>
Quitar la encuesta
</button>
</div>
</fieldset>
} @else {
<div>
<button
type="button"
class="ts-poll__close"
(click)="startPoll()"
>
📊 Añadir una encuesta
</button>
</div>
}
<pi-file-upload
label="Adjuntar al comentario"
[multiple]="true"
@@ -2146,9 +2268,66 @@ export class IssuePage {
protected addComment(): void {
const body = this.commentBody().trim();
if (!body) return;
this.facade.addComment(body, this.commentFiles());
const options = this.pollOptions()
.map((o) => o.trim())
.filter(Boolean);
if (this.pollOn() && options.length < 2) {
this.toast.error('Una encuesta necesita al menos dos opciones');
return;
}
this.facade.addComment(
body,
this.commentFiles(),
null,
this.pollOn() ? options : null,
);
this.commentBody.set('');
this.commentFiles.set([]);
this.pollOn.set(false);
}
/** A quick poll in the new comment: its options while it is written. */
protected readonly pollOn = signal(false);
protected readonly pollOptions = signal<string[]>(['', '']);
protected startPoll(): void {
this.pollOptions.set(['', '']);
this.pollOn.set(true);
}
protected setPollOption(index: number, value: string): void {
this.pollOptions.update((options) =>
options.map((o, i) => (i === index ? value : o)),
);
}
protected removePollOption(index: number): void {
this.pollOptions.update((options) => options.filter((_, i) => i !== index));
}
/** The option I voted, or null. */
protected votedFor(poll: CommentPoll): number | null {
const me = this.facade.me();
const index = poll.options.findIndex((o) =>
o.voters.some((v) => v.id === me),
);
return index < 0 ? null : index;
}
protected totalVotes(poll: CommentPoll): number {
return poll.options.reduce((sum, o) => sum + o.voters.length, 0);
}
/** An option's share of the votes, for its bar. */
protected share(poll: CommentPoll, index: number): number {
const total = this.totalVotes(poll);
return total
? Math.round((poll.options[index].voters.length / total) * 100)
: 0;
}
protected votersOf(option: { voters: { name: string }[] }): string {
return option.voters.map((v) => v.name).join(', ') || 'Nadie aún';
}
protected startReply(comment: Comment): void {