feat: what is new since my last visit, and turning a comment into a ticket

Opening a ticket notes the visit and answers the one before (issue_visits),
so the page marks others' comments and changes since. A comment can become
a ticket of its own, as a subtask or linked; it remembers where it went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 21:05:44 +02:00
parent 429b52e6f5
commit 285bd5c454
50 changed files with 1953 additions and 79 deletions

View File

@@ -339,6 +339,60 @@ a.ts-key {
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
}
/* Written by someone else since my last visit. */
.ts-comment.is-new:not(.is-decision) {
margin-inline: -0.75rem;
padding-inline: 0.75rem;
border-left: 3px solid var(--ts-brand);
background: color-mix(in srgb, var(--ts-brand) 5%, transparent);
}
.ts-comment__new {
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
color: var(--ts-brand);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* «→ Convertido en WEB-45». */
.ts-comment__spawned {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.8125rem;
a {
font-family: var(--ts-font-mono);
font-weight: 600;
}
}
/* What others did since my last visit, above the ticket. */
.ts-news {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.625rem;
margin: 0;
padding: 0.5rem 0.875rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--ts-brand) 9%, transparent);
font-size: 0.875rem;
}
.ts-news__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ts-brand);
}
.ts-comment.is-pointed {
animation: ts-pointed 2.4s ease-out;
}
@@ -469,6 +523,23 @@ a.ts-key {
&:last-child {
border-bottom: 0;
}
/* Changed by someone else since my last visit. */
&.is-new .ts-log__what {
color: var(--ts-ink);
font-weight: 600;
}
&.is-new .ts-log__when::before {
content: '';
display: inline-block;
width: 0.375rem;
height: 0.375rem;
margin-right: 0.375rem;
border-radius: 50%;
background: var(--ts-brand);
vertical-align: middle;
}
}
}

View File

