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:
315
libs/api/tracker/application/src/lib/analytics/analytics.ts
Normal file
315
libs/api/tracker/application/src/lib/analytics/analytics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user