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

@@ -43,6 +43,9 @@ export * from './lib/alerts.service';
export * from './lib/follows/tree-follows';
export * from './lib/ports/tree-follows.repository';
export * from './lib/tree-follows.service';
export * from './lib/visits/issue-visits';
export * from './lib/ports/issue-visits.repository';
export * from './lib/issue-visits.service';
export * from './lib/reminders/issue-reminders';
export * from './lib/ports/issue-reminders.repository';
export * from './lib/issue-reminders.service';
@@ -51,6 +54,7 @@ export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service';
export * from './lib/commands/reviews';
export * from './lib/commands/polls';
export * from './lib/commands/turn-comment-into-issue';
export * from './lib/sharing/release-notes-shares';
export * from './lib/ports/release-note-shares.repository';
export * from './lib/tracker-application.module';

View File

@@ -3,6 +3,7 @@ import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
import { REVIEW_HANDLERS } from './reviews';
import { POLL_HANDLERS } from './polls';
import { TURN_INTO_ISSUE_HANDLERS } from './turn-comment-into-issue';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
@@ -54,6 +55,7 @@ export const COMMAND_HANDLERS = [
ResolveThreadCommandHandler,
...REVIEW_HANDLERS,
...POLL_HANDLERS,
...TURN_INTO_ISSUE_HANDLERS,
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,

View File

@@ -15,6 +15,7 @@ import {
import {
Comment,
Issue,
IssueLinkType,
IssueStatus,
Project,
IssuePriority,
@@ -81,6 +82,7 @@ import { ReactToCommentCommand } from './react-to-comment.command';
import { MarkDecisionCommand } from './mark-decision.command';
import { ResolveThreadCommand } from './resolve-thread.command';
import { ClosePollCommand, VotePollCommand } from './polls';
import { TurnCommentIntoIssueCommand } from './turn-comment-into-issue';
import {
AnswerReviewCommand,
CancelReviewCommand,
@@ -951,6 +953,65 @@ describe('tracker commands (event sourced)', () => {
);
});
it('makes a ticket of a comment, as a subtask or linked, and marks the comment', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Pago', {}),
);
const comment = await run(
new AddCommentCommand('qa', issue.id, 'También falla en **Android**'),
);
// Viewers comment and react; making tickets is for the team.
await expect(
run(
new TurnCommentIntoIssueCommand(
'pm',
comment.id,
'Android',
IssueType.BUG,
false,
),
),
).rejects.toThrow(ForbiddenError);
const linked = await run(
new TurnCommentIntoIssueCommand(
'dev',
comment.id,
'Falla en Android',
IssueType.BUG,
false,
),
);
const sub = await run(
new TurnCommentIntoIssueCommand(
'dev',
comment.id,
'Probar en Android',
IssueType.TASK,
true,
),
);
const fromLink = await rehydrator.rehydrate(linked.id, Issue);
expect(fromLink.title).toBe('Falla en Android');
expect(fromLink.description).toBe(
'También falla en **Android**\n\n---\n_Sale de un comentario de user qa en TS-1._',
);
expect(fromLink.links).toEqual([
expect.objectContaining({
type: IssueLinkType.RELATES_TO,
targetIssueId: issue.id,
}),
]);
expect((await rehydrator.rehydrate(sub.id, Issue)).parentId).toBe(issue.id);
expect((await rehydrator.rehydrate(comment.id, Comment)).spawned).toEqual([
{ id: linked.id, key: 'TS-2' },
{ id: sub.id, key: 'TS-3' },
]);
});
it('takes votes in a comment poll and keeps the winner as a decision', async () => {
const { run, rehydrator, store } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -0,0 +1,94 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { LinkIssuesCommand } from './link-issues.command';
import { WriteAccess } from './write-access';
/*
* «Convertir en ticket»: a comment that turns out to be work of its own
* («también falla en Android») becomes a ticket, as a subtask of the one it
* was said on or linked to it. Its text is the new description, and the
* comment shows where it went («→ WEB-45»).
*/
export class TurnCommentIntoIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly title: string,
/** Ticket type id of the project's settings. */
public readonly type: string,
/** Hang it from the ticket of the comment; otherwise, link them. */
public readonly asSubtask: boolean,
) {
super();
}
}
/** Answers the new ticket (id, key, version). */
@CommandHandler(TurnCommentIntoIssueCommand)
export class TurnCommentIntoIssueCommandHandler
implements ICommandHandler<TurnCommentIntoIssueCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
private readonly users: UserDirectory,
) {}
async execute(command: TurnCommentIntoIssueCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
const source = await this.access.issue(comment.issueId);
const project = await this.access.project(comment.projectId);
this.access.assertSees(project, source, command.actorId);
// Creating and linking tickets is for the team: those commands check it.
project.assertCanContribute(command.actorId);
if (comment.deleted) throw new BusinessRuleError('Comment was deleted');
const author = (await this.users.refs([comment.authorId])).of(
comment.authorId,
);
const created = await this.commandBus.execute(
new CreateIssueCommand(
command.actorId,
project.id,
command.type,
command.title,
{
description: `${comment.body}\n\n---\n_Sale de un comentario de ${author.name} en ${source.key}._`,
},
null,
null,
command.asSubtask ? source.id : null,
null,
// The comment is the content: no template on top of it.
false,
),
);
if (!command.asSubtask)
await this.commandBus.execute(
new LinkIssuesCommand(
command.actorId,
created.id,
IssueLinkType.RELATES_TO,
source.id,
),
);
comment.turnedInto(command.actorId, {
id: created.id,
key: created.key ?? '',
});
await comment.commitAndWait();
return created;
}
}
export const TURN_INTO_ISSUE_HANDLERS = [TurnCommentIntoIssueCommandHandler];

