feat: dashboard forecasts releases, tells what changed and weighs the load

The insights reading grows (GraphQL insights(since:), REST ?since=, MCP):
- planned releases with a likely (p50) and safe (p85) finish date from a
  Monte Carlo over the last six weeks of finished work, one after another,
  and a verdict against their target;
- with `since` (the viewer's last visit, kept in the browser): tickets
  created, finished and sent back from QA, and their mentions;
- each person's open points against what they closed per sprint lately;
- open tickets without estimate, area or assignee, or untouched for 90
  days, each count opening the list filtered (new noEstimate, noArea and
  updatedBefore filters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:50:24 +02:00
parent fe03c5bda8
commit 0d158a2ed7
41 changed files with 2050 additions and 118 deletions

View File

@@ -5,7 +5,8 @@ import { ProjectInsights } from '@task-stream/web/tracker/domain';
export const insightsPageEvents = eventGroup({
source: 'Dashboard Page',
events: {
opened: type<{ projectId: string }>(),
/** `since`: my last visit, to tell what changed after it. */
opened: type<{ projectId: string; since: string | null }>(),
},
});

View File

@@ -14,9 +14,12 @@ export class InsightsFacade {
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 });
/**
* Loads the project's reading and keeps it up to date; with `since` (my
* last visit), it also tells what changed after it.
*/
open(projectId: string, since: string | null = null): void {
this.dispatch.opened({ projectId, since });
}
}

View File

