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

@@ -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,