feat: dashboard reads the sprint, the risks, aging and bottlenecks

A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
  needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
  and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:55:52 +02:00
parent 86e313efaa
commit fe03c5bda8
41 changed files with 2774 additions and 20 deletions

View File

@@ -9,6 +9,7 @@ export * from './lib/facades/home.facade';
export * from './lib/facades/explore.facade';
export * from './lib/facades/inbox.facade';
export * from './lib/facades/epics.facade';
export * from './lib/facades/insights.facade';
export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,18 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { ProjectInsights } from '@task-stream/web/tracker/domain';
export const insightsPageEvents = eventGroup({
source: 'Dashboard Page',
events: {
opened: type<{ projectId: string }>(),
},
});
export const insightsApiEvents = eventGroup({
source: 'Insights API',
events: {
loaded: type<ProjectInsights>(),
loadFailed: type<string>(),
},
});

View File

@@ -0,0 +1,26 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { insightsPageEvents } from '../events/insights.events';
import { InsightsStore } from '../stores/insights.store';
/** "Seguimiento": sprint under way, risks, aging and bottlenecks. */
@Injectable()
export class InsightsFacade {
private readonly store = inject(InsightsStore);
private readonly dispatch = injectDispatch(insightsPageEvents);
readonly insights = this.store.insights;
/** False until the first load: show skeletons, not empty states. */
readonly loaded = this.store.loaded;
readonly error = this.store.error;
/** Loads the project's reading and keeps it up to date. */
open(projectId: string): void {
this.dispatch.opened({ projectId });
}
}
/** Store + facade of the dashboard, for its route component. */
export function provideInsightsFacade(): Provider[] {
return [InsightsStore, InsightsFacade];
}

View File

@@ -0,0 +1,74 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
ProjectInsights,
ProjectsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { debounceTime, merge, startWith, switchMap } from 'rxjs';
import {
insightsApiEvents,
insightsPageEvents,
} from '../events/insights.events';
import { describeError } from '../notifications';
interface InsightsState {
insights: ProjectInsights | null;
loaded: boolean;
error: string | null;
}
/** The dashboard's reading of a project, live. Scoped to the dashboard. */
export const InsightsStore = signalStore(
withState<InsightsState>({ insights: null, loaded: false, error: null }),
withReducer(
on(insightsPageEvents.opened, () => ({ loaded: false, error: null })),
on(insightsApiEvents.loaded, ({ payload }) => ({
insights: payload,
loaded: true,
error: null,
})),
on(insightsApiEvents.loadFailed, ({ payload }) => ({
loaded: true,
error: payload,
})),
),
withEventHandlers(
(
_store,
events = inject(Events),
projects = inject(ProjectsGateway),
realtime = inject(TrackerRealtimeGateway),
) => ({
// Any ticket or sprint may change the reading: reload, calmly.
load$: events.on(insightsPageEvents.opened).pipe(
switchMap(({ payload }) =>
merge(
realtime.issueChanges(payload.projectId),
realtime.sprintChanges(payload.projectId),
realtime.reconnects(),
).pipe(
debounceTime(800),
startWith(null),
switchMap(() =>
projects.insights(payload.projectId).pipe(
mapResponse({
next: (insights) => insightsApiEvents.loaded(insights),
error: (error) =>
insightsApiEvents.loadFailed(describeError(error)),
}),
),
),
),
),
),
}),
),
);

View File

@@ -12,3 +12,4 @@ export * from './lib/models/roadmap';
export * from './lib/models/shortcuts';
export * from './lib/models/due';
export * from './lib/models/calendar';
export * from './lib/models/insights';

View File

