feat: dashboard weighs quality and splits work by a list field

The insights reading adds quality (open bugs per priority, bugs created
and closed per week for 8 weeks, QA returns and passes per area over 90
days, bugs reported in the 14 days after each of the last three
releases) and, for each list custom field, open tickets per option. The
dashboard shows both; the field to split by can be picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:06:11 +02:00
parent 0d158a2ed7
commit 76312c079d
22 changed files with 1264 additions and 74 deletions

View File

@@ -1,5 +1,7 @@
import {
CustomFieldType,
defaultProjectSettings,
IssuePriority,
ReleaseReadModel,
ReleaseStatus,
IssueLinkType,
@@ -302,4 +304,92 @@ describe('computeInsights', () => {
staleDays: STALE_DAYS,
});
});
it('weighs quality and splits open tickets by a list field', () => {
const settings = defaultProjectSettings();
settings.customFields = [
{
id: 'BROWSER',
name: 'Navegador',
type: CustomFieldType.SELECT,
options: ['Chrome', 'Safari'],
archived: false,
},
];
const issues = [
issue('B1', 'TODO', C.TODO, {
type: 'BUG',
priority: IssuePriority.HIGH,
createdAt: '2026-10-14T09:00:00Z',
customFields: { BROWSER: 'Safari' },
}),
issue('B2', 'DONE', C.DONE, {
type: 'BUG',
area: 'FRONTEND',
createdAt: '2026-10-02T09:00:00Z',
}),
issue('T1', 'IN_PROGRESS', C.IN_PROGRESS, {
type: 'TASK',
area: 'FRONTEND',
customFields: { BROWSER: 'Safari' },
}),
];
const timeline = [
step('B2', '2026-10-05T09:00:00Z', 'IN_QA', C.QA),
step('B2', '2026-10-06T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
step('B2', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
step('B2', '2026-10-08T09:00:00Z', 'DONE', C.DONE),
];
const { quality, fields } = computeInsights({
...BASE,
settings,
issues,
timeline,
releases: [
{
id: 'r',
name: '1.0',
status: ReleaseStatus.RELEASED,
releasedAt: '2026-10-01T00:00:00Z',
} as ReleaseReadModel,
],
});
expect(quality.openBugsByPriority[1]).toEqual({
priority: IssuePriority.HIGH,
count: 1,
});
expect(quality.bugWeeks).toHaveLength(8);
expect(quality.bugWeeks.at(-1)).toEqual({
week: '2026-10-12',
created: 1,
closed: 0,
});
expect(quality.bugWeeks.at(-2)).toEqual({
week: '2026-10-05',
created: 0,
closed: 1,
});
expect(quality.qaByArea).toEqual([
{ area: 'FRONTEND', returned: 1, passed: 1 },
]);
// B2 came 1 day after 1.0; B1 13 days after.
expect(quality.escaped).toEqual([
{
releaseId: 'r',
name: '1.0',
releasedAt: '2026-10-01T00:00:00Z',
bugs: 2,
},
]);
expect(fields).toEqual([
{
fieldId: 'BROWSER',
counts: [
{ value: 'Safari', count: 2 },
{ value: 'Chrome', count: 0 },
],
none: 0,
},
]);
});
});

View File

