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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user