@@ -0,0 +1,65 @@
import { UserRef } from './models';
/** A ticket the dashboard names, with the number that put it there. */
export interface InsightIssue {
id: string;
key: string;
title: string;
/** Status id of the project's settings. */
status: string;
assignee: UserRef | null;
/** Days in its status (aging, stuck) or past / to its due date. */
days: number;
dueDate: string | null;
}
/** The sprint under way: time gone against work done. */
export interface SprintPulse {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
/** Days left after today (negative once it is over). */
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
/** At this pace, what is open would not be closed in time. */
behind: boolean;
}
/** Tickets to look at now (worst first, a few of each). */
export interface Risks {
overdue: InsightIssue[];
overdueCount: number;
dueSoon: InsightIssue[];
dueSoonCount: number;
blocked: InsightIssue[];
blockedCount: number;
stuck: InsightIssue[];
stuckCount: number;
stuckDays: number;
}
/** Time tickets spend in a status of the flow, and how many are there. */
export interface StatusTime {
status: string;
averageDays: number | null;
samples: number;
open: number;
wipLimit: number | null;
overLimit: boolean;
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
sprint: SprintPulse | null;
risks: Risks;
aging: { issues: InsightIssue[]; thresholdDays: number | null };
bottlenecks: StatusTime[];
}

View File

@@ -1,3 +1,4 @@
import { ProjectInsights } from '../models/insights';
import { Observable } from 'rxjs';
import {
Activity,
@@ -63,6 +64,8 @@ export abstract class ProjectsGateway {
abstract publicProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>;
/** Sprint under way, risks, aging and bottlenecks (the dashboard). */
abstract insights(projectId: string): Observable<ProjectInsights>;
/** Statistics of the last `days` days. */
abstract analytics(
projectId: string,

View File

@@ -5,6 +5,7 @@ import {
MemberInput,
Project,
ProjectAnalytics,
ProjectInsights,
ProjectSettings,
ProjectsGateway,
ProjectStats,
@@ -18,6 +19,7 @@ import {
DeleteProjectDocument,
MyProjectsDocument,
ProjectAnalyticsDocument,
ProjectInsightsDocument,
ProjectDocument,
ProjectStatsDocument,
PublicProjectsDocument,
@@ -61,6 +63,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats)));
}
insights(projectId: string): Observable<ProjectInsights> {
return this.gql
.query(ProjectInsightsDocument, { idOrKey: projectId })
.pipe(map((d) => d.project.insights));
}
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
return this.gql
.query(ProjectAnalyticsDocument, { idOrKey: projectId, days })

View File

@@ -1757,6 +1757,107 @@ export type DeleteProjectMutation = {
deleteProject: { id: string; key: string | null; version: number };
};
export type InsightIssueFieldsFragment = {
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
};
export type ProjectInsightsQueryVariables = Exact<{
idOrKey: string;
}>;
export type ProjectInsightsQuery = {
project: {
id: string;
insights: {
generatedAt: string;
sprint: {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
behind: boolean;
} | null;
risks: {
overdueCount: number;
dueSoonCount: number;
blockedCount: number;
stuckCount: number;
stuckDays: number;
overdue: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
dueSoon: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
blocked: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
stuck: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
};
aging: {
thresholdDays: number | null;
issues: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
};
bottlenecks: Array<{
status: string;
averageDays: number | null;
samples: number;
open: number;
wipLimit: number | null;
overLimit: boolean;
}>;
};
};
};
export type ReleasesQueryVariables = Exact<{
idOrKey: string;
}>;
@@ -3275,6 +3376,58 @@ export const NotificationFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
export const InsightIssueFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'InsightIssueFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'InsightIssue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<InsightIssueFieldsFragment, unknown>;
export const SavedFilterFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -10753,6 +10906,336 @@ export const DeleteProjectDocument = {
DeleteProjectMutation,
DeleteProjectMutationVariables
>;
export const ProjectInsightsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'ProjectInsights' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'idOrKey' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'project' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'idOrKey' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'idOrKey' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'insights' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'generatedAt' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sprintId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'startDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'totalDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'daysLeft' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'tickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'doneTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'points' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'donePoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pacePerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'neededPerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'risks' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'overdue' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overdueCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'dueSoon' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'dueSoonCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blocked' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blockedCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stuck' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stuckCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stuckDays' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'aging' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'issues' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'thresholdDays' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bottlenecks' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'status' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'averageDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'samples' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'open' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overLimit' },
},
],
},
},
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'InsightIssueFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'InsightIssue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
],
},
},
],
} as unknown as DocumentNode<
ProjectInsightsQuery,
ProjectInsightsQueryVariables
>;
export const ReleasesDocument = {
kind: 'Document',
definitions: [

View File

@@ -144,3 +144,72 @@ mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
...CommandResultFields
}
}
fragment InsightIssueFields on InsightIssue {
id
key
title
status
assignee {
...UserRefFields
}
days
dueDate
}
query ProjectInsights($idOrKey: String!) {
project(idOrKey: $idOrKey) {
id
insights {
generatedAt
sprint {
sprintId
name
startDate
endDate
totalDays
daysLeft
tickets
doneTickets
points
donePoints
pacePerDay
neededPerDay
behind
}
risks {
overdue {
...InsightIssueFields
}
overdueCount
dueSoon {
...InsightIssueFields
}
dueSoonCount
blocked {
...InsightIssueFields
}
blockedCount
stuck {
...InsightIssueFields
}
stuckCount
stuckDays
}
aging {
issues {
...InsightIssueFields
}
thresholdDays
}
bottlenecks {
status
averageDays
samples
open
wipLimit
overLimit
}
}
}
}

