feat: dashboard maps the activity, follows a ticket's way and the proposals

Three more blocks on the project dashboard, also by REST, GraphQL and
MCP:
- Activity: what moved each day of the last 12 weeks, in a heatmap,
  with the busiest weekday and the longest lull.
- A ticket's way: for the tickets done in the last 90 days, where the
  time from created to done went, status by status (waiting included),
  and how much of it was work (flow efficiency).
- The public's proposals: received, waiting, under way, done and
  discarded, and the most voted still unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 09:29:38 +02:00
parent 9ccc3b5967
commit 1101b6e926
24 changed files with 1417 additions and 73 deletions

View File

@@ -126,6 +126,33 @@ export interface FieldBreakdown {
none: number;
}
/** Where a closed ticket's time goes, status by status. */
export interface Journey {
/** Tickets done in the last 90 days. */
samples: number;
leadDays: number | null;
/** Part of the lead time at work (an in-progress status), 0–100. */
flowEfficiency: number | null;
steps: { status: string; days: number; share: number }[];
}
/** The public's proposals, from received to done or discarded. */
export interface ProposalsFunnel {
received: number;
receivedLately: number;
waiting: number;
underWay: number;
done: number;
discarded: number;
topWaiting: {
id: string;
key: string;
title: string;
votes: number;
days: number;
}[];
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
@@ -140,6 +167,11 @@ export interface ProjectInsights {
backlog: BacklogHealth;
quality: Quality;
fields: FieldBreakdown[];
/** 12 weeks from a Monday, a day each (84). */
heatmap: { day: string; count: number }[];
journey: Journey;
/** Null while nobody has proposed anything. */
proposals: ProposalsFunnel | null;
}
/** One of my active projects at a glance. */

View File

@@ -2059,6 +2059,28 @@ export type ProjectInsightsQuery = {
none: number;
counts: Array<{ value: string; count: number }>;
}>;
heatmap: Array<{ day: string; count: number }>;
journey: {
samples: number;
leadDays: number | null;
flowEfficiency: number | null;
steps: Array<{ status: string; days: number; share: number }>;
};
proposals: {
received: number;
receivedLately: number;
waiting: number;
underWay: number;
done: number;
discarded: number;
topWaiting: Array<{
id: string;
key: string;
title: string;
votes: number;
days: number;
}>;
} | null;
};
};
};
@@ -12397,6 +12419,127 @@ export const ProjectInsightsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'heatmap' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'day' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'journey' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'samples' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'leadDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'flowEfficiency' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'steps' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'status' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'days' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'share' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'proposals' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'received' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'receivedLately' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'waiting' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'underWay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'done' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'discarded' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'topWaiting' },
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: 'votes' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'days' },
},
],
},
},
],
},
},
],
},
},

View File

@@ -296,6 +296,35 @@ query ProjectInsights($idOrKey: String!, $since: String) {
}
none
}
heatmap {
day
count
}
journey {
samples
leadDays
flowEfficiency
steps {
status
days
share
}
}
proposals {
received
receivedLately
waiting
underWay
done
discarded
topWaiting {
id
key
title
votes
days
}
}
}
}
}

View File

