From 429b52e6f58a4e03c2b1d82088d8330bb15c6c2e Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Wed, 21 Oct 2026 20:24:37 +0200 Subject: [PATCH] fix(web): polls, recordings, reviews and reminders use @pi/ui's fields and buttons Co-Authored-By: Claude Opus 5.5 --- apps/web/src/styles.scss | 3 +- libs/web/shared/ui/src/lib/icons.ts | 5 + .../presenters/src/lib/pages/issue.page.scss | 159 ++----- .../presenters/src/lib/pages/issue.page.ts | 402 ++++++++++-------- .../presenters/src/lib/ui/media-recorder.ts | 80 ++-- 5 files changed, 301 insertions(+), 348 deletions(-) diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 8c76353..95ea002 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -409,7 +409,8 @@ h3 { // A field's label floats above its box; other fields leave room for it // below them (their hint line), a segmented control does not. - > pi-segmented-control { + > pi-segmented-control, + > p:has(+ pi-text-field, + pi-textarea, + pi-select-field, + pi-date-field) { margin-bottom: 0.75rem; } } diff --git a/libs/web/shared/ui/src/lib/icons.ts b/libs/web/shared/ui/src/lib/icons.ts index 36aa27f..59d6a72 100644 --- a/libs/web/shared/ui/src/lib/icons.ts +++ b/libs/web/shared/ui/src/lib/icons.ts @@ -169,6 +169,11 @@ export const APP_ICONS: Record = { '', 'user-profile-company': '', + poll: '', + video: + '', + mic: '', + stop: '', whatsapp: '', }; diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index b9cb150..f083b14 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -726,19 +726,6 @@ a.ts-key { font-style: italic; } -.ts-resolve__label { - display: grid; - gap: 0.375rem; - color: var(--ts-muted); - font-size: 0.8125rem; -} - -.ts-resolve__text { - font: inherit; - color: var(--ts-ink); - resize: vertical; -} - .ts-reply__actions { display: flex; gap: 0.75rem; @@ -782,59 +769,31 @@ a.ts-key { font-size: 0.875rem; } -/* Small text buttons, not @pi/ui's sized ones. */ -.ts-reminder__action { - height: auto !important; - min-width: 0 !important; - padding: 0 !important; - border: 0; - background: none !important; - color: var(--ts-brand); - font: inherit; - font-weight: 600; - cursor: pointer; - - &:hover, - &:focus-visible { - text-decoration: underline; - } +.ts-reminder__actions { + display: inline-flex; + gap: 0.25rem; + margin-left: auto; } .ts-reminder__choices { display: grid; - gap: 0.375rem; + gap: 0.5rem; } -.ts-reminder__choice { - display: flex; - justify-content: space-between; - gap: 1rem; - height: auto !important; - min-width: 0 !important; - padding: 0.625rem 0.875rem !important; - border: 1px solid var(--ts-line); - border-radius: 0.5rem; - background: var(--ts-surface); - color: var(--ts-ink); - font: inherit; - text-align: left; - cursor: pointer; - - &:hover, - &:focus-visible { - border-color: var(--ts-brand); - } -} - -.ts-reminder__field { +.ts-reminder__custom { display: grid; - gap: 0.375rem; - color: var(--ts-muted); - font-size: 0.8125rem; + grid-template-columns: minmax(0, 1fr) 8rem; + gap: 0.75rem; + margin: 0; + padding: 0; + border: 0; - .ts-input { - font: inherit; - color: var(--ts-ink); + legend { + margin-bottom: 1.25rem; + padding: 0; + color: var(--ts-ink-soft); + font-size: 0.875rem; + font-weight: 600; } } @@ -888,45 +847,6 @@ a.ts-key { } } -/* Small buttons, not @pi/ui's sized ones. */ -.ts-reviews__button { - height: auto !important; - min-width: 0 !important; - padding: 0.25rem 0.75rem !important; - border: 1px solid var(--ts-line-strong) !important; - border-radius: 999px !important; - background: var(--ts-surface) !important; - color: var(--ts-ink); - font: inherit; - font-size: 0.8125rem; - font-weight: 600; - cursor: pointer; - - &.is-ok { - border-color: transparent !important; - background: var(--ts-c-green) !important; - color: #fff; - } - - &:hover, - &:focus-visible { - filter: brightness(1.05); - } -} - -.ts-reviews__link { - 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; -} - /* A quick poll in a comment: each option a bar of its share. */ .ts-poll { display: grid; @@ -994,47 +914,44 @@ a.ts-key { 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; + gap: 1rem; margin: 0; - padding: 0.75rem; - border: 1px dashed var(--ts-line-strong); - border-radius: 0.5rem; + padding: 1.25rem 1rem 1rem; + border: 1px solid var(--ts-line); + border-radius: 0.75rem; + background: var(--ts-surface); legend { - padding: 0 0.25rem; - color: var(--ts-muted); + padding: 0 0.375rem; + color: var(--ts-ink-soft); font-size: 0.8125rem; + font-weight: 600; } } .ts-poll-editor__row { display: flex; gap: 0.5rem; - align-items: center; + align-items: flex-end; - .ts-input { + pi-text-field { flex: 1; - font: inherit; } } .ts-poll-editor__actions { display: flex; - gap: 1rem; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +/* Poll, screen and voice: what a comment can carry besides words. */ +.ts-composer-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; } diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 10e42f5..5c8b1b7 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -318,20 +318,22 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; @if (reminder.note) { · «{{ reminder.note }}» } - - + + + +

} @@ -382,32 +384,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; }
@if (review.reviewer.id === facade.me()) { - - + + } @if ( review.requester.id === facade.me() || project.canManage() ) { - + }
} @else { @@ -968,20 +972,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; · cerrada{{ poll.winner === null ? ', sin ganadora' : '' }} - } @else if ( - comment.author.id === facade.me() || - project.canManage() - ) { - · - }