View File

@@ -2,23 +2,47 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
untracked,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
PiNoteComponent,
PiSkeletonComponent,
PiStatCardComponent,
} from '@pi/ui';
import {
InsightsFacade,
ProjectFacade,
provideInsightsFacade,
} from '@task-stream/web/tracker/application';
import {
ProjectRole,
QA_FAILED,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
import { AgingComponent } from '../ui/insights/aging';
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
import { RisksComponent } from '../ui/insights/risks';
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
selector: 'ts-dashboard-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent],
imports: [
RouterLink,
PiNoteComponent,
PiSkeletonComponent,
PiStatCardComponent,
SprintPulseComponent,
RisksComponent,
AgingComponent,
BottlenecksComponent,
],
providers: [provideInsightsFacade()],
template: `
@if (facade.stats(); as stats) {
<section class="ts-kpis" aria-label="Resumen">
@@ -66,6 +90,35 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
}
</section>
@if (insights.error(); as error) {
<pi-note
class="ts-insights-error"
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo leer el estado del proyecto"
[text]="error"
/>
} @else if (insights.insights(); as reading) {
<div class="ts-dashboard ts-insights">
@if (reading.sprint; as sprint) {
<ts-sprint-pulse [sprint]="sprint" />
}
<ts-risks [risks]="reading.risks" />
<ts-aging
[issues]="reading.aging.issues"
[thresholdDays]="reading.aging.thresholdDays"
/>
<ts-bottlenecks
[statuses]="reading.bottlenecks"
[workflow]="facade.workflow()"
/>
</div>
} @else {
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
}
<div class="ts-dashboard">
<section class="ts-panel" aria-labelledby="team-title">
<header class="ts-panel__head">
@@ -232,6 +285,12 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
font-weight: 600;
color: var(--ts-ink);
}
.ts-insights,
.ts-insights-error,
.ts-insights-loading {
display: grid;
margin-bottom: 1.25rem;
}
.ts-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
@@ -363,6 +422,14 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
})
export class DashboardPage {
protected readonly facade = inject(ProjectFacade);
protected readonly insights = inject(InsightsFacade);
constructor() {
effect(() => {
const projectId = this.facade.project()?.id;
if (projectId) untracked(() => this.insights.open(projectId));
});
}
protected readonly initialsOf = initials;
private count(status: string): number {

View File

@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { InsightIssue } from '@task-stream/web/tracker/domain';
/**
* Each ticket under way by the days it has been in its status, against the
* project's usual: 85% of the tickets finished lately took at most that.
*/
@Component({
selector: 'ts-aging',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: { class: 'ts-panel', 'aria-labelledby': 'aging-title', role: 'region' },
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="aging-title">Antigüedad de lo que está en curso</h2>
<span class="ts-muted">{{ legend() }}</span>
</header>
@if (issues().length) {
<ul class="ts-issues ts-aging">
@for (issue of issues(); track issue.id) {
<li [class.is-old]="isOld(issue)">
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
issue.key
}}</a>
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
issue.title
}}</a>
<span class="ts-bar" [attr.title]="issue.days + ' días'">
<span [style.width.%]="share(issue.days)"></span>
@if (threshold() !== null) {
<i
class="ts-aging__mark"
[style.left.%]="share(threshold()!)"
></i>
}
</span>
<span class="ts-days">{{ issue.days }} d</span>
</li>
}
</ul>
} @else {
<p class="ts-empty">Nada en curso ahora mismo.</p>
}
`,
styles: `
.ts-aging li {
grid-template-columns: auto minmax(0, 1fr) minmax(4rem, 7rem) 2.5rem;
align-items: center;
}
.ts-aging .ts-days {
text-align: right;
}
.ts-aging li.is-old {
--tone: var(--ts-c-red, #d14343);
}
.ts-bar {
overflow: visible;
}
.ts-aging__mark {
position: absolute;
top: -0.1875rem;
bottom: -0.1875rem;
width: 2px;
background: var(--ts-ink-soft);
}
`,
})
export class AgingComponent {
readonly issues = input.required<InsightIssue[]>();
/** p85 cycle time; none until something took at least part of a day. */
readonly thresholdDays = input<number | null>(null);
protected readonly threshold = computed(() => {
const days = this.thresholdDays();
return days && days > 0 ? days : null;
});
private readonly max = computed(() =>
Math.max(1, this.threshold() ?? 0, ...this.issues().map((i) => i.days)),
);
protected share(days: number): number {
return Math.min(100, (days / this.max()) * 100);
}
protected isOld(issue: InsightIssue): boolean {
const t = this.threshold();
return t !== null && issue.days > t;
}
protected readonly legend = computed(() => {
const t = this.threshold();
return t === null
? 'Días en su estado actual'
: `La raya: ${t.toLocaleString('es')} días, lo que tarda el 85% de los tickets`;
});
}

