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(14).fill(0); const minePerDay = new Map(); 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), ); }