View File

@@ -11,6 +11,7 @@ import { DeleteCommentCommand } from './commands/delete-comment.command';
import { EditCommentCommand } from './commands/edit-comment.command';
import { ReactToCommentCommand } from './commands/react-to-comment.command';
import { MarkDecisionCommand } from './commands/mark-decision.command';
import { TurnCommentIntoIssueCommand } from './commands/turn-comment-into-issue';
import { ResolveThreadCommand } from './commands/resolve-thread.command';
import { ClosePollCommand, VotePollCommand } from './commands/polls';
import { Page } from './ports/find-issues.repository';
@@ -109,6 +110,25 @@ export class CommentsService {
);
}
/** Makes a ticket of a comment: a subtask of its ticket, or linked to it. */
turnIntoIssue(
actorId: string,
commentId: string,
title: string,
type: string,
asSubtask: boolean,
): Promise<CommandResult> {
return this.commands.execute(
new TurnCommentIntoIssueCommand(
actorId,
commentId,
title,
type,
asSubtask,
),
);
}
/** A ticket's decisions, oldest first. */
decisions(
viewerId: string | null,

View File

@@ -43,6 +43,7 @@ export class CommentAddedEventHandler
resolved: false,
resolvedBy: null,
resolvedAt: null,
spawned: [],
createdAt: comment.createdAt,
editedAt: null,
version: versionOf(event),

View File

@@ -0,0 +1,23 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentTurnedIntoIssueEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentTurnedIntoIssueEvent)
export class CommentTurnedIntoIssueEventHandler
implements IEventHandler<CommentTurnedIntoIssueEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentTurnedIntoIssueEvent) {
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
spawned: event.spawned,
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
}
}

View File

