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