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()) {
- } @else {
-
-
-
}
-
+
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.
-
-
+
+ Quién
+
+
+ Qué mirar (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 {