@@ -5,6 +5,7 @@ import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
import { CommentTurnedIntoIssueEventHandler } from './comment-turned-into-issue.event-handler';
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
import { CommentPollEventHandler } from './comment-poll.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler';
@@ -56,6 +57,7 @@ export const EVENT_HANDLERS = [
CommentReactedEventHandler,
CommentDecisionMarkedEventHandler,
CommentThreadResolvedEventHandler,
CommentTurnedIntoIssueEventHandler,
IssueReviewsEventHandler,
CommentPollEventHandler,
IssueVotedEventHandler,

View File

@@ -0,0 +1,14 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { VisitIssueCommand } from './visits/issue-visits';
/** When I last opened a ticket, to tell what is new since. */
@Injectable()
export class IssueVisitsService {
constructor(private readonly commands: CommandRunner) {}
/** Notes that I opened it now; returns my visit before (null: never). */
visit(userId: string, issueId: string): Promise<string | null> {
return this.commands.execute(new VisitIssueCommand(userId, issueId));
}
}

View File

@@ -0,0 +1,11 @@
/** When someone last opened a ticket: personal, no history. */
export interface IssueVisit {
userId: string;
issueId: string;
seenAt: string;
}
export abstract class IssueVisitsRepository {
/** Notes this visit; returns when the one before it was (null: never). */
abstract visit(visit: IssueVisit): Promise<string | null>;
}

View File

@@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views';
import { PINS } from './saved-filters/pins';
import { ALERT_HANDLERS } from './alerts/alerts';
import { TREE_FOLLOWS } from './follows/tree-follows';
import { ISSUE_VISITS } from './visits/issue-visits';
import { IssueVisitsService } from './issue-visits.service';
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
import { MY_DAY } from './my-day/my-day';
import { RELEASE_NOTES_SHARES } from './sharing/release-notes-shares';
@@ -92,6 +94,7 @@ const SERVICES = [
PinsService,
AlertsService,
TreeFollowsService,
IssueVisitsService,
IssueRemindersService,
MyDayService,
SprintsService,
@@ -127,6 +130,7 @@ const SERVICES = [
...PINS,
...ALERT_HANDLERS,
...TREE_FOLLOWS,
...ISSUE_VISITS,
...ISSUE_REMINDERS,
...MY_DAY,
...RELEASE_NOTES_SHARES,

View File

@@ -0,0 +1,51 @@
import {
Command,
CommandHandler,
ICommandHandler,
QueryBus,
} from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { IssueVisitsRepository } from '../ports/issue-visits.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* «Lo nuevo desde tu última visita»: opening a ticket notes the moment, and
* says when the visit before was, so the client can tell what others did
* meanwhile. Personal, no history (like pinned tickets).
*/
export class VisitIssueCommand extends Command<string | null> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
@CommandHandler(VisitIssueCommand)
export class VisitIssueCommandHandler
implements ICommandHandler<VisitIssueCommand>
{
constructor(
private readonly visits: IssueVisitsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: VisitIssueCommand): Promise<string | null> {
const { userId, issueId } = command;
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(userId, [issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue) throw new NotFoundError('Issue', issueId);
return this.visits.visit({
userId,
issueId,
seenAt: new Date().toISOString(),
});
}
}
export const ISSUE_VISITS = [VisitIssueCommandHandler];

View File

@@ -7,6 +7,7 @@ import {
import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
import { CommentTurnedIntoIssueEvent } from '../events/comment-turned-into-issue.event';
import {
CommentPollClosedEvent,
CommentPollVotedEvent,
@@ -82,6 +83,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
public pollVotes: Record<string, number> = {};
public pollClosed = false;
public pollWinner: number | null = null;
/** Tickets made from it, oldest first. */
public spawned: { id: string; key: string }[] = [];
public deleted = false;
public createdAt!: string;
public editedAt: string | null = null;
@@ -277,10 +280,32 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
this.resolved = event.resolved;
}
onCommentTurnedIntoIssueEvent(event: CommentTurnedIntoIssueEvent): void {
this.spawned = event.spawned;
}
onCommentDeletedEvent(): void {
this.deleted = true;
}
/**
* Notes a ticket made from it (its text became the ticket's description).
* Who may create it is the caller's check.
*/
turnedInto(actorId: string, issue: { id: string; key: string }): void {
this.assertAlive();
if (this.spawned.some((s) => s.id === issue.id)) return;
this.apply(
new CommentTurnedIntoIssueEvent(
actorId,
this.id,
this.projectId,
this.issueId,
[...this.spawned, issue],
),
);
}
private assertAlive(): void {
if (this.deleted) throw new BusinessRuleError('Comment was deleted');
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket of its own was made from a comment («→ WEB-45» on it). */
@AutowiredEvent
export class CommentTurnedIntoIssueEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** Every ticket made from it so far, this one last. */
readonly spawned: { id: string; key: string }[],
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -17,6 +17,7 @@ export * from './comment-edited.event';
export * from './comment-reacted.event';
export * from './comment-decision-marked.event';
export * from './comment-thread-resolved.event';
export * from './comment-turned-into-issue.event';
export * from './issue-review.events';
export * from './comment-poll.events';
export * from './comment-deleted.event';

View File

@@ -41,6 +41,8 @@ export class CommentReadModel {
/** Who settled it and when (null while open). */
resolvedBy!: UserRefReadModel | null;
resolvedAt!: string | null;
/** Tickets made from it («Convertir en ticket»), oldest first. */
spawned!: { id: string; key: string }[];
createdAt!: string;
editedAt!: string | null;
version!: number;

View File

@@ -58,6 +58,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
resolved: doc.resolved ?? false,
resolvedBy: doc.resolvedBy ?? null,
resolvedAt: doc.resolvedAt ?? null,
spawned: doc.spawned ?? [],
});
const escapeRegex = (text: string) =>

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
IssueVisit,
IssueVisitsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
@Injectable()
export class MongoIssueVisitsRepository extends IssueVisitsRepository {
constructor(
@InjectModel(ReadModels.IssueVisit)
private readonly visits: Model<IssueVisit>,
) {
super();
}
async visit(visit: IssueVisit): Promise<string | null> {
// One round trip: the document as it was before this visit.
const before = await this.visits
.findOneAndUpdate(
{ userId: visit.userId, issueId: visit.issueId },
{ $set: { seenAt: visit.seenAt } },
{ upsert: true, new: false },
)
.lean<IssueVisit>();
return before?.seenAt ?? null;
}
}

View File

@@ -110,6 +110,7 @@ export const ReadModels = {
AlertIntegration: 'AlertIntegration',
AlertTrace: 'AlertTrace',
TreeFollow: 'TreeFollow',
IssueVisit: 'IssueVisit',
IssueReminder: 'IssueReminder',
MyDay: 'MyDay',
ReleaseNotesShare: 'ReleaseNotesShare',
@@ -373,6 +374,16 @@ export const TreeFollowSchema =
SchemaFactory.createForClass(TreeFollowDocument);
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'issue_visits' })
class IssueVisitDocument {
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) seenAt!: string;
}
export const IssueVisitSchema =
SchemaFactory.createForClass(IssueVisitDocument);
IssueVisitSchema.index({ userId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'issue_reminders' })
class IssueReminderDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@@ -511,6 +522,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.IssueVisit, schema: IssueVisitSchema },
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
{ name: ReadModels.MyDay, schema: MyDaySchema },
{ name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema },

View File

@@ -16,6 +16,7 @@ import {
PinsRepository,
AlertIntegrationsRepository,
TreeFollowsRepository,
IssueVisitsRepository,
IssueRemindersRepository,
MyDaysRepository,
ReleaseNoteSharesRepository,
@@ -87,6 +88,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoIssueVisitsRepository } from './mongo/issue-visits.repository';
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
import { MongoMyDaysRepository } from './mongo/my-days.repository';
import { MongoReleaseNoteSharesRepository } from './mongo/release-note-shares.repository';
@@ -172,6 +174,7 @@ const PORTS = [
useClass: MongoAlertIntegrationsRepository,
},
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
{ provide: IssueVisitsRepository, useClass: MongoIssueVisitsRepository },
{
provide: IssueRemindersRepository,
useClass: MongoIssueRemindersRepository,

View File

@@ -113,6 +113,27 @@ export class CommentResolver {
);
}
@Mutation(() => CommandResultGql, {
description:
'Make a ticket of a comment: its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO). Answers the new ticket. Whoever can change tickets',
})
turnCommentIntoIssue(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('title') title: string,
@Args('type', { description: "One of the project's ticket type ids" })
type: string,
@Args('asSubtask', { defaultValue: false }) asSubtask: boolean,
) {
return this.comments.turnIntoIssue(
me.id,
commentId,
title,
type,
asSubtask,
);
}
@OptionalAuth()
@Query(() => [DecisionGql], {
description:

View File

@@ -20,6 +20,7 @@ import {
PinsService,
TreeFollowsService,
IssueRemindersService,
IssueVisitsService,
MyDayService,
PresenceService,
WorkflowsService,
@@ -73,6 +74,7 @@ export class IssueResolver {
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
private readonly myDayService: MyDayService,
private readonly visits: IssueVisitsService,
) {}
@Mutation(() => [PresenceGql], {
@@ -452,6 +454,18 @@ export class IssueResolver {
return true;
}
@Mutation(() => String, {
nullable: true,
description:
'I opened this ticket now: answers when I had opened it before (null: never), to show what is new since',
})
visitIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<string | null> {
return this.visits.visit(me.id, issueId);
}
@Query(() => MyDayGql, {
description:
'«Mi día»: the tickets I picked for today, and what was left on my last day',

View File

@@ -572,6 +572,12 @@ export class CommentPollGql {
winner!: number | null;
}
@ObjectType('CommentSpawn', { description: 'A ticket made from a comment' })
export class CommentSpawnGql {
@Field(() => ID) id!: string;
@Field() key!: string;
}
@ObjectType('Comment')
export class CommentGql {
@Field(() => ID) id!: string;
@@ -615,6 +621,10 @@ export class CommentGql {
})
resolvedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) resolvedAt!: string | null;
@Field(() => [CommentSpawnGql], {
description: 'Tickets made from it («Convertir en ticket»), oldest first',
})
spawned!: CommentSpawnGql[];
@Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number;

View File

@@ -22,6 +22,7 @@ import {
ClosePollDto,
ReactToCommentDto,
ResolveThreadDto,
TurnCommentIntoIssueDto,
VotePollDto,
} from './dto/requests.dto';
import { CommandResultDto } from './dto/responses.dto';
@@ -117,6 +118,28 @@ export class CommentsController {
return this.comments.closePoll(me.id, commentId, body.decide ?? true);
}
@Post(':commentId/issue')
@ApiOperation({
summary: 'Make a ticket of a comment',
description:
'Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
turnIntoIssue(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: TurnCommentIntoIssueDto,
) {
return this.comments.turnIntoIssue(
me.id,
commentId,
body.title,
body.type,
body.asSubtask ?? false,
);
}
@Put(':commentId/resolution')
@ApiOperation({
summary: 'Settle a conversation (a top-level comment and its replies)',

View File

@@ -1640,6 +1640,27 @@ export class SetReminderDto {
note?: string;
}
export class TurnCommentIntoIssueDto {
@ApiProperty({ example: 'También falla en Android', maxLength: 200 })
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty({ description: "One of the project's ticket type ids" })
@IsString()
type!: string;
@ApiPropertyOptional({
default: false,
description:
'Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)',
})
@IsOptional()
@IsBoolean()
asSubtask?: boolean;
}
export class ResolveThreadDto {
@ApiPropertyOptional({
description:

View File

@@ -628,6 +628,10 @@ export class CommentPollDto {
winner!: number | null;
}
export class CommentSpawnDto {
@ApiProperty() id!: string;
@ApiProperty({ example: 'WEB-45' }) key!: string;
}
export class CommentDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) issueId!: string;
@@ -679,6 +683,11 @@ export class CommentDto {
resolvedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
resolvedAt!: string | null;
@ApiProperty({
type: () => [CommentSpawnDto],
description: 'Tickets made from it («Convertir en ticket»), oldest first',
})
spawned!: CommentSpawnDto[];
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
editedAt!: string | null;
@@ -1447,6 +1456,16 @@ export class IssueReminderDto {
@ApiProperty({ type: String, nullable: true }) note!: string | null;
}
export class IssueVisitDto {
@ApiProperty({
type: String,
format: 'date-time',
nullable: true,
description: 'My visit before this one (null: first time)',
})
previousVisit!: string | null;
}
export class ReminderOnIssueDto {
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;

View File

@@ -35,6 +35,7 @@ import {
PinsService,
TreeFollowsService,
IssueRemindersService,
IssueVisitsService,
MyDayService,
PresenceService,
ProjectsService,
@@ -73,6 +74,7 @@ import {
CommentPageDto,
DecisionDto,
IssueReminderDto,
IssueVisitDto,
MyDayDto,
ReminderOnIssueDto,
EpicDto,
@@ -112,6 +114,7 @@ export class IssuesController {
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
private readonly myDayService: MyDayService,
private readonly visits: IssueVisitsService,
) {}
@Get('me/home')
@@ -382,6 +385,27 @@ export class IssuesController {
);
}
@Put('issues/:issue/visit')
@ISSUE
@ApiOperation({
summary: 'I opened this ticket now',
description:
'Answers when I had opened it before (null: never), to show what others did since. Private to me.',
})
@ApiOkResponse({ type: IssueVisitDto })
@ApiCommandErrors()
async visit(
@CurrentUser() me: User,
@Param('issue') issue: string,
): Promise<IssueVisitDto> {
return {
previousVisit: await this.visits.visit(
me.id,
await this.refs.issueId(me.id, issue),
),
};
}
@Put('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE

View File

@@ -110,6 +110,12 @@ export const issuePageEvents = eventGroup({
resolved: boolean;
conclusion: string | null;
}>(),
commentToIssueRequested: type<{
commentId: string;
title: string;
type: string;
asSubtask: boolean;
}>(),
voteRequested: type<{ issueId: string; on: boolean }>(),
/** An image pasted into a text was uploaded (it joins the ticket's files). */
imagePasted: type<{ issueId: string }>(),

View File

@@ -316,6 +316,21 @@ export class IssueFacade {
});
}
/** Makes a ticket of a comment: a subtask of this one, or linked to it. */
turnCommentIntoIssue(
commentId: string,
title: string,
type: string,
asSubtask: boolean,
): void {
this.dispatch.commentToIssueRequested({
commentId,
title: title.trim(),
type,
asSubtask,
});
}
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
markDecision(commentId: string, on: boolean): void {
this.dispatch.decisionRequested({ commentId, on });

View File

@@ -608,6 +608,28 @@ export const IssueStore = signalStore(
),
),
),
events.on(issuePageEvents.commentToIssueRequested).pipe(
mergeMap(({ payload }) =>
comments
.turnIntoIssue(
payload.commentId,
payload.title,
payload.type,
payload.asSubtask,
)
.pipe(
mapResponse({
next: (result) =>
done(
result.key
? `Creado ${result.key}`
: 'Ticket creado',
),
error: (error) => failed(describeError(error)),
}),
),
),
),
events.on(issuePageEvents.reactRequested).pipe(
mergeMap(({ payload }) =>
comments.react(payload.commentId, payload.emoji, payload.on).pipe(

View File

@@ -4,6 +4,7 @@ export * from './lib/models/bulk';
export * from './lib/models/reminders';
export * from './lib/models/my-day';
export * from './lib/models/health';
export * from './lib/models/news';
export * from './lib/models/inputs';
export * from './lib/models/workflow';
export * from './lib/ports/gateways';

View File

@@ -385,6 +385,8 @@ export interface Comment {
resolved: boolean;
/** Who settled it (null while open). */
resolvedBy: UserRef | null;
/** Tickets made from it («Convertir en ticket»), oldest first. */
spawned: { id: string; key: string }[];
createdAt: string;
editedAt: string | null;
}

View File

@@ -0,0 +1,86 @@
import { Activity, Comment } from './models';
import { newsSince, titleFrom } from './news';
const user = (id: string, name: string) => ({ id, name });
const comment = (
id: string,
by: string,
at: string,
deleted = false,
): Comment =>
({
id,
author: user(by, by === 'me' ? 'Yo' : by === 'ana' ? 'Ana' : 'Bob'),
createdAt: at,
deleted,
}) as Comment;
const change = (id: string, by: string, at: string, type: string): Activity =>
({
id,
type,
actor: user(by, by === 'ana' ? 'Ana' : 'Bob'),
occurredAt: at,
}) as Activity;
describe('news since my last visit', () => {
const since = '2026-10-20T10:00:00.000Z';
it('counts what others did since, not mine nor what came before', () => {
const news = newsSince(
[
comment('old', 'ana', '2026-10-19T09:00:00.000Z'),
comment('mine', 'me', '2026-10-21T09:00:00.000Z'),
comment('c1', 'bob', '2026-10-21T09:30:00.000Z'),
comment('gone', 'ana', '2026-10-21T09:40:00.000Z', true),
comment('c2', 'ana', '2026-10-21T10:00:00.000Z'),
],
[
change(
'h1',
'ana',
'2026-10-21T08:00:00.000Z',
'IssueStatusChangedEvent',
),
// The comment itself is already counted as a comment.
change('h2', 'bob', '2026-10-21T09:30:00.000Z', 'CommentAddedEvent'),
change('h3', 'bob', '2026-10-19T08:00:00.000Z', 'IssueAssignedEvent'),
],
since,
'me',
);
expect(news).toEqual({
since,
what: '2 comentarios y 1 cambio',
who: 'Bob y Ana',
firstComment: 'c1',
});
});
it('says nothing the first time, or when nothing happened', () => {
const comments = [comment('c1', 'ana', '2026-10-21T09:00:00.000Z')];
expect(newsSince(comments, [], null, 'me')).toBeNull();
expect(
newsSince(comments, [], '2026-10-22T00:00:00.000Z', 'me'),
).toBeNull();
});
});
describe('titleFrom', () => {
it('takes the first line without Markdown', () => {
expect(
titleFrom(
'\n## También falla en **Android** ([log](https://x.dev))\nMás',
),
).toBe('También falla en Android (log)');
expect(titleFrom('- `npm ci` rompe en CI')).toBe('npm ci rompe en CI');
expect(titleFrom('También falla en Android.\nAdjunto el log.')).toBe(
'También falla en Android',
);
});
it('cuts long lines at a word', () => {
const title = titleFrom('palabra '.repeat(30));
expect(title.length).toBeLessThanOrEqual(121);
expect(title.endsWith('palabra…')).toBe(true);
});
});

View File

@@ -0,0 +1,102 @@
import { Activity, Comment } from './models';
/*
* «Lo nuevo desde tu última visita»: what others did on a ticket since I
* last opened it. My own comments and changes are not news to me.
*/
/** A comment someone else wrote since (deleted ones say nothing). */
export function isNewComment(
comment: Comment,
since: string | null,
me: string | null,
): boolean {
return (
!!since &&
!comment.deleted &&
comment.createdAt > since &&
comment.author.id !== me
);
}
/** A change someone else made since; comments count on their own. */
export function isNewChange(
entry: Activity,
since: string | null,
me: string | null,
): boolean {
return (
!!since &&
entry.occurredAt > since &&
entry.actor.id !== me &&
!entry.type.startsWith('Comment')
);
}
export interface IssueNews {
since: string;
/** «2 comentarios y 1 cambio». */
what: string;
/** «Ana y Bob». */
who: string;
/** The first new comment, to go to it. */
firstComment: string | null;
}
/** What is new since, or null when nothing is (or it is my first time). */
export function newsSince(
comments: Comment[],
history: Activity[],
since: string | null,
me: string | null,
): IssueNews | null {
if (!since) return null;
const fresh = comments.filter((c) => isNewComment(c, since, me));
const changes = history.filter((e) => isNewChange(e, since, me));
if (!fresh.length && !changes.length) return null;
const what = [
count(fresh.length, 'comentario', 'comentarios'),
count(changes.length, 'cambio', 'cambios'),
].filter(Boolean);
const who = [
...new Set([
...fresh.map((c) => c.author.name),
...changes.map((e) => e.actor.name),
]),
];
return {
since,
what: what.join(' y '),
who:
who.length > 1
? `${who.slice(0, -1).join(', ')} y ${who[who.length - 1]}`
: who[0],
firstComment: fresh[0]?.id ?? null,
};
}
function count(n: number, one: string, many: string): string {
return n === 1 ? `1 ${one}` : n ? `${n} ${many}` : '';
}
/** A ticket title out of a comment: its first line, without Markdown. */
export function titleFrom(body: string): string {
const line =
body
.split('\n')
.map((l) =>
l
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/^\s*(#+|>|[-*+]|\d+\.)\s*/, '')
.replace(/[*_`~]/g, '')
.trim()
// A title ends without the sentence's stop.
.replace(/[.:;,]+$/, ''),
)
.find(Boolean) ?? '';
if (line.length <= 120) return line;
const cut = line.slice(0, 120);
const space = cut.lastIndexOf(' ');
return `${space > 60 ? cut.slice(0, space) : cut}…`;
}

View File

@@ -170,6 +170,8 @@ export abstract class IssuesGateway {
abstract pin(issueId: string, pinned: boolean): Observable<void>;
/** Follows the ticket with everything under it (or stops). */
abstract followTree(issueId: string, on: boolean): Observable<void>;
/** I opened it now: answers my visit before (null: never). */
abstract visit(issueId: string): Observable<string | null>;
/** Reminds me of the ticket then (replaces the waiting one). */
abstract setReminder(
issueId: string,
@@ -368,6 +370,13 @@ export abstract class CommentsGateway {
resolved: boolean,
conclusion: string | null,
): Observable<CommandResult>;
/** Makes a ticket of it: a subtask of its ticket, or linked. Answers the new one. */
abstract turnIntoIssue(
commentId: string,
title: string,
type: string,
asSubtask: boolean,
): Observable<CommandResult>;
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
abstract markDecision(
commentId: string,

View File

@@ -25,6 +25,7 @@ import {
ReactToCommentDocument,
MarkDecisionDocument,
ResolveThreadDocument,
TurnCommentIntoIssueDocument,
VotePollDocument,
ClosePollDocument,
PublishReleaseDocument,
@@ -111,6 +112,22 @@ export class GraphqlCommentsGateway extends CommentsGateway {
.pipe(map((d) => toCommandResult(d.resolveThread)));
}
turnIntoIssue(
commentId: string,
title: string,
type: string,
asSubtask: boolean,
): Observable<CommandResult> {
return this.gql
.mutate(TurnCommentIntoIssueDocument, {
commentId,
title,
type,
asSubtask,
})
.pipe(map((d) => toCommandResult(d.turnCommentIntoIssue)));
}
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
return this.gql
.mutate(MarkDecisionDocument, { commentId, on })

View File

@@ -57,6 +57,7 @@ import {
CancelReviewDocument,
MyPinsDocument,
FollowTreeDocument,
VisitIssueDocument,
SetReminderDocument,
MyDayDocument,
AddToMyDayDocument,
@@ -272,6 +273,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map(() => undefined));
}
visit(issueId: string): Observable<string | null> {
return this.gql
.mutate(VisitIssueDocument, { issueId })
.pipe(map((d) => d.visitIssue ?? null));
}
followTree(issueId: string, on: boolean): Observable<void> {
const done$: Observable<unknown> = on
? this.gql.mutate(FollowTreeDocument, { issueId })

View File

@@ -218,6 +218,10 @@ fragment CommentFields on Comment {
resolvedBy {
...UserRefFields
}
spawned {
id
key
}
createdAt
editedAt
author {

View File

@@ -754,6 +754,7 @@ export type IssueFieldsFragment = {
}>;
} | null;
resolvedBy: { id: string; name: string } | null;
spawned: Array<{ id: string; key: string }>;
author: { id: string; name: string };
}>;
reporter: { id: string; name: string };
@@ -817,6 +818,7 @@ export type CommentFieldsFragment = {
}>;
} | null;
resolvedBy: { id: string; name: string } | null;
spawned: Array<{ id: string; key: string }>;
author: { id: string; name: string };
};
@@ -1027,6 +1029,7 @@ export type IssueQuery = {
}>;
} | null;
resolvedBy: { id: string; name: string } | null;
spawned: Array<{ id: string; key: string }>;
author: { id: string; name: string };
}>;
reporter: { id: string; name: string };
@@ -1398,6 +1401,7 @@ export type IssueCommentsQuery = {
}>;
} | null;
resolvedBy: { id: string; name: string } | null;
spawned: Array<{ id: string; key: string }>;
author: { id: string; name: string };
}>;
};
@@ -1830,6 +1834,23 @@ export type ResolveThreadMutation = {
resolveThread: { id: string; key: string | null; version: number };
};
export type TurnCommentIntoIssueMutationVariables = Exact<{
commentId: string;
title: string;
type: string;
asSubtask: boolean;
}>;
export type TurnCommentIntoIssueMutation = {
turnCommentIntoIssue: { id: string; key: string | null; version: number };
};
export type VisitIssueMutationVariables = Exact<{
issueId: string;
}>;
export type VisitIssueMutation = { visitIssue: string | null };
export type MarkDecisionMutationVariables = Exact<{
commentId: string;
on: boolean;
@@ -1870,6 +1891,7 @@ export type SearchDecisionsQuery = {
}>;
} | null;
resolvedBy: { id: string; name: string } | null;
spawned: Array<{ id: string; key: string }>;
author: { id: string; name: string };
};
issue: { id: string; key: string; title: string };
@@ -3254,6 +3276,7 @@ export type CommentChangedSubscription = {
}>;
} | null;
resolvedBy: { id: string; name: string } | null;
spawned: Array<{ id: string; key: string }>;
author: { id: string; name: string };
} | null;
};
@@ -4091,6 +4114,17 @@ export const CommentFieldsFragmentDoc = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'spawned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -4536,6 +4570,17 @@ export const IssueFieldsFragmentDoc = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'spawned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -6853,6 +6898,17 @@ export const IssueDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'spawned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -9204,6 +9260,17 @@ export const IssueCommentsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'spawned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -12014,6 +12081,181 @@ export const ResolveThreadDocument = {
ResolveThreadMutation,
ResolveThreadMutationVariables
>;
export const TurnCommentIntoIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'TurnCommentIntoIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'commentId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'title' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'type' } },
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'asSubtask' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Boolean' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'turnCommentIntoIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'commentId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'commentId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'title' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'title' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'type' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'type' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'asSubtask' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'asSubtask' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<
TurnCommentIntoIssueMutation,
TurnCommentIntoIssueMutationVariables
>;
export const VisitIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'VisitIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'visitIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<VisitIssueMutation, VisitIssueMutationVariables>;
export const MarkDecisionDocument = {
kind: 'Document',
definitions: [
@@ -12304,6 +12546,17 @@ export const SearchDecisionsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'spawned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -19548,6 +19801,17 @@ export const CommentChangedDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'spawned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{

View File

@@ -396,6 +396,26 @@ mutation ResolveThread(
}
}
mutation TurnCommentIntoIssue(
$commentId: ID!
$title: String!
$type: String!
$asSubtask: Boolean!
) {
turnCommentIntoIssue(
commentId: $commentId
title: $title
type: $type
asSubtask: $asSubtask
) {
...CommandResultFields
}
}
mutation VisitIssue($issueId: ID!) {
visitIssue(issueId: $issueId)
}
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
markDecision(commentId: $commentId, on: $on) {
...CommandResultFields

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 {