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

View File

@@ -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();
}
}

View File

@@ -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';

View File

@@ -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,