@@ -34,6 +34,9 @@ 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';
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
import { JourneyComponent } from '../ui/insights/journey';
import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
@@ -53,6 +56,9 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
BacklogHealthComponent,
QualityComponent,
FieldBreakdownComponent,
ActivityHeatmapComponent,
JourneyComponent,
ProposalsFunnelComponent,
],
providers: [provideInsightsFacade()],
template: `
@@ -132,11 +138,19 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
[statuses]="reading.bottlenecks"
[workflow]="facade.workflow()"
/>
<ts-journey
[journey]="reading.journey"
[workflow]="facade.workflow()"
/>
<ts-activity-heatmap [days]="reading.heatmap" />
<ts-backlog-health [health]="reading.backlog" />
<ts-quality
[quality]="reading.quality"
[workflow]="facade.workflow()"
/>
@if (reading.proposals; as proposals) {
<ts-proposals-funnel [funnel]="proposals" />
}
@if (reading.fields.length) {
<ts-field-breakdown
[fields]="reading.fields"

View File

@@ -0,0 +1,200 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
const WEEKDAYS = [
'lunes',
'martes',
'miércoles',
'jueves',
'viernes',
'sábado',
'domingo',
];
const MONTHS = [
'ene',
'feb',
'mar',
'abr',
'may',
'jun',
'jul',
'ago',
'sep',
'oct',
'nov',
'dic',
];
/** 12 weeks of the project's activity, a square a day: lulls and rushes at a glance. */
@Component({
selector: 'ts-activity-heatmap',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'heatmap-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="heatmap-title">Actividad</h2>
<span class="ts-muted">Movimientos por día, últimas 12 semanas</span>
</header>
<div class="ts-heat">
<span class="ts-heat__corner"></span>
@for (week of weeks(); track week.start) {
<span class="ts-heat__month">{{ week.month }}</span>
}
@for (row of [0, 1, 2, 3, 4, 5, 6]; track row) {
<span class="ts-heat__weekday">{{
row % 2 === 0 ? weekdayInitial(row) : ''
}}</span>
@for (week of weeks(); track week.start) {
@if (week.days[row]; as day) {
<span
class="ts-heat__day"
[attr.data-level]="day.level"
[class.is-future]="day.future"
[attr.title]="day.title"
></span>
}
}
}
</div>
<p class="ts-empty ts-heat__note">
@if (total()) {
{{ total() }} {{ total() === 1 ? 'movimiento' : 'movimientos' }} · el
día con más vida, el {{ busiest() }}
@if (quiet() >= 3) {
· parón más largo: {{ quiet() }} días
}
} @else {
Nada se ha movido en estas 12 semanas.
}
</p>
`,
styles: `
.ts-heat {
display: grid;
grid-template-columns: 1.25rem repeat(12, minmax(0, 1fr));
gap: 3px;
align-items: center;
}
.ts-heat__month,
.ts-heat__weekday {
color: var(--ts-muted);
font-size: 0.6875rem;
line-height: 1;
white-space: nowrap;
}
.ts-heat__month {
padding-bottom: 0.25rem;
}
.ts-heat__day {
aspect-ratio: 1;
max-height: 1.375rem;
border-radius: 3px;
background: var(--ts-sunken);
&[data-level='1'] {
background: color-mix(in srgb, var(--ts-brand) 25%, var(--ts-sunken));
}
&[data-level='2'] {
background: color-mix(in srgb, var(--ts-brand) 50%, var(--ts-sunken));
}
&[data-level='3'] {
background: color-mix(in srgb, var(--ts-brand) 75%, var(--ts-sunken));
}
&[data-level='4'] {
background: var(--ts-brand);
}
&.is-future {
background: transparent;
outline: 1px dashed var(--ts-line);
outline-offset: -1px;
}
}
.ts-heat__note {
margin-top: 0.875rem;
}
`,
})
export class ActivityHeatmapComponent {
/** 84 days from a Monday, oldest first. */
readonly days = input.required<{ day: string; count: number }[]>();
/** Today (`YYYY-MM-DD`), to grey out the rest of this week. */
readonly today = input<string>(new Date().toISOString().slice(0, 10));
protected readonly total = computed(() =>
this.days().reduce((sum, d) => sum + d.count, 0),
);
/** Squares coloured by quarters of the busiest day. */
protected readonly weeks = computed(() => {
const days = this.days();
const max = Math.max(1, ...days.map((d) => d.count));
const weeks: {
start: string;
month: string;
days: { level: number; future: boolean; title: string }[];
}[] = [];
for (let w = 0; w * 7 < days.length; w++) {
const week = days.slice(w * 7, w * 7 + 7);
const first = week[0].day;
// The month's name over the week it starts in (and over the first one).
const month =
w === 0 || week.some((d) => d.day.endsWith('-01'))
? MONTHS[
Number(
(week.find((d) => d.day.endsWith('-01')) ?? week[0]).day.slice(
5,
7,
),
) - 1
]
: '';
weeks.push({
start: first,
month,
days: week.map((d) => {
const future = d.day > this.today();
return {
future,
level: d.count ? Math.min(4, Math.ceil((d.count / max) * 4)) : 0,
title: future
? ''
: `${d.day.slice(8)}/${d.day.slice(5, 7)}: ${d.count} ${d.count === 1 ? 'movimiento' : 'movimientos'}`,
};
}),
});
}
return weeks;
});
protected readonly busiest = computed(() => {
const byWeekday = Array<number>(7).fill(0);
this.days().forEach((d, i) => (byWeekday[i % 7] += d.count));
return WEEKDAYS[byWeekday.indexOf(Math.max(...byWeekday))];
});
/** The longest run of days without anything moving, from the first that had something up to today. */
protected readonly quiet = computed(() => {
let longest = 0;
let run = 0;
const days = this.days();
const first = days.findIndex((d) => d.count > 0);
for (const d of first < 0 ? [] : days.slice(first)) {
if (d.day > this.today()) break;
run = d.count ? 0 : run + 1;
longest = Math.max(longest, run);
}
return longest;
});
protected weekdayInitial(row: number): string {
return ['L', 'M', 'X', 'J', 'V', 'S', 'D'][row];
}
}

