feat: per-project workflow settings, statistics and "my work" home

Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:53:45 +02:00
parent 240ea9a539
commit 0e301ad545
143 changed files with 10291 additions and 1052 deletions

View File

@@ -0,0 +1,315 @@
import {
DurationStats,
IssueReadModel,
IssueTimelineEntry,
ProjectAnalyticsReadModel,
ReleaseBurnupReadModel,
ReleaseReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
/*
* Statistics are computed from each ticket's timeline (status and release
* steps). Pure functions: no Nest, no database, easy to test.
*/
const DAY = 86_400_000;
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
interface Step {
at: number;
status: string;
category: StatusCategory;
assigneeId: string | null;
}
/** "2026-10-06" for a timestamp (UTC). */
export const dayOf = (time: number) =>
new Date(time).toISOString().slice(0, 10);
/** Monday of the ISO week of a timestamp (UTC), as "YYYY-MM-DD". */
export function weekOf(time: number): string {
const date = new Date(time);
const shift = (date.getUTCDay() + 6) % 7;
return dayOf(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() - shift,
),
);
}
/** End of each UTC day between two instants (inclusive). */
function days(from: number, to: number): number[] {
const out: number[] = [];
const first = Date.UTC(
new Date(from).getUTCFullYear(),
new Date(from).getUTCMonth(),
new Date(from).getUTCDate(),
);
for (let day = first; day <= to; day += DAY) out.push(day + DAY - 1);
return out;
}
function weeks(from: number, to: number): string[] {
const out: string[] = [];
for (let day = Date.parse(weekOf(from)); day <= to; day += 7 * DAY)
out.push(dayOf(day));
return out;
}
/** Status steps per ticket; tickets with no timeline get one from their creation. */
function stepsByIssue(
issues: IssueReadModel[],
timeline: IssueTimelineEntry[],
): Map<string, Step[]> {
const steps = new Map<string, Step[]>();
for (const entry of timeline) {
if (entry.kind !== 'status' || !entry.status || !entry.category) continue;
const list = steps.get(entry.issueId) ?? [];
list.push({
at: Date.parse(entry.at),
status: entry.status,
category: entry.category,
assigneeId: entry.assigneeId ?? null,
});
steps.set(entry.issueId, list);
}
for (const issue of issues) {
if (!steps.has(issue.id)) {
steps.set(issue.id, [
{
at: Date.parse(issue.createdAt),
status: issue.status,
category: issue.statusCategory,
assigneeId: issue.assignee?.id ?? null,
},
]);
}
steps.get(issue.id)?.sort((a, b) => a.at - b.at);
}
return steps;
}
/** Last step at or before `time`. */
function stepAt(steps: Step[], time: number): Step | null {
let current: Step | null = null;
for (const step of steps) {
if (step.at > time) break;
current = step;
}
return current;
}
function durations(values: number[]): DurationStats {
if (values.length === 0)
return { count: 0, averageDays: null, p85Days: null };
const sorted = [...values].sort((a, b) => a - b);
const average = values.reduce((sum, v) => sum + v, 0) / values.length;
const p85 = sorted[Math.max(0, Math.ceil(0.85 * sorted.length) - 1)];
const round = (ms: number) => Math.round((ms / DAY) * 10) / 10;
return {
count: values.length,
averageDays: round(average),
p85Days: round(p85),
};
}
export interface AnalyticsInput {
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
/** Status ids in the project's order (series follow it). */
statusOrder: string[];
from: Date;
to: Date;
}
export function computeProjectAnalytics(
input: AnalyticsInput,
): ProjectAnalyticsReadModel {
const from = input.from.getTime();
const to = input.to.getTime();
const inRange = (time: number) => time >= from && time <= to;
const steps = stepsByIssue(input.issues, input.timeline);
const byId = new Map(input.issues.map((issue) => [issue.id, issue]));
const people = new Map<string, UserRefReadModel>();
for (const issue of input.issues) {
if (issue.assignee) people.set(issue.assignee.id, issue.assignee);
people.set(issue.reporter.id, issue.reporter);
}
// Created vs resolved, per week.
const weekKeys = weeks(from, to);
const created = new Map(weekKeys.map((week) => [week, 0]));
const resolved = new Map(weekKeys.map((week) => [week, 0]));
for (const issue of input.issues) {
const at = Date.parse(issue.createdAt);
if (inRange(at))
created.set(weekOf(at), (created.get(weekOf(at)) ?? 0) + 1);
}
// Walk every transition once: resolutions, cycle/lead time, throughput, QA.
const cycle: number[] = [];
const lead: number[] = [];
const throughput = new Map<
string,
{ week: string; user: string | null; count: number }
>();
let passed = 0;
let failed = 0;
for (const [issueId, list] of steps) {
const issue = byId.get(issueId);
for (let i = 1; i < list.length; i++) {
const previous = list[i - 1];
const step = list[i];
if (!inRange(step.at)) continue;
if (
previous.category === StatusCategory.QA &&
step.category !== StatusCategory.QA
) {
if (step.category === StatusCategory.DONE) passed++;
else if (step.category !== StatusCategory.CANCELLED) failed++;
}
if (
step.category !== StatusCategory.DONE ||
previous.category === StatusCategory.DONE
)
continue;
const week = weekOf(step.at);
resolved.set(week, (resolved.get(week) ?? 0) + 1);
if (issue) lead.push(step.at - Date.parse(issue.createdAt));
const start = list.slice(0, i).find((s) => STARTED.includes(s.category));
if (start) cycle.push(step.at - start.at);
const key = `${week}|${step.assigneeId ?? ''}`;
const row = throughput.get(key) ?? {
week,
user: step.assigneeId,
count: 0,
};
row.count++;
throughput.set(key, row);
}
}
// Cumulative flow: tickets per status at the end of each day.
const dayEnds = days(from, to);
const counts = new Map<string, number[]>();
for (const list of steps.values()) {
dayEnds.forEach((end, index) => {
const step = stepAt(list, end);
if (!step) return;
const series =
counts.get(step.status) ?? new Array(dayEnds.length).fill(0);
series[index]++;
counts.set(step.status, series);
});
}
const order = (status: string) => {
const index = input.statusOrder.indexOf(status);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
return {
from: input.from.toISOString(),
to: input.to.toISOString(),
createdVsResolved: weekKeys.map((week) => ({
week,
created: created.get(week) ?? 0,
resolved: resolved.get(week) ?? 0,
})),
cumulativeFlow: {
dates: dayEnds.map(dayOf),
series: [...counts.entries()]
.sort(([a], [b]) => order(a) - order(b))
.map(([status, values]) => ({ status, counts: values })),
},
cycleTime: durations(cycle),
leadTime: durations(lead),
throughput: [...throughput.values()]
.sort((a, b) => a.week.localeCompare(b.week) || b.count - a.count)
.map((row) => ({
week: row.week,
user: row.user
? (people.get(row.user) ?? { id: row.user, name: '?' })
: null,
count: row.count,
})),
qa: {
passed,
failed,
failureRate:
passed + failed
? Math.round((failed / (passed + failed)) * 1000) / 1000
: null,
},
};
}
export interface BurnupInput {
release: ReleaseReadModel;
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
now: Date;
}
/**
* Per day, from the release's creation until it ships (or today): tickets in
* it (scope, cancelled ones aside) and how many of those are done.
*/
export function computeReleaseBurnup(
input: BurnupInput,
): ReleaseBurnupReadModel {
const { release } = input;
const steps = stepsByIssue(input.issues, input.timeline);
const membership = new Map<
string,
{ at: number; releaseId: string | null }[]
>();
for (const entry of input.timeline) {
if (entry.kind !== 'release') continue;
const list = membership.get(entry.issueId) ?? [];
list.push({ at: Date.parse(entry.at), releaseId: entry.releaseId ?? null });
membership.set(entry.issueId, list);
}
for (const issue of input.issues) {
if (!membership.has(issue.id) && issue.release) {
membership.set(issue.id, [
{ at: Date.parse(issue.createdAt), releaseId: issue.release.id },
]);
}
membership.get(issue.id)?.sort((a, b) => a.at - b.at);
}
const end = release.releasedAt
? Date.parse(release.releasedAt)
: input.now.getTime();
// At most the last 180 days, so a long-lived release stays readable.
const start = Math.max(Date.parse(release.createdAt), end - 179 * DAY);
const dayEnds = days(start, end);
const scope: number[] = [];
const done: number[] = [];
for (const time of dayEnds) {
let inScope = 0;
let finished = 0;
for (const [issueId, list] of membership) {
const last = list.filter((m) => m.at <= time).at(-1);
if (last?.releaseId !== release.id) continue;
const step = stepAt(steps.get(issueId) ?? [], time);
if (!step || step.category === StatusCategory.CANCELLED) continue;
inScope++;
if (step.category === StatusCategory.DONE) finished++;
}
scope.push(inScope);
done.push(finished);
}
return {
releaseId: release.id,
targetDate: release.targetDate,
dates: dayEnds.map(dayOf),
scope,
done,
};
}