@@ -1,5 +1,9 @@
import {
BacklogHealthReadModel,
CustomFieldType,
FieldBreakdownReadModel,
IssuePriority,
QualityReadModel,
ChangesSinceReadModel,
InsightIssueReadModel,
IssueLinkType,
@@ -18,7 +22,12 @@ import {
TeamLoadReadModel,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
import {
computeProjectAnalytics,
Step,
stepsByIssue,
weekOf,
} from './analytics';
/*
* The dashboard's reading of a project: what needs attention now (risks),
@@ -169,9 +178,124 @@ export function computeInsights(
new Set(blocked.map((b) => b.id)),
),
backlog: backlogHealth(open, now),
quality: quality(input.settings, live, steps, input.releases, now),
fields: fieldBreakdown(input.settings, open),
};
}
const PRIORITIES = [
IssuePriority.CRITICAL,
IssuePriority.HIGH,
IssuePriority.MEDIUM,
IssuePriority.LOW,
IssuePriority.LOWEST,
];
/** Bugs are the tickets whose type is drawn with the bug icon. */
function quality(
settings: ProjectSettings,
issues: IssueReadModel[],
steps: Map<string, Step[]>,
releases: ReleaseReadModel[],
now: number,
): QualityReadModel {
const bugTypes = new Set(
settings.issueTypes.filter((t) => t.icon === 'bug').map((t) => t.id),
);
const bugs = issues.filter((i) => bugTypes.has(i.type));
const openBugs = bugs.filter((i) => !CLOSED.includes(i.statusCategory));
// Created and closed per week, the last 8.
const thisWeek = Date.parse(weekOf(now));
const weeks = Array.from({ length: 8 }, (_, n) =>
new Date(thisWeek - (7 - n) * 7 * DAY).toISOString().slice(0, 10),
);
const tally = new Map(weeks.map((w) => [w, { created: 0, closed: 0 }]));
for (const bug of bugs) {
const created = tally.get(weekOf(Date.parse(bug.createdAt)));
if (created) created.created++;
const list = steps.get(bug.id) ?? [];
list.forEach((step, i) => {
if (i === 0 || !CLOSED.includes(step.category)) return;
if (CLOSED.includes(list[i - 1].category)) return;
const week = tally.get(weekOf(step.at));
if (week) week.closed++;
});
}
// QA's verdicts per area, the last 90 days.
const from = now - 90 * DAY;
const byArea = new Map<string | null, { returned: number; passed: number }>();
for (const issue of issues) {
const list = steps.get(issue.id) ?? [];
list.forEach((step, i) => {
const before = list[i - 1];
if (!before || step.at < from) return;
if (before.category !== StatusCategory.QA) return;
if (step.category === StatusCategory.QA) return;
if (step.category === StatusCategory.CANCELLED) return;
const row = byArea.get(issue.area) ?? { returned: 0, passed: 0 };
if (step.category === StatusCategory.DONE) row.passed++;
else row.returned++;
byArea.set(issue.area, row);
});
}
// Bugs reported in the 14 days after each of the last shipped releases.
const escaped = releases
.filter((r) => r.status === ReleaseStatus.RELEASED && r.releasedAt)
.sort((a, b) =>
(b.releasedAt as string).localeCompare(a.releasedAt as string),
)
.slice(0, 3)
.map((r) => {
const shipped = Date.parse(r.releasedAt as string);
return {
releaseId: r.id,
name: r.name,
releasedAt: r.releasedAt as string,
bugs: bugs.filter((b) => {
const at = Date.parse(b.createdAt);
return at > shipped && at <= shipped + 14 * DAY;
}).length,
};
});
return {
openBugsByPriority: PRIORITIES.map((priority) => ({
priority,
count: openBugs.filter((b) => b.priority === priority).length,
})),
bugWeeks: weeks.map((week) => ({ week, ...tally.get(week)! })),
qaByArea: [...byArea.entries()]
.map(([area, row]) => ({ area, ...row }))
.sort((a, b) => b.returned - a.returned || b.passed - a.passed),
escaped,
};
}
/** Open tickets per option of each active list field. */
function fieldBreakdown(
settings: ProjectSettings,
open: IssueReadModel[],
): FieldBreakdownReadModel[] {
return (settings.customFields ?? [])
.filter((f) => !f.archived && f.type === CustomFieldType.SELECT)
.map((field) => {
const values = open.map((i) => i.customFields?.[field.id] ?? null);
return {
fieldId: field.id,
counts: field.options
.map((value) => ({
value,
count: values.filter((v) => v === value).length,
}))
.sort((a, b) => b.count - a.count),
none: values.filter((v) => v === null).length,
};
});
}
/** Tickets that reached a DONE status per day, over the last PACE_DAYS days. */
function dailyThroughput(steps: Map<string, Step[]>, now: number): number[] {
const today = utcDay(now);

View File

@@ -128,6 +128,48 @@ export class BacklogHealthReadModel {
staleDays!: number;
}
/** Bugs created and closed in one week (Monday, UTC). */
export class BugWeekReadModel {
week!: string;
created!: number;
closed!: number;
}
/** What QA sent back, and let through, per area (last 90 days). */
export class QaByAreaReadModel {
/** Area id; null: tickets with no area. */
area!: string | null;
returned!: number;
passed!: number;
}
/** Bugs reported in the 14 days after a release shipped. */
export class EscapedBugsReadModel {
releaseId!: string;
name!: string;
releasedAt!: string;
bugs!: number;
}
/** Bugs (types drawn with the bug icon) and QA. */
export class QualityReadModel {
/** Open bugs per priority (all five, most urgent first). */
openBugsByPriority!: { priority: string; count: number }[];
/** The last 8 weeks, oldest first. */
bugWeeks!: BugWeekReadModel[];
qaByArea!: QaByAreaReadModel[];
/** The last three shipped releases, newest first. */
escaped!: EscapedBugsReadModel[];
}
/** Open tickets per option of a list custom field. */
export class FieldBreakdownReadModel {
fieldId!: string;
counts!: { value: string; count: number }[];
/** Open tickets with no value in it. */
none!: number;
}
/** What the project dashboard reads beyond the counts. */
export class ProjectInsightsReadModel {
generatedAt!: string;
@@ -143,4 +185,7 @@ export class ProjectInsightsReadModel {
/** Members (and others with open tickets), busiest first. */
team!: TeamLoadReadModel[];
backlog!: BacklogHealthReadModel;
quality!: QualityReadModel;
/** One per active list (SELECT) custom field. */
fields!: FieldBreakdownReadModel[];
}

View File

@@ -928,6 +928,64 @@ export class BacklogHealthGql {
@Field(() => Int) staleDays!: number;
}
@ObjectType('PriorityCount')
export class PriorityCountGql {
@Field(() => IssuePriority) priority!: IssuePriority;
@Field(() => Int) count!: number;
}
@ObjectType('BugWeek', { description: 'Bugs created and closed in a week' })
export class BugWeekGql {
@Field({ description: 'Monday (UTC), YYYY-MM-DD' }) week!: string;
@Field(() => Int) created!: number;
@Field(() => Int) closed!: number;
}
@ObjectType('QaByArea', { description: 'QA verdicts per area (90 days)' })
export class QaByAreaGql {
@Field(() => String, { nullable: true, description: 'Null: no area' })
area!: string | null;
@Field(() => Int) returned!: number;
@Field(() => Int) passed!: number;
}
@ObjectType('EscapedBugs', {
description: 'Bugs reported in the 14 days after a release shipped',
})
export class EscapedBugsGql {
@Field(() => ID) releaseId!: string;
@Field() name!: string;
@Field() releasedAt!: string;
@Field(() => Int) bugs!: number;
}
@ObjectType('Quality', {
description: 'Bugs (types with the bug icon) and QA',
})
export class QualityGql {
@Field(() => [PriorityCountGql]) openBugsByPriority!: PriorityCountGql[];
@Field(() => [BugWeekGql], { description: 'Last 8 weeks, oldest first' })
bugWeeks!: BugWeekGql[];
@Field(() => [QaByAreaGql]) qaByArea!: QaByAreaGql[];
@Field(() => [EscapedBugsGql]) escaped!: EscapedBugsGql[];
}
@ObjectType('OptionCount')
export class OptionCountGql {
@Field() value!: string;
@Field(() => Int) count!: number;
}
@ObjectType('FieldBreakdown', {
description: 'Open tickets per option of a list custom field',
})
export class FieldBreakdownGql {
@Field() fieldId!: string;
@Field(() => [OptionCountGql]) counts!: OptionCountGql[];
@Field(() => Int, { description: 'Open tickets with no value' })
none!: number;
}
@ObjectType('ProjectInsights', {
description:
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
@@ -947,4 +1005,9 @@ export class ProjectInsightsGql {
changes!: ChangesSinceGql | null;
@Field(() => [TeamLoadGql]) team!: TeamLoadGql[];
@Field(() => BacklogHealthGql) backlog!: BacklogHealthGql;
@Field(() => QualityGql) quality!: QualityGql;
@Field(() => [FieldBreakdownGql], {
description: 'One per active list custom field',
})
fields!: FieldBreakdownGql[];
}

View File

@@ -1006,6 +1006,56 @@ export class BacklogHealthDto {
@ApiProperty() staleDays!: number;
}
export class PriorityCountDto {
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
priority!: IssuePriority;
@ApiProperty() count!: number;
}
export class BugWeekDto {
@ApiProperty({ format: 'date', description: 'Monday (UTC)' }) week!: string;
@ApiProperty() created!: number;
@ApiProperty() closed!: number;
}
export class QaByAreaDto {
@ApiProperty({ type: String, nullable: true, description: 'Null: no area' })
area!: string | null;
@ApiProperty() returned!: number;
@ApiProperty() passed!: number;
}
export class EscapedBugsDto {
@ApiProperty({ format: 'uuid' }) releaseId!: string;
@ApiProperty() name!: string;
@ApiProperty() releasedAt!: string;
@ApiProperty({ description: 'Bugs reported in the 14 days after' })
bugs!: number;
}
export class QualityDto {
@ApiProperty({ type: [PriorityCountDto] })
openBugsByPriority!: PriorityCountDto[];
@ApiProperty({
type: [BugWeekDto],
description: 'Last 8 weeks, oldest first',
})
bugWeeks!: BugWeekDto[];
@ApiProperty({ type: [QaByAreaDto] }) qaByArea!: QaByAreaDto[];
@ApiProperty({ type: [EscapedBugsDto] }) escaped!: EscapedBugsDto[];
}
export class OptionCountDto {
@ApiProperty() value!: string;
@ApiProperty() count!: number;
}
export class FieldBreakdownDto {
@ApiProperty() fieldId!: string;
@ApiProperty({ type: [OptionCountDto] }) counts!: OptionCountDto[];
@ApiProperty({ description: 'Open tickets with no value' }) none!: number;
}
export class ProjectInsightsDto {
@ApiProperty() generatedAt!: string;
@ApiProperty({ type: SprintPulseDto, nullable: true })
@@ -1022,4 +1072,10 @@ export class ProjectInsightsDto {
changes!: ChangesSinceDto | null;
@ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[];
@ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto;
@ApiProperty({ type: QualityDto }) quality!: QualityDto;
@ApiProperty({
type: [FieldBreakdownDto],
description: 'One per active list custom field',
})
fields!: FieldBreakdownDto[];
}

View File

@@ -1,3 +1,4 @@
import { IssuePriority } from './enums';
import { UserRef } from './models';
/** A ticket the dashboard names, with the number that put it there. */
@@ -102,6 +103,29 @@ export interface BacklogHealth {
staleDays: number;
}
/** Bugs (types drawn with the bug icon) and QA. */
export interface Quality {
openBugsByPriority: { priority: IssuePriority; count: number }[];
/** The last 8 weeks (Monday), oldest first. */
bugWeeks: { week: string; created: number; closed: number }[];
/** QA's verdicts per area, the last 90 days (area null: none). */
qaByArea: { area: string | null; returned: number; passed: number }[];
/** Bugs reported in the 14 days after each of the last releases. */
escaped: {
releaseId: string;
name: string;
releasedAt: string;
bugs: number;
}[];
}
/** Open tickets per option of a list custom field. */
export interface FieldBreakdown {
fieldId: string;
counts: { value: string; count: number }[];
none: number;
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
@@ -114,4 +138,6 @@ export interface ProjectInsights {
changes: ChangesSince | null;
team: TeamLoad[];
backlog: BacklogHealth;
quality: Quality;
fields: FieldBreakdown[];
}

View File

@@ -1921,6 +1921,26 @@ export type ProjectInsightsQuery = {
stale: number;
staleDays: number;
};
quality: {
openBugsByPriority: Array<{ priority: IssuePriority; count: number }>;
bugWeeks: Array<{ week: string; created: number; closed: number }>;
qaByArea: Array<{
area: string | null;
returned: number;
passed: number;
}>;
escaped: Array<{
releaseId: string;
name: string;
releasedAt: string;
bugs: number;
}>;
};
fields: Array<{
fieldId: string;
none: number;
counts: Array<{ value: string; count: number }>;
}>;
};
};
};
@@ -11456,6 +11476,136 @@ export const ProjectInsightsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'quality' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'openBugsByPriority',
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'priority' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugWeeks' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'week' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'created' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'closed' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'qaByArea' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'area' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'returned' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'passed' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'escaped' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releasedAt' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugs' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'fields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'fieldId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'counts' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'value' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'none' },
},
],
},
},
],
},
},

