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>
216 lines
5.5 KiB
TypeScript
216 lines
5.5 KiB
TypeScript
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');
|
|
});
|
|
});
|