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:
215
libs/api/tracker/application/src/lib/analytics/home.spec.ts
Normal file
215
libs/api/tracker/application/src/lib/analytics/home.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
274
libs/api/tracker/application/src/lib/analytics/home.ts
Normal file
274
libs/api/tracker/application/src/lib/analytics/home.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
ActivityReadModel,
|
||||
AgendaItemReadModel,
|
||||
HomeProjectReadModel,
|
||||
HomeReadModel,
|
||||
HomeSprintReadModel,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
OnlineReadModel,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
SprintReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
SuggestionReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
import { guestOnly } from '../queries/read-access';
|
||||
import { stepsByIssue } from './analytics';
|
||||
import { sprintPulse } from './insights';
|
||||
|
||||
/*
|
||||
* The home page beyond «my work»: what comes this week, what moves in my
|
||||
* projects, how the week goes, and what to do when nothing is mine. Pure,
|
||||
* like the statistics.
|
||||
*/
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
const OPEN = [
|
||||
StatusCategory.TODO,
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
];
|
||||
|
||||
export interface HomeProjectInput {
|
||||
project: ProjectReadModel;
|
||||
issues: IssueReadModel[];
|
||||
timeline: IssueTimelineEntry[];
|
||||
sprints: SprintReadModel[];
|
||||
releases: ReleaseReadModel[];
|
||||
/** Activity per day of the last 14 days, oldest first. */
|
||||
activity: number[];
|
||||
}
|
||||
|
||||
export interface HomeInput {
|
||||
viewerId: string;
|
||||
projects: HomeProjectInput[];
|
||||
/** Latest activity of the projects I see whole, newest first. */
|
||||
activity: ActivityReadModel[];
|
||||
hasCalendar: boolean;
|
||||
online: OnlineReadModel[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
const utcDay = (time: number) =>
|
||||
Date.UTC(
|
||||
new Date(time).getUTCFullYear(),
|
||||
new Date(time).getUTCMonth(),
|
||||
new Date(time).getUTCDate(),
|
||||
);
|
||||
const dayString = (time: number) => new Date(time).toISOString().slice(0, 10);
|
||||
|
||||
export function computeHome(input: HomeInput): HomeReadModel {
|
||||
const now = input.now.getTime();
|
||||
const today = utcDay(now);
|
||||
const todayStr = dayString(today);
|
||||
const weekEnd = dayString(today + 6 * DAY);
|
||||
const me = input.viewerId;
|
||||
const whole = input.projects.filter((p) => !guestOnly(p.project, me));
|
||||
|
||||
// --- Agenda: the next 7 days ---
|
||||
const agenda: AgendaItemReadModel[] = [];
|
||||
for (const { project, issues, sprints, releases } of input.projects) {
|
||||
for (const issue of issues) {
|
||||
if (issue.trashedAt || CLOSED.includes(issue.statusCategory)) continue;
|
||||
const due = issue.dueDate?.slice(0, 10);
|
||||
if (!due || due < todayStr || due > weekEnd) continue;
|
||||
if (
|
||||
issue.assignee?.id !== me &&
|
||||
!(issue.watchers ?? []).some((w) => w.id === me)
|
||||
)
|
||||
continue;
|
||||
agenda.push({
|
||||
day: due,
|
||||
kind: 'ISSUE',
|
||||
projectKey: project.key,
|
||||
title: issue.title,
|
||||
issueKey: issue.key,
|
||||
});
|
||||
}
|
||||
if (guestOnly(project, me)) continue;
|
||||
for (const sprint of sprints)
|
||||
if (
|
||||
sprint.status === SprintStatus.ACTIVE &&
|
||||
sprint.endDate >= todayStr &&
|
||||
sprint.endDate <= weekEnd
|
||||
)
|
||||
agenda.push({
|
||||
day: sprint.endDate,
|
||||
kind: 'SPRINT_END',
|
||||
projectKey: project.key,
|
||||
title: sprint.name,
|
||||
issueKey: null,
|
||||
});
|
||||
for (const release of releases) {
|
||||
const target = release.targetDate?.slice(0, 10);
|
||||
if (
|
||||
release.status === ReleaseStatus.PLANNED &&
|
||||
target &&
|
||||
target >= todayStr &&
|
||||
target <= weekEnd
|
||||
)
|
||||
agenda.push({
|
||||
day: target,
|
||||
kind: 'RELEASE',
|
||||
projectKey: project.key,
|
||||
title: release.name,
|
||||
issueKey: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
agenda.sort(
|
||||
(a, b) => a.day.localeCompare(b.day) || a.kind.localeCompare(b.kind),
|
||||
);
|
||||
|
||||
// --- Finished per day: the team's and mine ---
|
||||
const teamPerDay = new Array<number>(14).fill(0);
|
||||
const minePerDay = new Map<number, number>();
|
||||
for (const { issues, timeline } of whole) {
|
||||
const steps = stepsByIssue(issues, timeline);
|
||||
for (const list of steps.values())
|
||||
list.forEach((step, i) => {
|
||||
if (step.category !== StatusCategory.DONE) return;
|
||||
if (i > 0 && list[i - 1].category === StatusCategory.DONE) return;
|
||||
if (i === 0) return; // created already done: nobody finished it
|
||||
const day = utcDay(step.at);
|
||||
const ago = Math.round((today - day) / DAY);
|
||||
if (ago >= 0 && ago < 14) teamPerDay[13 - ago]++;
|
||||
if (step.assigneeId === me)
|
||||
minePerDay.set(day, (minePerDay.get(day) ?? 0) + 1);
|
||||
});
|
||||
}
|
||||
const sum = (from: number, to: number) =>
|
||||
teamPerDay.slice(from, to).reduce((a, b) => a + b, 0);
|
||||
const mine = (daysAgoFrom: number, daysAgoTo: number) => {
|
||||
let total = 0;
|
||||
for (let ago = daysAgoFrom; ago < daysAgoTo; ago++)
|
||||
total += minePerDay.get(today - ago * DAY) ?? 0;
|
||||
return total;
|
||||
};
|
||||
// A streak counts up to today, or up to yesterday if today is still empty.
|
||||
let streak = 0;
|
||||
for (
|
||||
let ago = minePerDay.get(today) ? 0 : 1;
|
||||
minePerDay.get(today - ago * DAY);
|
||||
ago++
|
||||
)
|
||||
streak++;
|
||||
|
||||
// --- Sprints and project cards ---
|
||||
const sprints: HomeSprintReadModel[] = [];
|
||||
const projects: HomeProjectReadModel[] = [];
|
||||
for (const { project, issues, sprints: all, activity } of whole) {
|
||||
const live = issues.filter((i) => !i.trashedAt);
|
||||
const active = all.find((s) => s.status === SprintStatus.ACTIVE);
|
||||
const pulse = active ? sprintPulse(active, live, now) : null;
|
||||
if (pulse)
|
||||
sprints.push({
|
||||
projectKey: project.key,
|
||||
projectName: project.name,
|
||||
sprint: pulse,
|
||||
});
|
||||
projects.push({
|
||||
projectId: project.id,
|
||||
open: live.filter((i) => !CLOSED.includes(i.statusCategory)).length,
|
||||
overdue: live.filter(
|
||||
(i) =>
|
||||
!CLOSED.includes(i.statusCategory) &&
|
||||
!!i.dueDate &&
|
||||
i.dueDate.slice(0, 10) < todayStr,
|
||||
).length,
|
||||
activity,
|
||||
sprint: pulse
|
||||
? {
|
||||
name: pulse.name,
|
||||
percent: pulse.points
|
||||
? Math.round((pulse.donePoints / pulse.points) * 100)
|
||||
: pulse.tickets
|
||||
? Math.round((pulse.doneTickets / pulse.tickets) * 100)
|
||||
: 0,
|
||||
behind: pulse.behind,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Suggestions, for when nothing is mine ---
|
||||
const suggestions: SuggestionReadModel[] = [];
|
||||
for (const { project, issues } of whole) {
|
||||
const role = project.members.find((m) => m.user.id === me)?.role;
|
||||
if (!role || role === ProjectRole.VIEWER) continue;
|
||||
const open = issues.filter(
|
||||
(i) => !i.trashedAt && OPEN.includes(i.statusCategory),
|
||||
);
|
||||
const unassigned = open.filter((i) => !i.assignee).length;
|
||||
if (unassigned)
|
||||
suggestions.push({
|
||||
kind: 'UNASSIGNED',
|
||||
projectKey: project.key,
|
||||
count: unassigned,
|
||||
});
|
||||
const qa = open.filter(
|
||||
(i) => i.statusCategory === StatusCategory.QA,
|
||||
).length;
|
||||
if (qa)
|
||||
suggestions.push({
|
||||
kind: 'QA_WAITING',
|
||||
projectKey: project.key,
|
||||
count: qa,
|
||||
});
|
||||
if (role === ProjectRole.OWNER || role === ProjectRole.ADMIN) {
|
||||
const proposals = open.filter(
|
||||
(i) =>
|
||||
i.statusCategory === StatusCategory.TODO &&
|
||||
i.labels.includes(PROPOSAL_LABEL),
|
||||
).length;
|
||||
if (proposals)
|
||||
suggestions.push({
|
||||
kind: 'PROPOSALS',
|
||||
projectKey: project.key,
|
||||
count: proposals,
|
||||
});
|
||||
}
|
||||
}
|
||||
suggestions.sort((a, b) => b.count - a.count);
|
||||
|
||||
return {
|
||||
agenda,
|
||||
activity: input.activity,
|
||||
team: { perDay: teamPerDay, thisWeek: sum(7, 14), lastWeek: sum(0, 7) },
|
||||
me: { thisWeek: mine(0, 7), lastWeek: mine(7, 14), streak },
|
||||
sprints,
|
||||
projects,
|
||||
suggestions: suggestions.slice(0, 5),
|
||||
firstSteps: {
|
||||
project: input.projects.length > 0,
|
||||
invited: input.projects.some(
|
||||
(p) =>
|
||||
p.project.members.length > 1 &&
|
||||
p.project.members.some(
|
||||
(m) =>
|
||||
m.user.id === me &&
|
||||
(m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN),
|
||||
),
|
||||
),
|
||||
ticket: input.projects.some((p) =>
|
||||
p.issues.some((i) => i.reporter?.id === me),
|
||||
),
|
||||
calendar: input.hasCalendar,
|
||||
},
|
||||
online: input.online,
|
||||
};
|
||||
}
|
||||
|
||||
/** The last 14 UTC days (oldest first), as `YYYY-MM-DD`. */
|
||||
export function lastDays(now: Date, count = 14): string[] {
|
||||
const today = utcDay(now.getTime());
|
||||
return Array.from({ length: count }, (_, n) =>
|
||||
dayString(today - (count - 1 - n) * DAY),
|
||||
);
|
||||
}
|
||||
@@ -523,7 +523,7 @@ function backlogHealth(
|
||||
};
|
||||
}
|
||||
|
||||
function sprintPulse(
|
||||
export function sprintPulse(
|
||||
sprint: SprintReadModel,
|
||||
issues: IssueReadModel[],
|
||||
now: number,
|
||||
|
||||
@@ -8,4 +8,14 @@ export abstract class FindActivityRepository {
|
||||
/** ISO date cursor: only activity strictly older than this. */
|
||||
before?: string;
|
||||
}): Promise<ActivityReadModel[]>;
|
||||
/** The latest of several projects, newest first. */
|
||||
abstract findAcross(
|
||||
projectIds: string[],
|
||||
limit: number,
|
||||
): Promise<ActivityReadModel[]>;
|
||||
/** How much happened per project and day (`YYYY-MM-DD`) since `fromDay`. */
|
||||
abstract dailyCounts(
|
||||
projectIds: string[],
|
||||
fromDay: string,
|
||||
): Promise<{ projectId: string; day: string; count: number }[]>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface PresenceEntry {
|
||||
editing: boolean;
|
||||
/** Last heartbeat (ISO). */
|
||||
at: string;
|
||||
/** The ticket, in the project-wide list (who is on what). */
|
||||
issue?: { key: string; title: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,4 +23,14 @@ export abstract class PresenceStore {
|
||||
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
|
||||
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
|
||||
abstract leave(issueId: string, userId: string): Promise<void>;
|
||||
/** The same, per project: who is on which of its tickets. */
|
||||
abstract touchProject(
|
||||
projectId: string,
|
||||
entry: PresenceEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void>;
|
||||
abstract listProject(
|
||||
projectId: string,
|
||||
since: number,
|
||||
): Promise<PresenceEntry[]>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ class MemoryPresence extends PresenceStore {
|
||||
async leave(issueId: string, userId: string) {
|
||||
this.entries.get(issueId)?.delete(userId);
|
||||
}
|
||||
async touchProject() {
|
||||
/* not needed here */
|
||||
}
|
||||
async listProject() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const issues = {
|
||||
|
||||
@@ -28,17 +28,22 @@ export class PresenceService {
|
||||
issueId: string,
|
||||
editing: boolean,
|
||||
): Promise<PresenceEntry[]> {
|
||||
await this.assertCanView(viewer.id, issueId);
|
||||
const issue = await this.assertCanView(viewer.id, issueId);
|
||||
const now = Date.now();
|
||||
await this.store.touch(
|
||||
issueId,
|
||||
{
|
||||
user: { id: viewer.id, name: viewer.name },
|
||||
editing,
|
||||
at: new Date(now).toISOString(),
|
||||
},
|
||||
PRESENCE_TTL_MS,
|
||||
);
|
||||
const entry = {
|
||||
user: { id: viewer.id, name: viewer.name },
|
||||
editing,
|
||||
at: new Date(now).toISOString(),
|
||||
};
|
||||
await Promise.all([
|
||||
this.store.touch(issueId, entry, PRESENCE_TTL_MS),
|
||||
// The project-wide list says on which ticket (home's «who is here»).
|
||||
this.store.touchProject(
|
||||
issue.projectId,
|
||||
{ ...entry, issue: { key: issue.key, title: issue.title } },
|
||||
PRESENCE_TTL_MS,
|
||||
),
|
||||
]);
|
||||
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
|
||||
return here
|
||||
.filter((entry) => entry.user.id !== viewer.id)
|
||||
@@ -58,5 +63,6 @@ export class PresenceService {
|
||||
const issue = await this.findIssues.findById(issueId);
|
||||
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||
await this.access.assertCanViewIssue(issue, viewerId);
|
||||
return issue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ProjectAnalyticsReadModel,
|
||||
ProjectInsightsReadModel,
|
||||
PortfolioProjectReadModel,
|
||||
HomeReadModel,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ProjectSettings,
|
||||
@@ -40,6 +41,7 @@ import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
||||
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
||||
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
|
||||
import { GetPortfolioQuery } from './queries/get-portfolio.query';
|
||||
import { GetMyHomeQuery } from './queries/get-my-home.query';
|
||||
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||
import { GetProjectQuery } from './queries/get-project.query';
|
||||
@@ -339,6 +341,11 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
||||
/** The home page beyond «my work». */
|
||||
home(viewerId: string): Promise<HomeReadModel> {
|
||||
return this.queryBus.execute(new GetMyHomeQuery(viewerId));
|
||||
}
|
||||
|
||||
/** All my active projects at a glance (not archived, not as a guest). */
|
||||
portfolio(viewerId: string): Promise<PortfolioProjectReadModel[]> {
|
||||
return this.queryBus.execute(new GetPortfolioQuery(viewerId));
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
HomeReadModel,
|
||||
OnlineReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeHome, HomeProjectInput, lastDays } from '../analytics/home';
|
||||
import { CalendarFeedsRepository } from '../ports/calendar-feeds.repository';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { PresenceStore } from '../ports/presence.store';
|
||||
import { PRESENCE_TTL_MS } from '../presence.service';
|
||||
import { GetMyHomeQuery } from './get-my-home.query';
|
||||
import { guestOnly } from './read-access';
|
||||
|
||||
/** How much activity the home page lists. */
|
||||
const ACTIVITY = 30;
|
||||
|
||||
@QueryHandler(GetMyHomeQuery)
|
||||
export class GetMyHomeQueryHandler
|
||||
implements IQueryHandler<GetMyHomeQuery, HomeReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
private readonly feeds: CalendarFeedsRepository,
|
||||
private readonly presence: PresenceStore,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyHomeQuery): Promise<HomeReadModel> {
|
||||
const me = query.viewerId;
|
||||
const now = new Date();
|
||||
const projects = (await this.findProjects.findByMember(me)).filter(
|
||||
(p) => !p.archivedAt,
|
||||
);
|
||||
// Project-wide views only of the projects I see whole (not as a guest).
|
||||
const whole = projects.filter((p) => !guestOnly(p, me));
|
||||
const days = lastDays(now);
|
||||
const [inputs, activity, counts, feed, online] = await Promise.all([
|
||||
Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const [issues, timeline, sprints, releases] = await Promise.all([
|
||||
this.findIssues.findByProject(project.id),
|
||||
this.findTimeline.findByProject(project.id),
|
||||
this.findSprints.findByProject(project.id),
|
||||
this.findReleases.findByProject(project.id),
|
||||
]);
|
||||
return { project, issues, timeline, sprints, releases };
|
||||
}),
|
||||
),
|
||||
this.findActivity.findAcross(
|
||||
whole.map((p) => p.id),
|
||||
ACTIVITY,
|
||||
),
|
||||
this.findActivity.dailyCounts(
|
||||
whole.map((p) => p.id),
|
||||
days[0],
|
||||
),
|
||||
this.feeds.findByUser(me),
|
||||
Promise.all(
|
||||
whole.map(async (project) =>
|
||||
(
|
||||
await this.presence.listProject(
|
||||
project.id,
|
||||
now.getTime() - PRESENCE_TTL_MS,
|
||||
)
|
||||
)
|
||||
.filter((entry) => entry.user.id !== me)
|
||||
.map(
|
||||
(entry): OnlineReadModel => ({
|
||||
user: entry.user,
|
||||
projectKey: project.key,
|
||||
issueKey: entry.issue?.key ?? null,
|
||||
issueTitle: entry.issue?.title ?? null,
|
||||
editing: entry.editing,
|
||||
at: entry.at,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
const perDay = (projectId: string) =>
|
||||
days.map(
|
||||
(day) =>
|
||||
counts.find((c) => c.projectId === projectId && c.day === day)
|
||||
?.count ?? 0,
|
||||
);
|
||||
return computeHome({
|
||||
viewerId: me,
|
||||
projects: inputs.map(
|
||||
(i): HomeProjectInput => ({ ...i, activity: perDay(i.project.id) }),
|
||||
),
|
||||
activity,
|
||||
hasCalendar: !!feed,
|
||||
// One line per person: the latest ticket they are on.
|
||||
online: online
|
||||
.flat()
|
||||
.sort((a, b) => b.at.localeCompare(a.at))
|
||||
.filter(
|
||||
(entry, i, all) =>
|
||||
all.findIndex((e) => e.user.id === entry.user.id) === i,
|
||||
),
|
||||
now,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { HomeReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/**
|
||||
* The home page beyond «my work»: agenda, activity, the week, sprints,
|
||||
* project cards, suggestions, first steps and who is on what now.
|
||||
*/
|
||||
export class GetMyHomeQuery extends Query<HomeReadModel> {
|
||||
constructor(public readonly viewerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export * from './get-project-analytics.query';
|
||||
export * from './get-project-insights.query';
|
||||
export * from './get-ticket-scope.query';
|
||||
export * from './get-portfolio.query';
|
||||
export * from './get-my-home.query';
|
||||
export * from './get-project-stats.query';
|
||||
export * from './get-release-burnup.query';
|
||||
export * from './get-project.query';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-h
|
||||
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
||||
import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler';
|
||||
import { GetPortfolioQueryHandler } from './get-portfolio.query-handler';
|
||||
import { GetMyHomeQueryHandler } from './get-my-home.query-handler';
|
||||
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
||||
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
||||
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
||||
@@ -40,6 +41,7 @@ export const QUERY_HANDLERS = [
|
||||
GetProjectInsightsQueryHandler,
|
||||
GetTicketScopeQueryHandler,
|
||||
GetPortfolioQueryHandler,
|
||||
GetMyHomeQueryHandler,
|
||||
GetProjectStatsQueryHandler,
|
||||
GetReleaseBurnupQueryHandler,
|
||||
GetReleaseQueryHandler,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ActivityReadModel } from './activity.read-model';
|
||||
import { SprintPulseReadModel } from './insights.read-model';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
/** Something on a day of my coming week. */
|
||||
export class AgendaItemReadModel {
|
||||
/** `YYYY-MM-DD`. */
|
||||
day!: string;
|
||||
/** A ticket of mine due, a sprint ending or a release planned. */
|
||||
kind!: 'ISSUE' | 'SPRINT_END' | 'RELEASE';
|
||||
projectKey!: string;
|
||||
title!: string;
|
||||
/** For tickets. */
|
||||
issueKey!: string | null;
|
||||
}
|
||||
|
||||
/** The last 7 days against the 7 before. */
|
||||
export class WeekCountReadModel {
|
||||
thisWeek!: number;
|
||||
lastWeek!: number;
|
||||
}
|
||||
|
||||
/** Tickets finished by the teams of my projects. */
|
||||
export class TeamWeekReadModel extends WeekCountReadModel {
|
||||
/** The last 14 days, oldest first. */
|
||||
perDay!: number[];
|
||||
}
|
||||
|
||||
/** Tickets I finished (they were mine when they reached DONE). */
|
||||
export class MyWeekReadModel extends WeekCountReadModel {
|
||||
/** Days in a row, up to today (or yesterday), with at least one. */
|
||||
streak!: number;
|
||||
}
|
||||
|
||||
/** A sprint under way in one of my projects. */
|
||||
export class HomeSprintReadModel {
|
||||
projectKey!: string;
|
||||
projectName!: string;
|
||||
sprint!: SprintPulseReadModel;
|
||||
}
|
||||
|
||||
/** What a project's card on the home page shows besides its name. */
|
||||
export class HomeProjectReadModel {
|
||||
projectId!: string;
|
||||
open!: number;
|
||||
overdue!: number;
|
||||
/** Activity per day, the last 14 days, oldest first. */
|
||||
activity!: number[];
|
||||
sprint!: { name: string; percent: number; behind: boolean } | null;
|
||||
}
|
||||
|
||||
/** Something worth doing when nothing is assigned to me. */
|
||||
export class SuggestionReadModel {
|
||||
kind!: 'UNASSIGNED' | 'PROPOSALS' | 'QA_WAITING';
|
||||
projectKey!: string;
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** The first things to do in TaskStream, done or not. */
|
||||
export class FirstStepsReadModel {
|
||||
project!: boolean;
|
||||
invited!: boolean;
|
||||
ticket!: boolean;
|
||||
calendar!: boolean;
|
||||
}
|
||||
|
||||
/** Someone of my projects on a ticket right now. */
|
||||
export class OnlineReadModel {
|
||||
user!: UserRefReadModel;
|
||||
projectKey!: string;
|
||||
issueKey!: string | null;
|
||||
issueTitle!: string | null;
|
||||
editing!: boolean;
|
||||
at!: string;
|
||||
}
|
||||
|
||||
/** Everything the home page shows beyond «my work». */
|
||||
export class HomeReadModel {
|
||||
agenda!: AgendaItemReadModel[];
|
||||
/** Newest first, across my projects. */
|
||||
activity!: ActivityReadModel[];
|
||||
team!: TeamWeekReadModel;
|
||||
me!: MyWeekReadModel;
|
||||
sprints!: HomeSprintReadModel[];
|
||||
projects!: HomeProjectReadModel[];
|
||||
suggestions!: SuggestionReadModel[];
|
||||
firstSteps!: FirstStepsReadModel;
|
||||
online!: OnlineReadModel[];
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export * from './notification.read-model';
|
||||
export * from './saved-filter.read-model';
|
||||
export * from './sprint.read-model';
|
||||
export * from './insights.read-model';
|
||||
export * from './home.read-model';
|
||||
|
||||
@@ -614,6 +614,47 @@ export class MongoFindActivityRepository extends FindActivityRepository {
|
||||
.lean<ActivityDoc[]>();
|
||||
return docs.map(fromDoc);
|
||||
}
|
||||
|
||||
async findAcross(
|
||||
projectIds: string[],
|
||||
limit: number,
|
||||
): Promise<ActivityReadModel[]> {
|
||||
if (!projectIds.length) return [];
|
||||
const docs = await this.activity
|
||||
.find({ projectId: { $in: projectIds } })
|
||||
.sort({ occurredAt: -1 })
|
||||
.limit(limit)
|
||||
.lean<ActivityDoc[]>();
|
||||
return docs.map(fromDoc);
|
||||
}
|
||||
|
||||
async dailyCounts(
|
||||
projectIds: string[],
|
||||
fromDay: string,
|
||||
): Promise<{ projectId: string; day: string; count: number }[]> {
|
||||
if (!projectIds.length) return [];
|
||||
const rows = await this.activity.aggregate<{
|
||||
_id: { projectId: string; day: string };
|
||||
count: number;
|
||||
}>([
|
||||
{
|
||||
$match: {
|
||||
projectId: { $in: projectIds },
|
||||
occurredAt: { $gte: fromDay },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
projectId: '$projectId',
|
||||
day: { $substrCP: ['$occurredAt', 0, 10] },
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
return rows.map((r) => ({ ...r._id, count: r.count }));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -20,12 +20,35 @@ export class RedisPresenceStore extends PresenceStore {
|
||||
return `task-stream:presence:${issueId}`;
|
||||
}
|
||||
|
||||
async touch(
|
||||
issueId: string,
|
||||
private projectKey(projectId: string): string {
|
||||
return `task-stream:presence:project:${projectId}`;
|
||||
}
|
||||
|
||||
touchProject(
|
||||
projectId: string,
|
||||
entry: PresenceEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
return this.write(this.projectKey(projectId), entry, ttlMs);
|
||||
}
|
||||
|
||||
listProject(projectId: string, since: number): Promise<PresenceEntry[]> {
|
||||
return this.read(this.projectKey(projectId), since);
|
||||
}
|
||||
|
||||
touch(issueId: string, entry: PresenceEntry, ttlMs: number): Promise<void> {
|
||||
return this.write(this.key(issueId), entry, ttlMs);
|
||||
}
|
||||
|
||||
list(issueId: string, since: number): Promise<PresenceEntry[]> {
|
||||
return this.read(this.key(issueId), since);
|
||||
}
|
||||
|
||||
private async write(
|
||||
key: string,
|
||||
entry: PresenceEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
const key = this.key(issueId);
|
||||
await this.redis
|
||||
.multi()
|
||||
.hset(key, entry.user.id, JSON.stringify(entry))
|
||||
@@ -33,8 +56,7 @@ export class RedisPresenceStore extends PresenceStore {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async list(issueId: string, since: number): Promise<PresenceEntry[]> {
|
||||
const key = this.key(issueId);
|
||||
private async read(key: string, since: number): Promise<PresenceEntry[]> {
|
||||
const all = await this.redis.hgetall(key);
|
||||
const fresh: PresenceEntry[] = [];
|
||||
const stale: string[] = [];
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ProjectAnalyticsGql,
|
||||
ProjectInsightsGql,
|
||||
PortfolioProjectGql,
|
||||
HomeGql,
|
||||
ProjectGql,
|
||||
ProjectSettingsGql,
|
||||
ProjectStatsGql,
|
||||
@@ -48,6 +49,14 @@ export class ProjectResolver {
|
||||
return this.projects.findMine(me.id);
|
||||
}
|
||||
|
||||
@Query(() => HomeGql, {
|
||||
description:
|
||||
'The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now',
|
||||
})
|
||||
myHome(@CurrentUser() me: User) {
|
||||
return this.projects.home(me.id);
|
||||
}
|
||||
|
||||
@Query(() => [PortfolioProjectGql], {
|
||||
description:
|
||||
'My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics',
|
||||
|
||||
@@ -1097,3 +1097,95 @@ export class PortfolioProjectGql {
|
||||
epics!: EpicGql[];
|
||||
@Field() updatedAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('AgendaItem', { description: 'Something on a day of my week' })
|
||||
export class AgendaItemGql {
|
||||
@Field({ description: 'YYYY-MM-DD' }) day!: string;
|
||||
@Field({ description: 'ISSUE, SPRINT_END or RELEASE' }) kind!: string;
|
||||
@Field() projectKey!: string;
|
||||
@Field() title!: string;
|
||||
@Field(() => String, { nullable: true }) issueKey!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('TeamWeek')
|
||||
export class TeamWeekGql {
|
||||
@Field(() => [Int], {
|
||||
description: 'Finished per day, last 14, oldest first',
|
||||
})
|
||||
perDay!: number[];
|
||||
@Field(() => Int) thisWeek!: number;
|
||||
@Field(() => Int) lastWeek!: number;
|
||||
}
|
||||
|
||||
@ObjectType('MyWeek')
|
||||
export class MyWeekGql {
|
||||
@Field(() => Int) thisWeek!: number;
|
||||
@Field(() => Int) lastWeek!: number;
|
||||
@Field(() => Int, { description: 'Days in a row with something finished' })
|
||||
streak!: number;
|
||||
}
|
||||
|
||||
@ObjectType('HomeSprint')
|
||||
export class HomeSprintGql {
|
||||
@Field() projectKey!: string;
|
||||
@Field() projectName!: string;
|
||||
@Field(() => SprintPulseGql) sprint!: SprintPulseGql;
|
||||
}
|
||||
|
||||
@ObjectType('HomeSprintBrief')
|
||||
export class HomeSprintBriefGql {
|
||||
@Field() name!: string;
|
||||
@Field(() => Int) percent!: number;
|
||||
@Field() behind!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('HomeProject')
|
||||
export class HomeProjectGql {
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field(() => Int) open!: number;
|
||||
@Field(() => Int) overdue!: number;
|
||||
@Field(() => [Int], {
|
||||
description: 'Activity per day, last 14, oldest first',
|
||||
})
|
||||
activity!: number[];
|
||||
@Field(() => HomeSprintBriefGql, { nullable: true })
|
||||
sprint!: HomeSprintBriefGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('Suggestion')
|
||||
export class SuggestionGql {
|
||||
@Field({ description: 'UNASSIGNED, PROPOSALS or QA_WAITING' }) kind!: string;
|
||||
@Field() projectKey!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('FirstSteps')
|
||||
export class FirstStepsGql {
|
||||
@Field() project!: boolean;
|
||||
@Field() invited!: boolean;
|
||||
@Field() ticket!: boolean;
|
||||
@Field() calendar!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('Online', { description: 'Someone of my projects on a ticket now' })
|
||||
export class OnlineGql {
|
||||
@Field(() => UserRefGql) user!: UserRefGql;
|
||||
@Field() projectKey!: string;
|
||||
@Field(() => String, { nullable: true }) issueKey!: string | null;
|
||||
@Field(() => String, { nullable: true }) issueTitle!: string | null;
|
||||
@Field() editing!: boolean;
|
||||
@Field() at!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Home', { description: 'The home page beyond my work' })
|
||||
export class HomeGql {
|
||||
@Field(() => [AgendaItemGql]) agenda!: AgendaItemGql[];
|
||||
@Field(() => [ActivityGql]) activity!: ActivityGql[];
|
||||
@Field(() => TeamWeekGql) team!: TeamWeekGql;
|
||||
@Field(() => MyWeekGql) me!: MyWeekGql;
|
||||
@Field(() => [HomeSprintGql]) sprints!: HomeSprintGql[];
|
||||
@Field(() => [HomeProjectGql]) projects!: HomeProjectGql[];
|
||||
@Field(() => [SuggestionGql]) suggestions!: SuggestionGql[];
|
||||
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
|
||||
@Field(() => [OnlineGql]) online!: OnlineGql[];
|
||||
}
|
||||
|
||||
@@ -1161,3 +1161,88 @@ export class PortfolioProjectDto {
|
||||
epics!: EpicDto[];
|
||||
@ApiProperty() updatedAt!: string;
|
||||
}
|
||||
|
||||
export class AgendaItemDto {
|
||||
@ApiProperty({ format: 'date' }) day!: string;
|
||||
@ApiProperty({ enum: ['ISSUE', 'SPRINT_END', 'RELEASE'] }) kind!: string;
|
||||
@ApiProperty() projectKey!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) issueKey!: string | null;
|
||||
}
|
||||
|
||||
export class TeamWeekDto {
|
||||
@ApiProperty({
|
||||
type: [Number],
|
||||
description: 'Finished per day, last 14, oldest first',
|
||||
})
|
||||
perDay!: number[];
|
||||
@ApiProperty() thisWeek!: number;
|
||||
@ApiProperty() lastWeek!: number;
|
||||
}
|
||||
|
||||
export class MyWeekDto {
|
||||
@ApiProperty() thisWeek!: number;
|
||||
@ApiProperty() lastWeek!: number;
|
||||
@ApiProperty({ description: 'Days in a row with something finished' })
|
||||
streak!: number;
|
||||
}
|
||||
|
||||
export class HomeSprintDto {
|
||||
@ApiProperty() projectKey!: string;
|
||||
@ApiProperty() projectName!: string;
|
||||
@ApiProperty({ type: SprintPulseDto }) sprint!: SprintPulseDto;
|
||||
}
|
||||
|
||||
export class HomeSprintBriefDto {
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty() percent!: number;
|
||||
@ApiProperty() behind!: boolean;
|
||||
}
|
||||
|
||||
export class HomeProjectDto {
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty() open!: number;
|
||||
@ApiProperty() overdue!: number;
|
||||
@ApiProperty({
|
||||
type: [Number],
|
||||
description: 'Activity per day, last 14, oldest first',
|
||||
})
|
||||
activity!: number[];
|
||||
@ApiProperty({ type: HomeSprintBriefDto, nullable: true })
|
||||
sprint!: HomeSprintBriefDto | null;
|
||||
}
|
||||
|
||||
export class SuggestionDto {
|
||||
@ApiProperty({ enum: ['UNASSIGNED', 'PROPOSALS', 'QA_WAITING'] })
|
||||
kind!: string;
|
||||
@ApiProperty() projectKey!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class FirstStepsDto {
|
||||
@ApiProperty() project!: boolean;
|
||||
@ApiProperty() invited!: boolean;
|
||||
@ApiProperty() ticket!: boolean;
|
||||
@ApiProperty() calendar!: boolean;
|
||||
}
|
||||
|
||||
export class OnlineDto {
|
||||
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
|
||||
@ApiProperty() projectKey!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) issueKey!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) issueTitle!: string | null;
|
||||
@ApiProperty() editing!: boolean;
|
||||
@ApiProperty() at!: string;
|
||||
}
|
||||
|
||||
export class HomeDto {
|
||||
@ApiProperty({ type: [AgendaItemDto] }) agenda!: AgendaItemDto[];
|
||||
@ApiProperty({ type: [ActivityDto] }) activity!: ActivityDto[];
|
||||
@ApiProperty({ type: TeamWeekDto }) team!: TeamWeekDto;
|
||||
@ApiProperty({ type: MyWeekDto }) me!: MyWeekDto;
|
||||
@ApiProperty({ type: [HomeSprintDto] }) sprints!: HomeSprintDto[];
|
||||
@ApiProperty({ type: [HomeProjectDto] }) projects!: HomeProjectDto[];
|
||||
@ApiProperty({ type: [SuggestionDto] }) suggestions!: SuggestionDto[];
|
||||
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
|
||||
@ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[];
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
PortfolioProjectDto,
|
||||
HomeDto,
|
||||
PresenceDto,
|
||||
} from './dto/responses.dto';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
@@ -95,6 +96,18 @@ export class IssuesController {
|
||||
private readonly projects: ProjectsService,
|
||||
) {}
|
||||
|
||||
@Get('me/home')
|
||||
@ApiOperation({
|
||||
summary: 'The home page beyond my work',
|
||||
description:
|
||||
'Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now.',
|
||||
})
|
||||
@ApiOkResponse({ type: HomeDto })
|
||||
@ApiReadErrors()
|
||||
home(@CurrentUser() me: User) {
|
||||
return this.projects.home(me.id);
|
||||
}
|
||||
|
||||
@Get('me/portfolio')
|
||||
@ApiOperation({
|
||||
summary: 'My projects at a glance',
|
||||
|
||||
Reference in New Issue
Block a user