View File

@@ -254,6 +254,36 @@ query ProjectInsights($idOrKey: String!, $since: String) {
stale
staleDays
}
quality {
openBugsByPriority {
priority
count
}
bugWeeks {
week
created
closed
}
qaByArea {
area
returned
passed
}
escaped {
releaseId
name
releasedAt
bugs
}
}
fields {
fieldId
counts {
value
count
}
none
}
}
}
}

View File

@@ -32,6 +32,8 @@ import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
import { ChangesSinceComponent } from '../ui/insights/changes-since';
import { BacklogHealthComponent } from '../ui/insights/backlog-health';
import { takeLastVisit } from '../ui/last-visit';
import { QualityComponent } from '../ui/insights/quality';
import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
@@ -49,6 +51,8 @@ import { takeLastVisit } from '../ui/last-visit';
ReleaseForecastComponent,
ChangesSinceComponent,
BacklogHealthComponent,
QualityComponent,
FieldBreakdownComponent,
],
providers: [provideInsightsFacade()],
template: `
@@ -129,6 +133,16 @@ import { takeLastVisit } from '../ui/last-visit';
[workflow]="facade.workflow()"
/>
<ts-backlog-health [health]="reading.backlog" />
<ts-quality
[quality]="reading.quality"
[workflow]="facade.workflow()"
/>
@if (reading.fields.length) {
<ts-field-breakdown
[fields]="reading.fields"
[workflow]="facade.workflow()"
/>
}
</div>
} @else {
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />

View File

@@ -0,0 +1,112 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
linkedSignal,
} from '@angular/core';
import { PiSelectFieldComponent, type PiSelectFieldOption } from '@pi/ui';
import { FieldBreakdown, Workflow } from '@task-stream/web/tracker/domain';
/** Open tickets per option of a list custom field («bugs por navegador»). */
@Component({
selector: 'ts-field-breakdown',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiSelectFieldComponent],
host: {
class: 'ts-panel',
'aria-labelledby': 'breakdown-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="breakdown-title">Abiertos por {{ name() }}</h2>
@if (options().length > 1) {
<pi-select-field
class="ts-breakdown__pick"
[options]="options()"
[(value)]="chosen"
/>
}
</header>
@if (current(); as field) {
<ul class="ts-issues ts-breakdown">
@for (row of rows(); track row.value) {
<li>
<span class="ts-title">{{ row.value }}</span>
<span class="ts-bar"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days">{{ row.count }}</span>
</li>
}
<li class="ts-muted">
<span class="ts-title">Sin valor</span>
<span class="ts-bar"
><span [style.width.%]="noneShare()"></span
></span>
<span class="ts-days">{{ field.none }}</span>
</li>
</ul>
}
`,
styles: `
.ts-breakdown li {
grid-template-columns: minmax(6rem, 10rem) 1fr 2.5rem;
align-items: center;
}
.ts-breakdown .ts-days {
text-align: right;
}
.ts-breakdown__pick {
min-width: 10rem;
}
`,
})
export class FieldBreakdownComponent {
readonly fields = input.required<FieldBreakdown[]>();
readonly workflow = input.required<Workflow>();
protected readonly options = computed<PiSelectFieldOption<string>[]>(() =>
this.fields().map((f) => ({
value: f.fieldId,
label: this.nameOf(f.fieldId),
})),
);
/** The field shown; the first one until another is picked. */
protected readonly chosen = linkedSignal<string | null>(
() => this.fields()[0]?.fieldId ?? null,
);
protected readonly current = computed(
() =>
this.fields().find((f) => f.fieldId === this.chosen()) ??
this.fields()[0] ??
null,
);
protected readonly name = computed(() => {
const field = this.current();
return field ? this.nameOf(field.fieldId).toLocaleLowerCase('es') : '';
});
private readonly max = computed(() => {
const field = this.current();
return Math.max(
1,
field?.none ?? 0,
...(field?.counts.map((c) => c.count) ?? []),
);
});
protected readonly rows = computed(() =>
(this.current()?.counts ?? []).map((c) => ({
...c,
share: (c.count / this.max()) * 100,
})),
);
protected readonly noneShare = computed(
() => ((this.current()?.none ?? 0) / this.max()) * 100,
);
private nameOf(fieldId: string): string {
return this.workflow().customField(fieldId)?.name ?? fieldId;
}
}

