Files
task-stream/libs/api/tracker/application/src/lib/queries/get-my-home.query-handler.ts
Josep Piguillem 0946e9517d 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>
2026-10-15 19:20:41 +02:00

114 lines
4.0 KiB
TypeScript

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,
});
}
}