/cartera, /explorar and /p/:key/proponer are /portfolio, /explore and /p/:key/propose now, like every other path; the Spanish ones redirect, so links already shared keep working. Settings sections go by ?section= with English names. The portfolio reads itself again, quietly, when a ticket, sprint or release of the projects shown changes, when my projects change, or when the live connection comes back; «En directo» replaces the refresh button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
5.6 KiB
TypeScript
187 lines
5.6 KiB
TypeScript
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
|
import { NotFoundError } from '@task-stream/api/shared/domain';
|
|
import { ReleaseStatus, StatusCategory } from '@task-stream/api/tracker/domain';
|
|
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 { PROPOSAL_LABEL } from '../proposals';
|
|
|
|
/** Tickets with this label are offered to newcomers on public pages. */
|
|
export const GOOD_FIRST_LABEL = 'good-first-issue';
|
|
|
|
/**
|
|
* What a public project shows to the world in one call: where it is going
|
|
* (epics with dates, releases), what it is doing (the sprint under way) and
|
|
* where someone new could help. Paths are relative to the web app.
|
|
*/
|
|
export interface Showcase {
|
|
project: {
|
|
id: string;
|
|
key: string;
|
|
name: string;
|
|
description: string | null;
|
|
};
|
|
activeSprint: {
|
|
name: string;
|
|
goal: string | null;
|
|
startDate: string;
|
|
endDate: string;
|
|
percent: number;
|
|
} | null;
|
|
roadmap: {
|
|
key: string;
|
|
title: string;
|
|
startDate: string | null;
|
|
dueDate: string | null;
|
|
percent: number;
|
|
done: boolean;
|
|
/** How many want it. */
|
|
votes: number;
|
|
path: string;
|
|
}[];
|
|
releases: {
|
|
name: string;
|
|
status: ReleaseStatus;
|
|
targetDate: string | null;
|
|
releasedAt: string | null;
|
|
percent: number;
|
|
notes: string | null;
|
|
}[];
|
|
goodFirst: { key: string; title: string; type: string; path: string }[];
|
|
/** Bug reports and ideas from the public: whether it takes them, where, and the open ones. */
|
|
proposals: {
|
|
open: boolean;
|
|
/** The form in the web app, while it takes them. */
|
|
path: string | null;
|
|
/** Open proposals, most voted first. */
|
|
top: {
|
|
key: string;
|
|
title: string;
|
|
type: string;
|
|
votes: number;
|
|
path: string;
|
|
}[];
|
|
};
|
|
}
|
|
|
|
export class GetShowcaseQuery extends Query<Showcase> {
|
|
constructor(public readonly project: string) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
|
const OPEN = [
|
|
StatusCategory.TODO,
|
|
StatusCategory.IN_PROGRESS,
|
|
StatusCategory.QA,
|
|
];
|
|
|
|
@QueryHandler(GetShowcaseQuery)
|
|
export class GetShowcaseQueryHandler
|
|
implements IQueryHandler<GetShowcaseQuery, Showcase>
|
|
{
|
|
constructor(
|
|
private readonly findProjects: FindProjectsRepository,
|
|
private readonly findIssues: FindIssuesRepository,
|
|
private readonly findReleases: FindReleasesRepository,
|
|
private readonly findSprints: FindSprintsRepository,
|
|
) {}
|
|
|
|
/** Public projects only: anything else is not there. */
|
|
async execute(query: GetShowcaseQuery): Promise<Showcase> {
|
|
const project =
|
|
(await this.findProjects.findById(query.project)) ??
|
|
(await this.findProjects.findByKey(query.project.toUpperCase()));
|
|
if (!project?.isPublic) throw new NotFoundError('Project', query.project);
|
|
const path = (key: string) => `/p/${project.key}/issues/${key}`;
|
|
const [epics, releases, sprint, goodFirst, proposals] = await Promise.all([
|
|
this.findIssues.epics(project.id),
|
|
this.findReleases.findByProject(project.id),
|
|
this.findSprints.findActive(project.id),
|
|
this.findIssues.search({
|
|
projectId: project.id,
|
|
labels: [GOOD_FIRST_LABEL],
|
|
categories: [StatusCategory.TODO],
|
|
limit: 20,
|
|
offset: 0,
|
|
}),
|
|
this.findIssues.search({
|
|
projectId: project.id,
|
|
labels: [PROPOSAL_LABEL],
|
|
categories: OPEN,
|
|
limit: 100,
|
|
offset: 0,
|
|
}),
|
|
]);
|
|
const visible = releases
|
|
.filter((r) => r.status !== ReleaseStatus.ARCHIVED)
|
|
.sort((a, b) =>
|
|
(b.releasedAt ?? b.targetDate ?? '').localeCompare(
|
|
a.releasedAt ?? a.targetDate ?? '',
|
|
),
|
|
)
|
|
.slice(0, 6);
|
|
const progress = await Promise.all(
|
|
visible.map((r) => this.findIssues.releaseProgress(r.id)),
|
|
);
|
|
return {
|
|
project: {
|
|
id: project.id,
|
|
key: project.key,
|
|
name: project.name,
|
|
description: project.description,
|
|
},
|
|
activeSprint: sprint
|
|
? {
|
|
name: sprint.name,
|
|
goal: sprint.goal,
|
|
startDate: sprint.startDate,
|
|
endDate: sprint.endDate,
|
|
percent: (await this.findIssues.sprintProgress(sprint.id)).percent,
|
|
}
|
|
: null,
|
|
roadmap: epics.map(({ issue, progress: p }) => ({
|
|
key: issue.key,
|
|
title: issue.title,
|
|
startDate: issue.startDate,
|
|
dueDate: issue.dueDate,
|
|
percent: p.percent,
|
|
done: FINISHED.includes(issue.statusCategory),
|
|
votes: issue.votes,
|
|
path: path(issue.key),
|
|
})),
|
|
releases: visible.map((r, i) => ({
|
|
name: r.name,
|
|
status: r.status,
|
|
targetDate: r.targetDate,
|
|
releasedAt: r.releasedAt,
|
|
percent: progress[i].percent,
|
|
notes: r.status === ReleaseStatus.RELEASED ? r.notes : null,
|
|
})),
|
|
goodFirst: goodFirst.items.map((i) => ({
|
|
key: i.key,
|
|
title: i.title,
|
|
type: i.type,
|
|
path: path(i.key),
|
|
})),
|
|
proposals: {
|
|
open: project.acceptsProposals,
|
|
path: project.acceptsProposals ? `/p/${project.key}/propose` : null,
|
|
top: [...proposals.items]
|
|
.sort((a, b) => b.votes - a.votes)
|
|
.slice(0, 10)
|
|
.map((i) => ({
|
|
key: i.key,
|
|
title: i.title,
|
|
type: i.type,
|
|
votes: i.votes,
|
|
path: path(i.key),
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
}
|