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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
86
libs/web/tracker/domain/src/lib/models/news.spec.ts
Normal file
86
libs/web/tracker/domain/src/lib/models/news.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
102
libs/web/tracker/domain/src/lib/models/news.ts
Normal file
102
libs/web/tracker/domain/src/lib/models/news.ts
Normal 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}…`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user