feat: tickets I follow, and more news for followers

"Que sigo" on the home page lists the open tickets I follow, whoever
works on them; `watching` filters my issues (REST, GraphQL, MCP) and a
project's search (none without a session).

Followers now also hear when a ticket's priority or due date changes,
and when it enters or leaves a sprint. Smaller edits (title, labels…)
still do not notify.

Docs (with the REPLIED row the replies change missed), Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:29:17 +02:00
parent ea4ef82239
commit f6417248c7
29 changed files with 636 additions and 11573 deletions

View File

@@ -16,6 +16,7 @@ export const homeApiEvents = eventGroup({
loaded: type<{
mine: IssuePage;
awaitingQa: IssuePage;
following: IssuePage;
releases: Release[];
}>(),
loadFailed: type<string>(),

View File

@@ -390,19 +390,22 @@ describe('HomeFacade', () => {
({ id: key, key, status, statusCategory, title: key }) as never;
it('loads my work and groups it by category, QA failures first', () => {
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
of(
filter.assignedToMe
? {
items: [
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
issue('WEB-4', 'BACKLOG', 'TODO'),
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
],
total: 3,
}
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
),
const mine = vi.fn(
(filter: { assignedToMe: boolean; watching?: boolean }) =>
of(
filter.watching
? { items: [issue('API-7', 'IN_REVIEW', 'IN_PROGRESS')], total: 1 }
: filter.assignedToMe
? {
items: [
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
issue('WEB-4', 'BACKLOG', 'TODO'),
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
],
total: 3,
}
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
),
);
TestBed.configureTestingModule({
providers: [
@@ -437,6 +440,10 @@ describe('HomeFacade', () => {
]);
expect(home.qaFailed()).toBe(1);
expect(home.awaitingQaTotal()).toBe(1);
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({ assignedToMe: false, watching: true }),
);
expect(home.following().map((i) => i.key)).toEqual(['API-7']);
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
});
});

View File

@@ -18,6 +18,9 @@ export class HomeFacade {
/** Tickets of my projects in a QA-category status (anyone's). */
readonly awaitingQa = this.store.awaitingQa;
readonly awaitingQaTotal = this.store.awaitingQaTotal;
/** Open tickets I follow, whoever works on them. */
readonly following = this.store.following;
readonly followingTotal = this.store.followingTotal;
/** Planned releases of my projects, soonest first. */
readonly releases = this.store.releases;
/** False until the first load: show skeletons, not empty states. */

View File

@@ -32,6 +32,8 @@ interface HomeState {
mineTotal: number;
awaitingQa: IssueSummary[];
awaitingQaTotal: number;
following: IssueSummary[];
followingTotal: number;
releases: Release[];
loaded: boolean;
loading: boolean;
@@ -40,7 +42,7 @@ interface HomeState {
/**
* "My work" across every project I belong to: my open tickets, tickets
* waiting for QA and upcoming releases. Root-scoped, so coming back to the
* waiting for QA, the open ones I follow and upcoming releases. Root-scoped, so coming back to the
* home page shows the last data at once while it refreshes.
*/
export const HomeStore = signalStore(
@@ -50,6 +52,8 @@ export const HomeStore = signalStore(
mineTotal: 0,
awaitingQa: [],
awaitingQaTotal: 0,
following: [],
followingTotal: 0,
releases: [],
loaded: false,
loading: false,
@@ -82,6 +86,8 @@ export const HomeStore = signalStore(
mineTotal: payload.mine.total,
awaitingQa: payload.awaitingQa.items,
awaitingQaTotal: payload.awaitingQa.total,
following: payload.following.items,
followingTotal: payload.following.total,
releases: payload.releases,
loaded: true,
loading: false,
@@ -111,6 +117,12 @@ export const HomeStore = signalStore(
categories: [StatusCategory.QA],
limit: 20,
}),
following: issues.mine({
assignedToMe: false,
watching: true,
categories: [...OPEN_CATEGORIES],
limit: 20,
}),
releases: releases.upcoming(5),
}).pipe(
mapResponse({