View File

@@ -0,0 +1,105 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { StatusTime, Workflow } from '@task-stream/web/tracker/domain';
import { statusLook } from '../copy';
/** Where tickets wait: average days in each status of the flow. */
@Component({
selector: 'ts-bottlenecks',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'bottlenecks-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="bottlenecks-title">Cuellos de botella</h2>
<span class="ts-muted">Días de media en cada estado (90 días)</span>
</header>
@if (rows().length) {
<ul class="ts-issues ts-necks">
@for (row of rows(); track row.status) {
<li [style.--tone]="row.tone" [class.is-slowest]="row.slowest">
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days">{{ row.average }}</span>
<span
class="ts-days"
[class.ts-warn]="row.overLimit"
[attr.title]="
row.wipLimit !== null
? 'Hay ' + row.open + ' con un límite de ' + row.wipLimit
: 'Hay ' + row.open + ' ahora'
"
>{{ row.open
}}{{ row.wipLimit !== null ? '/' + row.wipLimit : '' }}</span
>
</li>
}
</ul>
@if (slowest(); as slowest) {
<p class="ts-empty ts-necks__note">
Donde más se paran: <strong>{{ slowest }}</strong
>.
</p>
}
} @else {
<p class="ts-empty">El flujo no tiene estados en curso o de QA.</p>
}
`,
styles: `
.ts-necks {
gap: 0.5rem;
li {
grid-template-columns: minmax(6rem, 9rem) 1fr 3.5rem 2.5rem;
align-items: center;
}
.ts-days {
text-align: right;
}
li.is-slowest .ts-title {
font-weight: 600;
}
}
.ts-necks__note {
margin-top: 0.875rem;
}
`,
})
export class BottlenecksComponent {
readonly statuses = input.required<StatusTime[]>();
readonly workflow = input.required<Workflow>();
protected readonly rows = computed(() => {
const list = this.statuses();
const max = Math.max(1, ...list.map((s) => s.averageDays ?? 0));
const slowest = Math.max(...list.map((s) => s.averageDays ?? 0));
return list.map((s) => {
const look = statusLook(this.workflow(), s.status);
return {
...s,
label: look.label,
tone: look.tone,
share: ((s.averageDays ?? 0) / max) * 100,
average:
s.averageDays === null
? '—'
: `${s.averageDays.toLocaleString('es')} d`,
slowest: slowest > 0 && s.averageDays === slowest,
};
});
});
protected readonly slowest = computed(() => {
const row = this.rows().find((r) => r.slowest);
return row ? `${row.label} (${row.average})` : null;
});
}

View File

@@ -0,0 +1,94 @@
// Shared by the dashboard's insight panels (each host is a .ts-panel).
:host {
display: block;
padding: 1.5rem;
}
.ts-panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.ts-muted {
font-size: 0.8125rem;
}
}
.ts-issues {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: baseline;
gap: 0.5rem;
margin: 0;
font-size: 0.875rem;
}
a {
color: inherit;
text-decoration: none;
&:hover {
color: var(--ts-brand);
}
}
}
.ts-key {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-days {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-ink-soft);
white-space: nowrap;
}
.ts-bar {
position: relative;
height: 0.625rem;
border-radius: 0.25rem;
background: var(--ts-sunken);
overflow: hidden;
> span {
display: block;
height: 100%;
border-radius: 0.25rem;
background: var(--tone, var(--ts-brand));
}
}
.ts-warn {
color: var(--ts-c-red, #d14343);
}
.ts-empty {
margin: 0;
color: var(--ts-muted);
font-size: 0.875rem;
}

View File

@@ -0,0 +1,114 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { InsightIssue, Risks } from '@task-stream/web/tracker/domain';
const plural = (n: number, one: string, many: string) =>
`${n} ${n === 1 ? one : many}`;
/** Overdue, due soon, blocked and stuck: what to look at now. */
@Component({
selector: 'ts-risks',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: { class: 'ts-panel', 'aria-labelledby': 'risks-title', role: 'region' },
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="risks-title">Tickets en riesgo</h2>
<span class="ts-muted">Lo que conviene mirar hoy</span>
</header>
@for (group of groups(); track group.title) {
@if (group.count) {
<section class="ts-risk">
<h3 [class.ts-warn]="group.warn">
{{ group.title }} <span class="ts-days">{{ group.count }}</span>
</h3>
<ul class="ts-issues">
@for (issue of group.issues; track issue.id) {
<li>
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
issue.key
}}</a>
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
issue.title
}}</a>
<span class="ts-days">{{ group.days(issue) }}</span>
</li>
}
</ul>
@if (group.count > group.issues.length) {
<p class="ts-empty">
y {{ group.count - group.issues.length }} más
</p>
}
</section>
}
}
@if (!total()) {
<p class="ts-empty">Nada vencido, bloqueado ni atascado.</p>
}
`,
styles: `
.ts-risk + .ts-risk {
margin-top: 1rem;
}
h3 {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin: 0 0 0.375rem;
font-size: 0.875rem;
font-weight: 600;
}
`,
})
export class RisksComponent {
readonly risks = input.required<Risks>();
protected readonly total = computed(() => {
const r = this.risks();
return r.overdueCount + r.dueSoonCount + r.blockedCount + r.stuckCount;
});
protected readonly groups = computed(() => {
const r = this.risks();
return [
{
title: 'Vencidos',
warn: true,
issues: r.overdue,
count: r.overdueCount,
days: (i: InsightIssue) => `hace ${plural(i.days, 'día', 'días')}`,
},
{
title: 'Vencen esta semana',
warn: false,
issues: r.dueSoon,
count: r.dueSoonCount,
days: (i: InsightIssue) =>
i.days === 0 ? 'hoy' : i.days === 1 ? 'mañana' : `en ${i.days} días`,
},
{
title: 'Bloqueados',
warn: false,
issues: r.blocked,
count: r.blockedCount,
days: (i: InsightIssue) =>
i.days ? plural(i.days, 'día', 'días') : 'hoy',
},
{
title: `Atascados (${r.stuckDays} días o más sin moverse)`,
warn: false,
issues: r.stuck,
count: r.stuckCount,
days: (i: InsightIssue) =>
i.days ? plural(i.days, 'día', 'días') : 'hoy',
},
];
});
}