View File

@@ -0,0 +1,146 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Journey, Workflow } from '@task-stream/web/tracker/domain';
import { statusLook } from '../copy';
/**
* Where a ticket's time goes from created to done, waiting included: one
* bar split by status, and how much of it was actual work.
*/
@Component({
selector: 'ts-journey',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'journey-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="journey-title">El recorrido de un ticket</h2>
<span class="ts-muted">De creado a hecho, los cerrados en 90 días</span>
</header>
@if (journey().leadDays !== null && steps().length) {
<p class="ts-journey__lead">
<strong>{{ days(journey().leadDays!) }}</strong> de media ·
<strong>{{ journey().flowEfficiency }} %</strong> trabajando, el resto
esperando
</p>
<div class="ts-journey__bar" role="img" [attr.aria-label]="summary()">
@for (step of steps(); track step.status) {
<span
[style.--tone]="step.tone"
[style.flex-grow]="step.share || 1"
[attr.title]="
step.label + ': ' + days(step.days) + ' (' + step.share + ' %)'
"
></span>
}
</div>
<ul class="ts-issues ts-journey__legend">
@for (step of steps(); track step.status) {
<li [style.--tone]="step.tone" [class.is-longest]="step.longest">
<span class="ts-journey__dot" aria-hidden="true"></span>
<span class="ts-title">{{ step.label }}</span>
<span class="ts-days"
>{{ days(step.days) }} · {{ step.share }} %</span
>
</li>
}
</ul>
<p class="ts-empty ts-journey__note">
Medido en {{ journey().samples }}
{{ journey().samples === 1 ? 'ticket' : 'tickets' }}.
@if (longest(); as longest) {
Donde más tiempo se va: <strong>{{ longest }}</strong
>.
}
</p>
} @else {
<p class="ts-empty">
Aún no se ha terminado ningún ticket en los últimos 90 días.
</p>
}
`,
styles: `
.ts-journey__lead {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
}
.ts-journey__bar {
display: flex;
gap: 2px;
height: 1rem;
border-radius: 0.375rem;
overflow: hidden;
span {
min-width: 3px;
background: var(--tone);
}
}
.ts-journey__legend {
margin-top: 0.875rem;
gap: 0.375rem;
li {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
}
li.is-longest .ts-title {
font-weight: 600;
}
}
.ts-journey__dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--tone);
}
.ts-journey__note {
margin-top: 0.875rem;
}
`,
})
export class JourneyComponent {
readonly journey = input.required<Journey>();
readonly workflow = input.required<Workflow>();
protected readonly steps = computed(() => {
const steps = this.journey().steps;
const most = Math.max(0, ...steps.map((s) => s.share));
return steps.map((s) => {
const look = statusLook(this.workflow(), s.status);
return {
...s,
label: look.label,
tone: look.tone,
longest: s.share === most,
};
});
});
protected readonly longest = computed(() => {
const step = this.steps().find((s) => s.longest);
return step ? `${step.label} (${step.share} %)` : null;
});
protected readonly summary = computed(() =>
this.steps()
.map((s) => `${s.label} ${s.share} %`)
.join(', '),
);
/** Days, or hours when under one (or «menos de 1 h»). */
protected days(n: number): string {
if (n < 1) {
const hours = Math.round(n * 24);
return hours < 1 ? 'menos de 1 h' : `${hours} h`;
}
const rounded = Math.round(n * 10) / 10;
return `${rounded.toLocaleString('es')} ${rounded === 1 ? 'día' : 'días'}`;
}
}

View File

@@ -0,0 +1,98 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { ProposalsFunnel } from '@task-stream/web/tracker/domain';
/** The public's proposals: how many came, where they are, and the most voted still unanswered. */
@Component({
selector: 'ts-proposals-funnel',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'proposals-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="proposals-title">Propuestas del público</h2>
<span class="ts-muted"
>{{ funnel().receivedLately }} en los últimos 30 días</span
>
</header>
<ul class="ts-issues ts-funnel">
@for (stage of stages(); track stage.label) {
<li [style.--tone]="stage.tone">
<span class="ts-title">{{ stage.label }}</span>
<span class="ts-bar"
><span [style.width.%]="stage.width"></span
></span>
<span class="ts-days">{{ stage.count }}</span>
</li>
}
</ul>
@if (funnel().topWaiting.length) {
<h3 class="ts-funnel__subtitle">Las más votadas sin respuesta</h3>
<ul class="ts-issues">
@for (p of funnel().topWaiting; track p.id) {
<li>
<a class="ts-key" [routerLink]="['../issues', p.key]">{{
p.key
}}</a>
<a class="ts-title" [routerLink]="['../issues', p.key]">{{
p.title
}}</a>
<span class="ts-days" [attr.title]="p.days + ' días esperando'"
>▲ {{ p.votes }} · {{ p.days }} d</span
>
</li>
}
</ul>
} @else if (funnel().received) {
<p class="ts-empty ts-funnel__note">Ninguna espera respuesta.</p>
}
`,
styles: `
.ts-funnel {
gap: 0.5rem;
li {
grid-template-columns: minmax(6rem, 8rem) 1fr 2.5rem;
align-items: center;
}
.ts-days {
text-align: right;
}
}
.ts-funnel__subtitle {
margin: 1.125rem 0 0.5rem;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ts-funnel__note {
margin-top: 0.875rem;
}
`,
})
export class ProposalsFunnelComponent {
readonly funnel = input.required<ProposalsFunnel>();
protected readonly stages = computed(() => {
const f = this.funnel();
const max = Math.max(1, f.received);
return [
{ label: 'Recibidas', count: f.received, tone: 'var(--ts-brand)' },
{ label: 'Esperando', count: f.waiting, tone: 'var(--ts-c-amber)' },
{ label: 'En marcha', count: f.underWay, tone: 'var(--ts-c-blue)' },
{ label: 'Hechas', count: f.done, tone: 'var(--ts-c-green)' },
{ label: 'Descartadas', count: f.discarded, tone: 'var(--ts-c-slate)' },
].map((s) => ({ ...s, width: (s.count / max) * 100 }));
});
}