View File

@@ -0,0 +1,202 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Quality, Workflow } from '@task-stream/web/tracker/domain';
import { PRIORITY } from '../copy';
const short = (day: string) =>
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
/** Open bugs, how they come and go, what QA sends back and what escapes. */
@Component({
selector: 'ts-quality',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'quality-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="quality-title">Calidad</h2>
<span class="ts-muted">Bugs: los tipos con icono de bicho</span>
</header>
<h3>Bugs abiertos ({{ openBugs() }})</h3>
<ul class="ts-issues ts-quality__rows">
@for (row of priorities(); track row.priority) {
<li>
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar"><span [style.width.%]="row.share"></span></span>
<span class="ts-days">{{ row.count }}</span>
</li>
}
</ul>
<h3>Últimas 8 semanas</h3>
<div class="ts-quality__weeks" role="img" [attr.aria-label]="weeksLabel()">
@for (week of weeks(); track week.week) {
<span class="ts-quality__week" [attr.title]="week.title">
<i class="is-created" [style.height.%]="week.createdShare"></i>
<i class="is-closed" [style.height.%]="week.closedShare"></i>
</span>
}
</div>
<p class="ts-quality__legend">
<span class="is-created">Nuevos</span>
<span class="is-closed">Cerrados</span>
</p>
@if (areas().length) {
<h3>QA por área (90 días)</h3>
<ul class="ts-issues ts-quality__rows">
@for (row of areas(); track row.label) {
<li>
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar ts-bar--warn"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days"
>{{ row.returned }}/{{ row.returned + row.passed }} vueltos</span
>
</li>
}
</ul>
}
@if (quality().escaped.length) {
<h3>Bugs en los 14 días tras cada release</h3>
<ul class="ts-issues">
@for (row of quality().escaped; track row.releaseId) {
<li>
<span class="ts-key">{{ row.name }}</span>
<span class="ts-title ts-muted">{{ shipped(row.releasedAt) }}</span>
<span class="ts-days" [class.ts-warn]="row.bugs > 0">{{
row.bugs
}}</span>
</li>
}
</ul>
}
`,
styles: `
h3 {
margin: 1rem 0 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
&:first-of-type {
margin-top: 0;
}
}
.ts-quality__rows li {
grid-template-columns: minmax(6rem, 9rem) 1fr 5.5rem;
align-items: center;
}
.ts-quality__rows .ts-days {
text-align: right;
}
.ts-bar--warn {
--tone: var(--ts-c-red, #d14343);
}
.ts-quality__weeks {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5rem;
height: 4rem;
}
.ts-quality__week {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
border-bottom: 1px solid var(--ts-line);
i {
width: 40%;
min-height: 1px;
border-radius: 2px 2px 0 0;
}
}
.is-created {
--tone: var(--ts-c-red, #d14343);
}
.is-closed {
--tone: var(--ts-c-green, #2e9e6a);
}
.ts-quality__week i {
background: var(--tone);
}
.ts-quality__legend {
display: flex;
gap: 1rem;
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.75rem;
span::before {
content: '';
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-right: 0.3rem;
border-radius: 2px;
background: var(--tone);
}
}
`,
})
export class QualityComponent {
readonly quality = input.required<Quality>();
readonly workflow = input.required<Workflow>();
protected readonly openBugs = computed(() =>
this.quality().openBugsByPriority.reduce((sum, r) => sum + r.count, 0),
);
protected readonly priorities = computed(() => {
const max = Math.max(
1,
...this.quality().openBugsByPriority.map((r) => r.count),
);
return this.quality().openBugsByPriority.map((r) => ({
...r,
label: PRIORITY[r.priority].label,
share: (r.count / max) * 100,
}));
});
protected readonly weeks = computed(() => {
const list = this.quality().bugWeeks;
const max = Math.max(1, ...list.flatMap((w) => [w.created, w.closed]));
return list.map((w) => ({
...w,
createdShare: (w.created / max) * 100,
closedShare: (w.closed / max) * 100,
title: `Semana del ${short(w.week)}: ${w.created} nuevos, ${w.closed} cerrados`,
}));
});
protected readonly weeksLabel = computed(() =>
this.weeks()
.map((w) => w.title)
.join('. '),
);
protected readonly areas = computed(() => {
const rows = this.quality().qaByArea;
return rows.map((r) => ({
...r,
label: r.area ? this.workflow().area(r.area).name : 'Sin área',
share:
r.returned + r.passed
? (r.returned / (r.returned + r.passed)) * 100
: 0,
}));
});
protected shipped(day: string): string {
return `publicada el ${short(day)}`;
}
}