View File

@@ -0,0 +1,133 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { SprintPulse } from '@task-stream/web/tracker/domain';
const short = (day: string) =>
new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
const decimal = (n: number) =>
n.toLocaleString('es', { maximumFractionDigits: 1 });
/** The sprint under way: days left, points done and whether it gets there. */
@Component({
selector: 'ts-sprint-pulse',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: { class: 'ts-panel', 'aria-labelledby': 'pulse-title', role: 'region' },
styleUrl: './insight-panel.scss',
template: `
@let s = sprint();
<header class="ts-panel__head">
<h2 id="pulse-title">{{ s.name }}</h2>
<span class="ts-muted">{{ when() }}</span>
</header>
<p class="ts-pulse__days">
<strong>{{ daysText() }}</strong>
@if (s.behind) {
<span class="ts-pulse__flag">A este ritmo no llega</span>
} @else if (s.points && s.donePoints === s.points) {
<span class="ts-pulse__flag is-ok">Todo hecho</span>
}
</p>
<div class="ts-pulse__bars">
<span class="ts-pulse__label">Tiempo</span>
<span class="ts-bar"><span [style.width.%]="timeShare()"></span></span>
<span class="ts-days">{{ timeShare() }}%</span>
<span class="ts-pulse__label">Puntos</span>
<span class="ts-bar" [class.is-behind]="s.behind"
><span [style.width.%]="pointShare()"></span
></span>
<span class="ts-days">{{ s.donePoints }}/{{ s.points }}</span>
</div>
<p class="ts-empty">
{{ s.doneTickets }} de {{ s.tickets }} tickets terminados.
@if (paceText(); as pace) {
{{ pace }}
}
<a routerLink="../sprints">Ver el sprint →</a>
</p>
`,
styles: `
.ts-pulse__days {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin: 0 0 1rem;
strong {
font-size: 1.5rem;
}
}
.ts-pulse__flag {
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d14343) 14%, transparent);
color: var(--ts-c-red, #d14343);
font-size: 0.8125rem;
font-weight: 600;
&.is-ok {
background: color-mix(
in srgb,
var(--ts-c-green, #2e9e6a) 14%,
transparent
);
color: var(--ts-c-green, #2e9e6a);
}
}
.ts-pulse__bars {
display: grid;
grid-template-columns: 4rem 1fr auto;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 1rem;
}
.ts-pulse__label {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-bar.is-behind {
--tone: var(--ts-c-red, #d14343);
}
a {
color: var(--ts-brand);
text-decoration: none;
white-space: nowrap;
}
`,
})
export class SprintPulseComponent {
readonly sprint = input.required<SprintPulse>();
protected readonly when = computed(
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
);
protected readonly daysText = computed(() => {
const left = this.sprint().daysLeft;
if (left < 0)
return 'Terminó hace ' + -left + (left === -1 ? ' día' : ' días');
if (left === 0) return 'Último día';
return `Quedan ${left} ${left === 1 ? 'día' : 'días'}`;
});
protected readonly timeShare = computed(() => {
const s = this.sprint();
const gone = s.totalDays - Math.max(s.daysLeft, 0);
return Math.round((Math.min(gone, s.totalDays) / s.totalDays) * 100);
});
protected readonly pointShare = computed(() => {
const s = this.sprint();
return s.points ? Math.round((s.donePoints / s.points) * 100) : 0;
});
protected readonly paceText = computed(() => {
const s = this.sprint();
if (s.pacePerDay === null || s.neededPerDay === null) return '';
return `Se cierran ${decimal(s.pacePerDay)} pts al día; hacen falta ${decimal(s.neededPerDay)}.`;
});
}