@@ -58,7 +58,7 @@ export const InsightsStore = signalStore(
debounceTime(800),
startWith(null),
switchMap(() =>
projects.insights(payload.projectId).pipe(
projects.insights(payload.projectId, payload.since).pipe(
mapResponse({
next: (insights) => insightsApiEvents.loaded(insights),
error: (error) =>

View File

@@ -103,6 +103,11 @@ export interface IssueListFilter {
unassigned?: boolean;
releaseId?: string;
sprintId?: string;
/** Only tickets nobody estimated / with no area. */
noEstimate?: boolean;
noArea?: boolean;
/** Only tickets not updated since this instant (ISO). */
updatedBefore?: string;
/** Only tickets holding these custom values. */
customFields?: CustomFieldValue[];
limit: number;

View File

@@ -55,6 +55,53 @@ export interface StatusTime {
overLimit: boolean;
}
/** When a planned release would be finished, at the team's real pace. */
export interface ReleaseForecast {
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
/** Half of the simulated futures finish by then. */
likelyDate: string | null;
/** 85% of them do. */
safeDate: string | null;
verdict: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE';
}
/** What happened after a moment (my last visit). */
export interface ChangesSince {
since: string;
created: InsightIssue[];
createdCount: number;
finished: InsightIssue[];
finishedCount: number;
/** Sent back from QA. */
returned: InsightIssue[];
returnedCount: number;
mentions: number;
}
/** One person's open work against what they usually close per sprint. */
export interface TeamLoad {
user: UserRef;
openTickets: number;
openPoints: number;
blocked: number;
pointsPerSprint: number | null;
overloaded: boolean;
}
/** Open tickets missing what planning needs. */
export interface BacklogHealth {
open: number;
unestimated: number;
noArea: number;
unassigned: number;
stale: number;
staleDays: number;
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
@@ -62,4 +109,9 @@ export interface ProjectInsights {
risks: Risks;
aging: { issues: InsightIssue[]; thresholdDays: number | null };
bottlenecks: StatusTime[];
releases: ReleaseForecast[];
/** Only when asked with a `since`. */
changes: ChangesSince | null;
team: TeamLoad[];
backlog: BacklogHealth;
}

View File

@@ -64,8 +64,11 @@ 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>;
/** The dashboard; with `since`, also what changed after it. */
abstract insights(
projectId: string,
since: string | null,
): Observable<ProjectInsights>;
/** Statistics of the last `days` days. */
abstract analytics(
projectId: string,

View File

@@ -63,10 +63,13 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats)));
}
insights(projectId: string): Observable<ProjectInsights> {
insights(
projectId: string,
since: string | null,
): Observable<ProjectInsights> {
return this.gql
.query(ProjectInsightsDocument, { idOrKey: projectId })
.pipe(map((d) => d.project.insights));
.query(ProjectInsightsDocument, { idOrKey: projectId, since })
.pipe(map((d) => d.project.insights as ProjectInsights));
}
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {

View File

@@ -178,6 +178,10 @@ export type IssueFilterInput = {
/** Due on or before this day (YYYY-MM-DD) */
dueTo?: string | null | undefined;
labels?: Array<string> | null | undefined;
/** Only tickets with no area */
noArea?: boolean | null | undefined;
/** Only tickets nobody estimated */
noEstimate?: boolean | null | undefined;
/** Only tickets in no sprint */
noSprint?: boolean | null | undefined;
/** Only the tickets hanging from this one */
@@ -192,6 +196,8 @@ export type IssueFilterInput = {
statuses?: Array<string> | null | undefined;
types?: Array<string> | null | undefined;
unassigned?: boolean | null | undefined;
/** Only tickets not updated since this instant (ISO) */
updatedBefore?: string | null | undefined;
/** Only the tickets I follow (none when anonymous) */
watching?: boolean | null | undefined;
};
@@ -1769,6 +1775,7 @@ export type InsightIssueFieldsFragment = {
export type ProjectInsightsQueryVariables = Exact<{
idOrKey: string;
since?: string | null | undefined;
}>;
export type ProjectInsightsQuery = {
@@ -1854,6 +1861,66 @@ export type ProjectInsightsQuery = {
wipLimit: number | null;
overLimit: boolean;
}>;
releases: Array<{
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
likelyDate: string | null;
safeDate: string | null;
verdict: string;
}>;
changes: {
since: string;
createdCount: number;
finishedCount: number;
returnedCount: number;
mentions: number;
created: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
finished: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
returned: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
} | null;
team: Array<{
openTickets: number;
openPoints: number;
blocked: number;
pointsPerSprint: number | null;
overloaded: boolean;
user: { id: string; name: string };
}>;
backlog: {
open: number;
unestimated: number;
noArea: number;
unassigned: number;
stale: number;
staleDays: number;
};
};
};
};
@@ -10928,6 +10995,14 @@ export const ProjectInsightsDocument = {
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'since' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
@@ -10952,6 +11027,16 @@ export const ProjectInsightsDocument = {
{
kind: 'Field',
name: { kind: 'Name', value: 'insights' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'since' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'since' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
@@ -11175,6 +11260,202 @@ export const ProjectInsightsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releases' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'targetDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'total' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'likelyDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'safeDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'verdict' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'changes' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'since' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'created' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'createdCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finishedCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'returned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'returnedCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'mentions' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'team' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'user' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'UserRefFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'openTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'openPoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blocked' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pointsPerSprint' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overloaded' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'backlog' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'open' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'unestimated' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'noArea' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'unassigned' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stale' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'staleDays' },
},
],
},
},
],
},
},

View File

@@ -157,10 +157,10 @@ fragment InsightIssueFields on InsightIssue {
dueDate
}
query ProjectInsights($idOrKey: String!) {
query ProjectInsights($idOrKey: String!, $since: String) {
project(idOrKey: $idOrKey) {
id
insights {
insights(since: $since) {
generatedAt
sprint {
sprintId
@@ -210,6 +210,50 @@ query ProjectInsights($idOrKey: String!) {
wipLimit
overLimit
}
releases {
releaseId
name
targetDate
total
finished
likelyDate
safeDate
verdict
}
changes {
since
created {
...InsightIssueFields
}
createdCount
finished {
...InsightIssueFields
}
finishedCount
returned {
...InsightIssueFields
}
returnedCount
mentions
}
team {
user {
...UserRefFields
}
openTickets
openPoints
blocked
pointsPerSprint
overloaded
}
backlog {
open
unestimated
noArea
unassigned
stale
staleDays
}
}
}
}

View File

@@ -21,12 +21,17 @@ import {
ProjectRole,
QA_FAILED,
StatusCategory,
TeamLoad,
} 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';
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';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
@@ -41,6 +46,9 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
RisksComponent,
AgingComponent,
BottlenecksComponent,
ReleaseForecastComponent,
ChangesSinceComponent,
BacklogHealthComponent,
],
providers: [provideInsightsFacade()],
template: `
@@ -102,10 +110,16 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
/>
} @else if (insights.insights(); as reading) {
<div class="ts-dashboard ts-insights">
@if (reading.changes; as changes) {
<ts-changes-since [changes]="changes" />
}
@if (reading.sprint; as sprint) {
<ts-sprint-pulse [sprint]="sprint" />
}
<ts-risks [risks]="reading.risks" />
@if (reading.releases.length) {
<ts-release-forecast [releases]="reading.releases" />
}
<ts-aging
[issues]="reading.aging.issues"
[thresholdDays]="reading.aging.thresholdDays"
@@ -114,6 +128,7 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
[statuses]="reading.bottlenecks"
[workflow]="facade.workflow()"
/>
<ts-backlog-health [health]="reading.backlog" />
</div>
} @else {
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
@@ -155,6 +170,12 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
} @else {
<span class="ts-pill">Ya no es miembro</span>
}
@if (loadOf().get(person.id); as load) {
@if (load.overloaded) {
<span class="ts-pill ts-pill--warn">Sobrecarga</span>
}
<span class="ts-team__load">{{ loadText(load) }}</span>
}
</span>
<span class="ts-bar" aria-hidden="true"
><span
@@ -382,6 +403,14 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
gap: 0.25rem 0.5rem;
min-width: 0;
}
.ts-team__load {
flex-basis: 100%;
color: var(--ts-muted);
font-size: 0.75rem;
}
.ts-pill--warn {
--tone: var(--ts-c-red, #d14343);
}
.ts-team__go {
color: var(--ts-brand);
opacity: 0;
@@ -424,10 +453,15 @@ export class DashboardPage {
protected readonly facade = inject(ProjectFacade);
protected readonly insights = inject(InsightsFacade);
/** Opened once per project: the project itself changes on every update. */
private openedFor: string | null = null;
constructor() {
effect(() => {
const projectId = this.facade.project()?.id;
if (projectId) untracked(() => this.insights.open(projectId));
if (!projectId || projectId === this.openedFor) return;
this.openedFor = projectId;
untracked(() => this.insights.open(projectId, takeLastVisit(projectId)));
});
}
protected readonly initialsOf = initials;
@@ -557,6 +591,25 @@ export class DashboardPage {
(a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'),
);
});
/** Open points and usual pace per person (from the insights). */
protected readonly loadOf = computed(
() =>
new Map(
(this.insights.insights()?.team ?? []).map((t) => [t.user.id, t]),
),
);
protected loadText(load: TeamLoad): string {
const parts = [`${load.openPoints} pts abiertos`];
if (load.pointsPerSprint !== null)
parts.push(
`cierra ${load.pointsPerSprint.toLocaleString('es')} por sprint`,
);
if (load.blocked)
parts.push(
`${load.blocked} ${load.blocked === 1 ? 'bloqueado' : 'bloqueados'}`,
);
return parts.join(' · ');
}
protected readonly unassigned = computed(
() => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0,
);

View File

@@ -5,6 +5,7 @@ import {
effect,
inject,
input,
linkedSignal,
signal,
untracked,
} from '@angular/core';
@@ -130,6 +131,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
</span>
}
</div>
@if (onlyLabel(); as only) {
<p class="ts-list__only">
Solo {{ only }}
<button type="button" class="ts-plain-link" (click)="clearOnly()">
Quitar
</button>
</p>
}
@if (facade.error(); as error) {
<pi-note
@@ -344,6 +353,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
.ts-list__count {
font-size: 0.8125rem;
}
.ts-list__only {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin: -0.25rem 0 0.75rem;
color: var(--ts-ink-soft);
font-size: 0.875rem;
}
.ts-list {
overflow-x: auto;
padding: 0;
@@ -452,6 +469,38 @@ const numberOf = (key: string) => Number(key.split('-').pop());
export class ListPage {
/** `?release=<id>`: opens already filtered by that release. */
readonly release = input<string | undefined>(undefined);
/** `?missing=estimate|area|assignee` (from the dashboard's backlog health). */
readonly missing = input<string | undefined>(undefined);
/** `?staleDays=90`: only tickets not updated in that many days. */
readonly staleDays = input<string | undefined>(undefined);
/** What the URL asked for, until cleared here. */
protected readonly gap = linkedSignal(() =>
this.missing() === 'estimate' || this.missing() === 'area'
? this.missing()
: null,
);
protected readonly stale = linkedSignal(() => {
const days = Math.round(Number(this.staleDays()));
return days > 0 ? days : null;
});
private readonly unassignedFromUrl = effect(() => {
if (this.missing() === 'assignee')
untracked(() => this.assignee.set(NOBODY));
});
protected readonly onlyLabel = computed(() =>
[
this.gap() === 'estimate' ? 'sin estimar' : null,
this.gap() === 'area' ? 'sin área' : null,
this.stale() ? `sin tocar en ${this.stale()} días` : null,
]
.filter(Boolean)
.join(' y '),
);
protected clearOnly(): void {
this.gap.set(null);
this.stale.set(null);
}
protected readonly facade = inject(ListFacade);
protected readonly project = inject(ProjectFacade);
protected readonly NOBODY = NOBODY;
@@ -577,6 +626,13 @@ export class ListPage {
unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
releaseId: this.releaseFilter() || undefined,
noEstimate: this.gap() === 'estimate' || undefined,
noArea: this.gap() === 'area' || undefined,
updatedBefore: this.stale()
? new Date(
Date.now() - (this.stale() as number) * 86_400_000,
).toISOString()
: undefined,
customFields: fields.length
? fields.map(([fieldId, value]) => ({ fieldId, value }))
: undefined,

View File

@@ -0,0 +1,82 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { BacklogHealth } from '@task-stream/web/tracker/domain';
/** Open tickets missing what planning needs, each count opening the list. */
@Component({
selector: 'ts-backlog-health',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'health-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="health-title">Salud del backlog</h2>
<span class="ts-muted">De {{ health().open }} tickets abiertos</span>
</header>
<ul class="ts-issues ts-health">
@for (row of rows(); track row.label) {
<li>
<a class="ts-title" routerLink="../list" [queryParams]="row.query">{{
row.label
}}</a>
<span class="ts-bar"><span [style.width.%]="row.share"></span></span>
<span class="ts-days" [class.ts-warn]="row.value > 0">{{
row.value
}}</span>
</li>
}
</ul>
`,
styles: `
.ts-health {
gap: 0.5rem;
li {
grid-template-columns: minmax(8rem, 12rem) 1fr 2.5rem;
align-items: center;
}
.ts-days {
text-align: right;
}
.ts-warn {
color: inherit;
font-weight: 600;
}
}
`,
})
export class BacklogHealthComponent {
readonly health = input.required<BacklogHealth>();
protected readonly rows = computed(() => {
const h = this.health();
const share = (n: number) => (h.open ? (n / h.open) * 100 : 0);
return [
{
label: 'Sin estimar',
value: h.unestimated,
query: { missing: 'estimate' },
},
{ label: 'Sin área', value: h.noArea, query: { missing: 'area' } },
{
label: 'Sin asignar',
value: h.unassigned,
query: { missing: 'assignee' },
},
{
label: `Sin tocar en ${h.staleDays} días`,
value: h.stale,
query: { staleDays: h.staleDays },
},
].map((row) => ({ ...row, share: share(row.value) }));
});
}

View File

@@ -0,0 +1,105 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { ChangesSince } from '@task-stream/web/tracker/domain';
import { timeAgo } from '../copy';
/** What happened in the project since my last visit. */
@Component({
selector: 'ts-changes-since',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'changes-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="changes-title">Desde tu última visita</h2>
<span class="ts-muted">{{ ago() }}</span>
</header>
<dl class="ts-changes__counts">
@for (count of counts(); track count.label) {
<div>
<dt>{{ count.label }}</dt>
<dd>{{ count.value }}</dd>
</div>
}
</dl>
@for (group of groups(); track group.title) {
@if (group.issues.length) {
<h3>{{ group.title }}</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></span>
</li>
}
</ul>
}
}
@if (quiet()) {
<p class="ts-empty">Nada nuevo desde entonces.</p>
}
`,
styles: `
.ts-changes__counts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
margin: 0 0 0.75rem;
dt {
color: var(--ts-muted);
font-size: 0.75rem;
}
dd {
margin: 0;
font-family: var(--ts-font-mono);
font-size: 1.25rem;
font-weight: 600;
}
}
h3 {
margin: 0.75rem 0 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
}
`,
})
export class ChangesSinceComponent {
readonly changes = input.required<ChangesSince>();
protected readonly ago = computed(() => timeAgo(this.changes().since));
protected readonly counts = computed(() => {
const c = this.changes();
return [
{ label: 'Nuevos', value: c.createdCount },
{ label: 'Terminados', value: c.finishedCount },
{ label: 'Vueltos de QA', value: c.returnedCount },
{ label: 'Menciones', value: c.mentions },
];
});
protected readonly groups = computed(() => {
const c = this.changes();
return [
{ title: 'Vueltos de QA', issues: c.returned },
{ title: 'Nuevos', issues: c.created },
{ title: 'Terminados', issues: c.finished },
];
});
protected readonly quiet = computed(() =>
this.counts().every((c) => !c.value),
);
}

View File

@@ -0,0 +1,114 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { ReleaseForecast } from '@task-stream/web/tracker/domain';
const short = (day: string | null) =>
day
? new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
})
: '—';
const VERDICT: Record<
ReleaseForecast['verdict'],
{ label: string; tone: string }
> = {
ON_TRACK: { label: 'Llega', tone: 'var(--ts-c-green, #2e9e6a)' },
AT_RISK: { label: 'Justa', tone: 'var(--ts-c-amber, #c98a12)' },
LATE: { label: 'No llega', tone: 'var(--ts-c-red, #d14343)' },
DONE: { label: 'Todo hecho', tone: 'var(--ts-c-green, #2e9e6a)' },
NO_DATE: { label: 'Sin fecha', tone: 'var(--ts-muted)' },
UNKNOWN: { label: 'Sin ritmo aún', tone: 'var(--ts-muted)' },
};
/**
* Planned releases with the date they would likely be finished at the
* team's real pace (Monte Carlo over the last six weeks).
*/
@Component({
selector: 'ts-release-forecast',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'forecast-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="forecast-title">Próximas releases</h2>
<span class="ts-muted">Previsión al ritmo de las últimas 6 semanas</span>
</header>
@if (rows().length) {
<ul class="ts-issues ts-forecast">
@for (row of rows(); track row.releaseId) {
<li>
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
<span class="ts-verdict" [style.--tone]="row.tone">{{
row.label
}}</span>
<span class="ts-bar"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days">{{ row.finished }}/{{ row.total }}</span>
<span class="ts-forecast__dates">
Objetivo {{ row.target }} · probable {{ row.likely }} · segura
{{ row.safe }}
</span>
</li>
}
</ul>
} @else {
<p class="ts-empty">No hay releases planificadas.</p>
}
`,
styles: `
.ts-forecast {
gap: 0.875rem;
li {
grid-template-columns: minmax(0, 1fr) auto;
row-gap: 0.25rem;
align-items: center;
}
.ts-bar {
grid-column: 1;
}
}
.ts-verdict {
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--tone) 14%, transparent);
color: var(--tone);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.ts-forecast__dates {
grid-column: 1 / -1;
color: var(--ts-muted);
font-size: 0.75rem;
}
`,
})
export class ReleaseForecastComponent {
readonly releases = input.required<ReleaseForecast[]>();
protected readonly rows = computed(() =>
this.releases().map((r) => ({
...r,
...VERDICT[r.verdict],
share: r.total ? (r.finished / r.total) * 100 : 0,
target: short(r.targetDate),
likely: short(r.likelyDate),
safe: short(r.safeDate),
})),
);
}

View File

@@ -0,0 +1,19 @@
/*
* When I last opened a project's dashboard, kept in this browser only: it
* is a convenience ("what changed since"), so storage may fail or be empty.
*/
const key = (projectId: string) => `task-stream.lastVisit.${projectId}`;
/** The previous visit (ISO), and from now on this one. */
export function takeLastVisit(
projectId: string,
now = new Date(),
): string | null {
try {
const previous = localStorage.getItem(key(projectId));
localStorage.setItem(key(projectId), now.toISOString());
return previous && !Number.isNaN(Date.parse(previous)) ? previous : null;
} catch {
return null;
}
}