+ @if ( + !poll.closed && + (comment.author.id === facade.me() || + project.canManage()) + ) { +
+ +
+ } } @if ( @@ -1072,31 +1080,27 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; " novalidate > - + + Conclusión (opcional): quedará como decisión, arriba + del ticket +
- +
} @else if (replyingTo() === thread.comment.id) { @@ -1211,64 +1215,71 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; /> @if (pollOn()) {
- Encuesta (2 a 6 opciones) + Encuesta · de 2 a 6 opciones @for (option of pollOptions(); track $index) {
- + [value]="option" + (valueChange)="setPollOption($index, $event)" + > + Opción {{ $index + 1 }} + @if (pollOptions().length > 2) { - + (execute)="removePollOption($index)" + /> }
}
@if (pollOptions().length < 6) { - + } - +
- } @else { -
- -
} - +
+ @if (!pollOn()) { + + } + +
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.

- - + + Quién + + + Qué mirar (opcional) + - + }} +

@@ -1531,42 +1532,40 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;

@for (choice of reminderChoices(); track choice.label) { - + }
- - +
+ Otro momento + + Día + + + Hora + +
+ + Nota (opcional) + @@ -1698,9 +1697,23 @@ export class IssuePage { ) .map((m) => ({ id: m.user.id, name: m.user.name })), ); - /** The reminder being written: `datetime-local` value and note. */ - protected readonly reminderAt = signal(''); + protected readonly reviewerOptions = computed[]>( + () => this.reviewers().map((m) => ({ value: m.id, label: m.name })), + ); + /** The reminder being written: day, time and note. */ + protected readonly reminderDay = signal(null); + protected readonly reminderTime = signal('09:00'); protected readonly reminderNote = signal(''); + protected readonly timeOptions = TIME_OPTIONS; + /** The day and time chosen, if both make a moment still to come. */ + protected readonly customReminder = computed(() => { + const day = this.reminderDay(); + if (!day) return null; + const [hours, minutes] = this.reminderTime().split(':').map(Number); + const at = new Date(day); + at.setHours(hours, minutes, 0, 0); + return at.getTime() > Date.now() ? at : null; + }); protected readonly reminderChoices = signal([]); protected readonly when = whenLabel; private readonly editForm = @@ -2158,12 +2171,18 @@ export class IssuePage { protected openReminder(): void { const current = this.facade.issue()?.myReminder; this.reminderChoices.set(reminderChoices()); - this.reminderAt.set(current ? toLocalInput(new Date(current.at)) : ''); + const at = current ? new Date(current.at) : null; + this.reminderDay.set(at); + this.reminderTime.set(at ? halfHourOf(at) : '09:00'); this.reminderNote.set(current?.note ?? ''); this.drawer.open('Recordármelo', this.reminderForm()); } - /** `at`: ISO, or the `datetime-local` value (local time). */ + protected saveCustomReminder(): void { + const at = this.customReminder(); + if (at) this.saveReminder(at.toISOString()); + } + protected saveReminder(at: string): void { if (!at) return; const when = new Date(at); @@ -2172,9 +2191,11 @@ export class IssuePage { this.drawer.closeAll(); } - /** Now, as the `min` of a `datetime-local` input. */ - protected nowLocal(): string { - return toLocalInput(new Date()); + /** The first day a reminder can be set on. */ + protected today(): Date { + const day = new Date(); + day.setHours(0, 0, 0, 0); + return day; } protected move(to: string): void { @@ -2303,7 +2324,7 @@ export class IssuePage { protected setPollOption(index: number, value: string): void { this.pollOptions.update((options) => - options.map((o, i) => (i === index ? value : o)), + options.map((o, i) => (i === index ? value.slice(0, 80) : o)), ); } @@ -2548,7 +2569,18 @@ export class IssuePage { } /** A date as a `datetime-local` value (local time, to the minute). */ -function toLocalInput(date: Date): string { - const pad = (n: number) => String(n).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +const pad = (n: number) => String(n).padStart(2, '0'); + +/** The times a reminder can ring at: every half hour. */ +const TIME_OPTIONS: PiSelectFieldOption[] = Array.from( + { length: 48 }, + (_, i) => { + const time = `${pad(Math.floor(i / 2))}:${i % 2 ? '30' : '00'}`; + return { value: time, label: time }; + }, +); + +/** A moment's time, to the half hour before it. */ +function halfHourOf(date: Date): string { + return `${pad(date.getHours())}:${date.getMinutes() < 30 ? '00' : '30'}`; } diff --git a/libs/web/tracker/presenters/src/lib/ui/media-recorder.ts b/libs/web/tracker/presenters/src/lib/ui/media-recorder.ts index 3b5c089..2a3c65f 100644 --- a/libs/web/tracker/presenters/src/lib/ui/media-recorder.ts +++ b/libs/web/tracker/presenters/src/lib/ui/media-recorder.ts @@ -6,6 +6,7 @@ import { output, signal, } from '@angular/core'; +import { PiIconButtonComponent } from '@pi/ui'; import { ToastService } from '@task-stream/web/shared/ui'; /** Long enough to show a bug, short enough to stay under the upload limit. */ @@ -42,68 +43,65 @@ function mimeFor(kind: Kind): string { @Component({ selector: 'ts-media-recorder', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiIconButtonComponent], template: ` @if (recording(); as kind) { - Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }} · - {{ clock(elapsed()) }} / {{ clock(max) }} + Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }} + {{ clock(elapsed()) }} / {{ clock(max) }} - + } @else { @if (canScreen) { - + (execute)="start('screen')" + /> } @if (canVoice) { - + (execute)="start('voice')" + /> } } `, styles: ` :host { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 1rem; - } - /* Small text buttons, not @pi/ui's sized ones. */ - .ts-recorder__button { - 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; - &:hover, - &:focus-visible { - text-decoration: underline; - } + display: contents; } .ts-recorder__live { display: inline-flex; align-items: center; gap: 0.5rem; - font-size: 0.8125rem; + padding: 0 0.75rem; + border-radius: 999px; + background: color-mix(in srgb, var(--ts-c-red) 12%, transparent); + font-size: 0.875rem; + font-weight: 600; + } + .ts-recorder__clock { + font-weight: 400; font-variant-numeric: tabular-nums; } .ts-recorder__dot {