feat: a home page with life

Greeting and summary of the day, the coming week, the latest across my
projects, who is online now, the week in numbers (mine and the team's,
with a streak), sprints under way, «Retomar», project cards with their
activity, what to pick up when nothing is mine and first steps for
newcomers. REST (GET /api/me/home), GraphQL (myHome) and MCP
(get_my_week).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:20:41 +02:00
parent affce615a7
commit 0946e9517d
57 changed files with 3728 additions and 53 deletions

View File

@@ -0,0 +1,215 @@
import {
IssueReadModel,
IssueTimelineEntry,
ProjectReadModel,
ProjectRole,
ReleaseReadModel,
ReleaseStatus,
SprintReadModel,
SprintStatus,
StatusCategory as C,
} from '@task-stream/api/tracker/domain';
import { computeHome, HomeProjectInput, lastDays } from './home';
const NOW = new Date('2026-10-15T12:00:00Z');
const ana = { id: 'ana', name: 'Ana' };
const bob = { id: 'bob', name: 'Bob' };
const project = (
key: string,
role: ProjectRole,
extra: Partial<ProjectReadModel> = {},
): ProjectReadModel =>
({
id: key.toLowerCase(),
key,
name: `Proyecto ${key}`,
isPublic: false,
archivedAt: null,
members: [
{ user: ana, role },
{ user: bob, role: ProjectRole.MEMBER },
],
...extra,
}) as ProjectReadModel;
const issue = (
id: string,
category: C,
extra: Partial<IssueReadModel> = {},
): IssueReadModel =>
({
id,
key: id,
projectId: 'web',
title: `Ticket ${id}`,
status: category,
statusCategory: category,
reporter: bob,
assignee: null,
watchers: [],
labels: [],
dueDate: null,
trashedAt: null,
storyPoints: null,
sprint: null,
createdAt: '2026-09-01T09:00:00Z',
updatedAt: '2026-10-01T09:00:00Z',
...extra,
}) as IssueReadModel;
let seq = 0;
const done = (
issueId: string,
at: string,
assigneeId: string,
): IssueTimelineEntry[] => [
{
id: `${issueId}:${++seq}`,
issueId,
projectId: 'web',
at: '2026-09-01T09:00:00Z',
kind: 'status',
status: 'IN_PROGRESS',
category: C.IN_PROGRESS,
assigneeId,
},
{
id: `${issueId}:${++seq}`,
issueId,
projectId: 'web',
at,
kind: 'status',
status: 'DONE',
category: C.DONE,
assigneeId,
},
];
const input = (projects: HomeProjectInput[]) => ({
viewerId: 'ana',
projects,
activity: [],
hasCalendar: false,
online: [],
now: NOW,
});
const web = (extra: Partial<HomeProjectInput> = {}): HomeProjectInput => ({
project: project('WEB', ProjectRole.OWNER),
issues: [],
timeline: [],
sprints: [],
releases: [],
activity: new Array(14).fill(0),
...extra,
});
describe('computeHome', () => {
it('puts my coming week on the agenda: tickets, sprint ends, releases', () => {
const home = computeHome(
input([
web({
issues: [
issue('MINE', C.TODO, { assignee: ana, dueDate: '2026-10-17' }),
issue('FOLLOWED', C.TODO, {
watchers: [ana],
dueDate: '2026-10-15',
}),
issue('NOT_MINE', C.TODO, { assignee: bob, dueDate: '2026-10-16' }),
issue('LATER', C.TODO, { assignee: ana, dueDate: '2026-10-30' }),
issue('DONE', C.DONE, { assignee: ana, dueDate: '2026-10-16' }),
],
sprints: [
{
id: 's',
name: 'Sprint 7',
status: SprintStatus.ACTIVE,
startDate: '2026-10-08',
endDate: '2026-10-21',
} as SprintReadModel,
],
releases: [
{
id: 'r',
name: '2.4',
status: ReleaseStatus.PLANNED,
targetDate: '2026-10-20',
} as ReleaseReadModel,
],
}),
]),
);
expect(home.agenda.map((a) => [a.day, a.kind, a.title])).toEqual([
['2026-10-15', 'ISSUE', 'Ticket FOLLOWED'],
['2026-10-17', 'ISSUE', 'Ticket MINE'],
['2026-10-20', 'RELEASE', '2.4'],
['2026-10-21', 'SPRINT_END', 'Sprint 7'],
]);
});
it("counts the team's week and mine, and my streak", () => {
const closed = [
['A', '2026-10-15T10:00:00Z', 'ana'],
['B', '2026-10-14T10:00:00Z', 'ana'],
['C', '2026-10-13T10:00:00Z', 'ana'],
['D', '2026-10-11T10:00:00Z', 'ana'],
['E', '2026-10-05T10:00:00Z', 'bob'],
] as const;
const home = computeHome(
input([
web({
issues: closed.map(([id]) => issue(id, C.DONE)),
timeline: closed.flatMap(([id, at, who]) => done(id, at, who)),
}),
]),
);
expect(home.team.perDay).toHaveLength(14);
expect(home.team.perDay[13]).toBe(1);
expect(home.team).toMatchObject({ thisWeek: 4, lastWeek: 1 });
// Today, yesterday and the day before: three in a row (the 12th breaks it).
expect(home.me).toEqual({ thisWeek: 4, lastWeek: 0, streak: 3 });
});
it('suggests what to do, and keeps a guest out of project-wide views', () => {
const home = computeHome(
input([
web({
issues: [
issue('U1', C.TODO),
issue('U2', C.TODO, { labels: ['propuesta'] }),
issue('Q1', C.QA, { assignee: bob }),
],
}),
{
...web(),
project: project('CLI', ProjectRole.GUEST),
issues: [issue('X', C.TODO)],
},
]),
);
expect(
home.suggestions.map((s) => [s.kind, s.projectKey, s.count]),
).toEqual([
['UNASSIGNED', 'WEB', 2],
['QA_WAITING', 'WEB', 1],
['PROPOSALS', 'WEB', 1],
]);
// Only the project seen whole has a card.
expect(home.projects.map((p) => p.projectId)).toEqual(['web']);
expect(home.projects[0]).toMatchObject({ open: 3, overdue: 0 });
expect(home.firstSteps).toEqual({
project: true,
invited: true,
ticket: false,
calendar: false,
});
});
it('lists the last 14 days, oldest first', () => {
const days = lastDays(NOW);
expect(days).toHaveLength(14);
expect(days[0]).toBe('2026-10-02');
expect(days[13]).toBe('2026-10-15');
});
});