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:
@@ -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>(),
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user