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)),
}),
),
),
),
),
),
}),
),
);