@@ -25,6 +25,7 @@ import {
PiIconComponent,
PiLabelDirective,
PiNoteComponent,
PiSegmentedControlComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
@@ -44,7 +45,12 @@ import {
} from '@task-stream/web/tracker/application';
import {
Attachment,
Activity,
Comment,
isNewChange,
isNewComment,
newsSince,
titleFrom,
CommentPoll,
DevLink,
Issue,
@@ -113,6 +119,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
PiIconButtonComponent,
PiLabelDirective,
PiNoteComponent,
PiSegmentedControlComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
@@ -337,6 +344,27 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</p>
}
@if (news(); as news) {
<p class="ts-news" role="status">
<span class="ts-news__dot" aria-hidden="true"></span>
<span
>Desde tu última visita, {{ ago(news.since) }}:
<strong>{{ news.what }}</strong> de {{ news.who }}.</span
>
@if (news.firstComment; as first) {
<span class="ts-reminder__actions">
<pi-action-button
size="x-small"
color="grey"
appearance="outline"
text="Ver lo nuevo"
(execute)="goToNews(first)"
/>
</span>
}
</p>
}
@if (project.canContribute() && issue.allowedTransitions.length) {
<div class="ts-transitions" role="group" aria-label="Mover a">
<span class="ts-eyebrow">Mover a</span>
@@ -852,6 +880,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
class="ts-comment"
[id]="'comment-' + comment.id"
[class.is-decision]="comment.decision && !comment.deleted"
[class.is-new]="isNew(comment)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
@@ -868,6 +897,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
@if (comment.decision && !comment.deleted) {
<span class="ts-comment__decision">Decisión</span>
}
@if (isNew(comment)) {
<span class="ts-comment__new">Nuevo</span>
}
<span class="ts-spacer"></span>
@if (!comment.deleted && project.canContribute()) {
<!-- Native: pi-icon-button leaves its button unnamed. -->
@@ -893,6 +925,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-icon icon="flag" size="small" />
</button>
}
@if (!comment.deleted && project.canContribute()) {
<!-- Native, like the flag: a named button. -->
<button
type="button"
class="ts-comment__flag"
aria-label="Convertir en ticket"
title="Convertir en ticket: su texto será la descripción"
(click)="openTurnIntoIssue(comment)"
>
<pi-icon icon="task" size="small" />
</button>
}
@if (
!comment.deleted && comment.author.id === facade.me()
) {
@@ -925,6 +969,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
[me]="facade.me()"
[loader]="loadImage"
/>
@if (comment.spawned.length) {
<p class="ts-comment__spawned">
<span aria-hidden="true">→</span> Convertido en
@for (spawn of comment.spawned; track spawn.id) {
<a [routerLink]="['..', spawn.key]">{{
spawn.key
}}</a>
}
</p>
}
}
@if (!comment.deleted && comment.poll; as poll) {
<div
@@ -1044,6 +1098,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
: ''
}}</span
>
@if (newIn(thread); as count) {
<span class="ts-comment__new"
>{{ count }}
{{ count === 1 ? 'nuevo' : 'nuevos' }}</span
>
}
</div>
<button
type="button"
@@ -1304,7 +1364,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<h2 class="ts-section">Historial</h2>
<ol class="ts-log">
@for (entry of facade.history(); track entry.id) {
<li>
<li [class.is-new]="isNewChange(entry)">
<time
class="ts-log__when"
[attr.datetime]="entry.occurredAt"
@@ -1451,6 +1511,41 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-skeleton shape="text" [lines]="8" />
}
<ng-template #turnIntoIssueForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); sendTurnIntoIssue()"
novalidate
>
<p class="ts-muted">
El texto del comentario será la descripción, y el comentario enlazará
al ticket nuevo.
</p>
<pi-text-field [(value)]="spawnTitle">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
<pi-select-field
[options]="subtaskTypeOptions()"
[value]="spawnType()"
(valueChange)="spawnType.set($event ?? '')"
>
<pi-label>Tipo</pi-label>
</pi-select-field>
<pi-segmented-control
ariaLabel="Cómo queda"
[options]="spawnModes()"
[(value)]="spawnMode"
/>
<pi-action-button
size="w100"
color="primary"
text="Crear ticket"
[disabled]="!spawnTitle().trim() || !spawnType()"
(execute)="sendTurnIntoIssue()"
/>
</form>
</ng-template>
<ng-template #reviewRequestForm>
<form
class="ts-form"
@@ -1673,6 +1768,20 @@ export class IssuePage {
viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly reminderForm =
viewChild.required<TemplateRef<unknown>>('reminderForm');
private readonly turnIntoIssueForm =
viewChild.required<TemplateRef<unknown>>('turnIntoIssueForm');
/** «Convertir en ticket»: the comment, and the ticket being made of it. */
private readonly spawnFrom = signal<Comment | null>(null);
protected readonly spawnTitle = signal('');
protected readonly spawnType = signal('');
protected readonly spawnMode = signal<'link' | 'subtask'>('link');
protected readonly spawnModes = computed(() => {
const key = this.facade.issue()?.key ?? 'este';
return [
{ value: 'link' as const, label: `Enlazado a ${key}` },
{ value: 'subtask' as const, label: `Subtarea de ${key}` },
];
});
private readonly reviewRequestForm =
viewChild.required<TemplateRef<unknown>>('reviewRequestForm');
private readonly reviewAnswerForm =
@@ -2024,6 +2133,17 @@ export class IssuePage {
if (id && this.facade.comments().some((c) => c.id === id))
untracked(() => setTimeout(() => this.goToComment(id)));
});
// My visit before this one, once per ticket (only with a session).
effect(() => {
const issueId = this.openIssueId();
if (!issueId || !this.facade.me()) return;
untracked(() =>
this.issuesGateway.visit(issueId).subscribe({
next: (since) => this.visit.set({ issueId, since }),
error: () => this.visit.set(null),
}),
);
});
// Whether it is in today's «Mi día», once per ticket.
effect(() => {
const issueId = this.openIssueId();
@@ -2106,6 +2226,65 @@ export class IssuePage {
protected readonly inMyDay = signal<boolean | null>(null);
/** Changes with the ticket, not with each reload of it. */
private readonly openIssueId = computed(() => this.facade.issue()?.id);
/* «Lo nuevo desde tu última visita»: what others did since I last came. */
private readonly visit = signal<{
issueId: string;
since: string | null;
} | null>(null);
private readonly since = computed(() => {
const visit = this.visit();
return visit && visit.issueId === this.openIssueId() ? visit.since : null;
});
protected isNew(comment: Comment): boolean {
return isNewComment(comment, this.since(), this.facade.me());
}
protected isNewChange(entry: Activity): boolean {
return isNewChange(entry, this.since(), this.facade.me());
}
protected newIn(thread: { comment: Comment; replies: Comment[] }): number {
return [thread.comment, ...thread.replies].filter((c) => this.isNew(c))
.length;
}
protected readonly news = computed(() =>
newsSince(
this.facade.comments(),
this.facade.history(),
this.since(),
this.facade.me(),
),
);
/** To the first new comment, unfolding its conversation if settled. */
protected goToNews(commentId: string): void {
const thread = this.threads().find(
(t) =>
t.comment.id === commentId || t.replies.some((r) => r.id === commentId),
);
if (thread?.comment.resolved && !this.expanded().has(thread.comment.id))
this.toggleThread(thread.comment.id);
setTimeout(() => this.goToComment(commentId));
}
protected openTurnIntoIssue(comment: Comment): void {
this.spawnFrom.set(comment);
this.spawnTitle.set(titleFrom(comment.body));
this.spawnType.set(this.defaultSubtaskType());
this.spawnMode.set('link');
this.drawer.open('Convertir en ticket', this.turnIntoIssueForm());
}
protected sendTurnIntoIssue(): void {
const comment = this.spawnFrom();
if (!comment || !this.spawnTitle().trim() || !this.spawnType()) return;
this.facade.turnCommentIntoIssue(
comment.id,
this.spawnTitle(),
this.spawnType(),
this.spawnMode() === 'subtask',
);
this.drawer.closeAll();
}
private readonly issuesGateway = inject(IssuesGateway);
protected toggleMyDay(issue: Issue): void {