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>
275 lines
8.2 KiB
TypeScript
275 lines
8.2 KiB
TypeScript
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),
|
|
);
|
|
}
|