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:
@@ -13,6 +13,7 @@ import {
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeInsights,
|
||||
HEATMAP_DAYS,
|
||||
InsightsInput,
|
||||
simulateDays,
|
||||
STALE_DAYS,
|
||||
@@ -392,4 +393,76 @@ describe('computeInsights', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps 12 weeks of activity, the whole way of done tickets and the proposals', () => {
|
||||
const proposal = { labels: ['propuesta'] };
|
||||
const issues = [
|
||||
// Done: 2 days waiting in TODO, 3 at work, 1 in QA.
|
||||
issue('A', 'DONE', C.DONE, { createdAt: '2026-10-01T10:00:00Z' }),
|
||||
issue('P1', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 2,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P2', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 5,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P3', 'TODO', C.TODO, { ...proposal, ...inSprint }),
|
||||
issue('P4', 'DONE', C.DONE, proposal),
|
||||
issue('P5', 'CANCELLED', C.CANCELLED, proposal),
|
||||
];
|
||||
const timeline = [
|
||||
step('A', '2026-10-01T10:00:00Z', 'TODO', C.TODO),
|
||||
step('A', '2026-10-03T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||
step('A', '2026-10-06T10:00:00Z', 'IN_QA', C.QA),
|
||||
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
|
||||
];
|
||||
const insights = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
activity: [
|
||||
{ day: '2026-10-15', count: 3 },
|
||||
{ day: '2026-10-14', count: 1 },
|
||||
{ day: '2026-01-01', count: 9 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.heatmap).toHaveLength(HEATMAP_DAYS);
|
||||
// From the Monday 11 weeks back to this week's Sunday.
|
||||
expect(insights.heatmap[0].day).toBe('2026-07-27');
|
||||
expect(insights.heatmap.at(-1)?.day).toBe('2026-10-18');
|
||||
expect(insights.heatmap.find((d) => d.day === '2026-10-15')?.count).toBe(3);
|
||||
|
||||
expect(insights.journey).toEqual({
|
||||
samples: 1,
|
||||
leadDays: 6,
|
||||
flowEfficiency: 50,
|
||||
steps: [
|
||||
{ status: 'TODO', days: 2, share: 33 },
|
||||
{ status: 'IN_PROGRESS', days: 3, share: 50 },
|
||||
{ status: 'IN_QA', days: 1, share: 17 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.proposals).toMatchObject({
|
||||
received: 5,
|
||||
waiting: 2,
|
||||
underWay: 1,
|
||||
done: 1,
|
||||
discarded: 1,
|
||||
});
|
||||
expect(insights.proposals?.topWaiting.map((p) => [p.key, p.votes])).toEqual(
|
||||
[
|
||||
['P2', 5],
|
||||
['P1', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('has no proposals block while nobody has proposed anything', () => {
|
||||
expect(
|
||||
computeInsights({ issues: [], timeline: [], ...BASE }).proposals,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
ActivityDayReadModel,
|
||||
BacklogHealthReadModel,
|
||||
JourneyReadModel,
|
||||
ProposalsFunnelReadModel,
|
||||
CustomFieldType,
|
||||
FieldBreakdownReadModel,
|
||||
IssuePriority,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
stepsByIssue,
|
||||
weekOf,
|
||||
} from './analytics';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
|
||||
/*
|
||||
* The dashboard's reading of a project: what needs attention now (risks),
|
||||
@@ -61,6 +65,8 @@ export interface InsightsInput {
|
||||
since: Date | null;
|
||||
/** Times the viewer was mentioned in the project since then. */
|
||||
mentions: number;
|
||||
/** Activity events per day (`YYYY-MM-DD`), the last 12 weeks or more. */
|
||||
activity?: { day: string; count: number }[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
@@ -72,6 +78,8 @@ const utcDay = (time: number) =>
|
||||
);
|
||||
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
/** Hundredths of a day (about a quarter of an hour): short stays still show. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export function computeInsights(
|
||||
input: InsightsInput,
|
||||
@@ -180,6 +188,127 @@ export function computeInsights(
|
||||
backlog: backlogHealth(open, now),
|
||||
quality: quality(input.settings, live, steps, input.releases, now),
|
||||
fields: fieldBreakdown(input.settings, open),
|
||||
heatmap: heatmap(input.activity ?? [], now),
|
||||
journey: journey(input.settings, live, steps, now),
|
||||
proposals: proposalsFunnel(live, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** Days shown by the heatmap: 12 weeks. */
|
||||
export const HEATMAP_DAYS = 84;
|
||||
|
||||
/** The first day of the heatmap: the Monday 11 weeks before this week's. */
|
||||
export function heatmapStart(now: number): string {
|
||||
return new Date(Date.parse(weekOf(now)) - 11 * 7 * DAY)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** 12 weeks of activity, a day each, zero where nothing happened. */
|
||||
function heatmap(
|
||||
activity: { day: string; count: number }[],
|
||||
now: number,
|
||||
): ActivityDayReadModel[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of activity)
|
||||
counts.set(a.day, (counts.get(a.day) ?? 0) + a.count);
|
||||
const start = Date.parse(heatmapStart(now));
|
||||
return Array.from({ length: HEATMAP_DAYS }, (_, n) => {
|
||||
const day = new Date(start + n * DAY).toISOString().slice(0, 10);
|
||||
return { day, count: counts.get(day) ?? 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the time of a closed ticket goes, from created to done, status by
|
||||
* status (waiting included), for the tickets done in the last 90 days.
|
||||
*/
|
||||
function journey(
|
||||
settings: ProjectSettings,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
now: number,
|
||||
): JourneyReadModel {
|
||||
const from = now - 90 * DAY;
|
||||
const spent = new Map<string, number>();
|
||||
let lead = 0;
|
||||
let working = 0;
|
||||
let samples = 0;
|
||||
for (const issue of issues) {
|
||||
if (issue.statusCategory !== StatusCategory.DONE) continue;
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
// The last arrival at a done status, from somewhere else.
|
||||
let end = -1;
|
||||
list.forEach((step, i) => {
|
||||
if (
|
||||
i > 0 &&
|
||||
step.category === StatusCategory.DONE &&
|
||||
list[i - 1].category !== StatusCategory.DONE
|
||||
)
|
||||
end = i;
|
||||
});
|
||||
if (end < 0 || list[end].at < from) continue;
|
||||
samples++;
|
||||
lead += list[end].at - list[0].at;
|
||||
for (let i = 0; i < end; i++) {
|
||||
const stay = list[i + 1].at - list[i].at;
|
||||
spent.set(list[i].status, (spent.get(list[i].status) ?? 0) + stay);
|
||||
if (list[i].category === StatusCategory.IN_PROGRESS) working += stay;
|
||||
}
|
||||
}
|
||||
const order = settings.statuses.map((s) => s.id);
|
||||
const rank = (id: string) =>
|
||||
order.includes(id) ? order.indexOf(id) : order.length;
|
||||
return {
|
||||
samples,
|
||||
leadDays: samples ? round2(lead / samples / DAY) : null,
|
||||
flowEfficiency: lead ? Math.round((working / lead) * 100) : null,
|
||||
steps: [...spent.entries()]
|
||||
.filter(([, time]) => time > 0)
|
||||
.sort(([a], [b]) => rank(a) - rank(b))
|
||||
.map(([status, time]) => ({
|
||||
status,
|
||||
days: round2(time / samples / DAY),
|
||||
share: Math.round((time / lead) * 100),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The public's proposals, from received to done or discarded. */
|
||||
function proposalsFunnel(
|
||||
issues: IssueReadModel[],
|
||||
now: number,
|
||||
): ProposalsFunnelReadModel | null {
|
||||
const proposals = issues.filter((i) =>
|
||||
(i.labels ?? []).includes(PROPOSAL_LABEL),
|
||||
);
|
||||
if (!proposals.length) return null;
|
||||
const isWaiting = (i: IssueReadModel) =>
|
||||
i.statusCategory === StatusCategory.TODO && !i.sprint && !i.release;
|
||||
const open = proposals.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||
const waiting = open.filter(isWaiting);
|
||||
const age = (i: IssueReadModel) =>
|
||||
Math.floor((now - Date.parse(i.createdAt)) / DAY);
|
||||
return {
|
||||
received: proposals.length,
|
||||
receivedLately: proposals.filter((i) => age(i) < 30).length,
|
||||
waiting: waiting.length,
|
||||
underWay: open.length - waiting.length,
|
||||
done: proposals.filter((i) => i.statusCategory === StatusCategory.DONE)
|
||||
.length,
|
||||
discarded: proposals.filter(
|
||||
(i) => i.statusCategory === StatusCategory.CANCELLED,
|
||||
).length,
|
||||
topWaiting: waiting
|
||||
.sort((a, b) => (b.votes ?? 0) - (a.votes ?? 0) || age(b) - age(a))
|
||||
.slice(0, 5)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
key: i.key,
|
||||
title: i.title,
|
||||
votes: i.votes ?? 0,
|
||||
days: age(i),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
ProjectInsightsReadModel,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights } from '../analytics/insights';
|
||||
import { computeInsights, heatmapStart } from '../analytics/insights';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
@@ -27,13 +28,15 @@ export class GetProjectInsightsQueryHandler
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly notifications: NotificationsRepository,
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetProjectInsightsQuery,
|
||||
): Promise<ProjectInsightsReadModel> {
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
const [project, issues, timeline, sprints, releases, mentions] =
|
||||
const now = new Date();
|
||||
const [project, issues, timeline, sprints, releases, mentions, activity] =
|
||||
await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
@@ -41,6 +44,10 @@ export class GetProjectInsightsQueryHandler
|
||||
this.findSprints.findByProject(query.projectId),
|
||||
this.findReleases.findByProject(query.projectId),
|
||||
this.mentionsSince(query),
|
||||
this.findActivity.dailyCounts(
|
||||
[query.projectId],
|
||||
heatmapStart(now.getTime()),
|
||||
),
|
||||
]);
|
||||
return computeInsights({
|
||||
issues,
|
||||
@@ -53,7 +60,8 @@ export class GetProjectInsightsQueryHandler
|
||||
members: project?.members ?? [],
|
||||
since: query.since,
|
||||
mentions,
|
||||
now: new Date(),
|
||||
activity,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,60 @@ export class FieldBreakdownReadModel {
|
||||
none!: number;
|
||||
}
|
||||
|
||||
/** How much happened on one day (every event of the activity feed). */
|
||||
export class ActivityDayReadModel {
|
||||
/** `YYYY-MM-DD`. */
|
||||
day!: string;
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** Where a closed ticket's time goes, from created to done. */
|
||||
export class JourneyStepReadModel {
|
||||
/** Status id of the project's settings. */
|
||||
status!: string;
|
||||
/** Average days a closed ticket spent in it. */
|
||||
days!: number;
|
||||
/** Its part of the whole lead time, 0–100. */
|
||||
share!: number;
|
||||
}
|
||||
|
||||
/** The whole way of the tickets closed lately (90 days). */
|
||||
export class JourneyReadModel {
|
||||
/** Tickets measured: closed as done in the last 90 days. */
|
||||
samples!: number;
|
||||
/** Average days from created to done. */
|
||||
leadDays!: number | null;
|
||||
/** Part of that time in an in-progress status (work, not waiting), 0–100. */
|
||||
flowEfficiency!: number | null;
|
||||
/** In board order, only the statuses they went through. */
|
||||
steps!: JourneyStepReadModel[];
|
||||
}
|
||||
|
||||
/** A proposal waiting for an answer, with its votes. */
|
||||
export class WaitingProposalReadModel {
|
||||
id!: string;
|
||||
key!: string;
|
||||
title!: string;
|
||||
votes!: number;
|
||||
/** Days since it came in. */
|
||||
days!: number;
|
||||
}
|
||||
|
||||
/** The public's proposals (tickets labelled «propuesta»), from in to out. */
|
||||
export class ProposalsFunnelReadModel {
|
||||
received!: number;
|
||||
/** In the last 30 days. */
|
||||
receivedLately!: number;
|
||||
/** Open, not started, in no sprint nor release. */
|
||||
waiting!: number;
|
||||
/** Open and taken up (started, or planned in a sprint or release). */
|
||||
underWay!: number;
|
||||
done!: number;
|
||||
discarded!: number;
|
||||
/** The most voted of those waiting, at most five. */
|
||||
topWaiting!: WaitingProposalReadModel[];
|
||||
}
|
||||
|
||||
/** What the project dashboard reads beyond the counts. */
|
||||
export class ProjectInsightsReadModel {
|
||||
generatedAt!: string;
|
||||
@@ -189,6 +243,11 @@ export class ProjectInsightsReadModel {
|
||||
quality!: QualityReadModel;
|
||||
/** One per active list (SELECT) custom field. */
|
||||
fields!: FieldBreakdownReadModel[];
|
||||
/** The last 12 weeks, oldest first, starting on a Monday (84 days). */
|
||||
heatmap!: ActivityDayReadModel[];
|
||||
journey!: JourneyReadModel;
|
||||
/** Null while the project has never had a proposal. */
|
||||
proposals!: ProposalsFunnelReadModel | null;
|
||||
}
|
||||
|
||||
/** One of my projects at a glance, for the portfolio. */
|
||||
|
||||
@@ -1038,6 +1038,67 @@ export class FieldBreakdownGql {
|
||||
none!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ActivityDay', { description: 'How much happened on one day' })
|
||||
export class ActivityDayGql {
|
||||
@Field({ description: 'YYYY-MM-DD' }) day!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('JourneyStep')
|
||||
export class JourneyStepGql {
|
||||
@Field({ description: 'Status id of the project settings' }) status!: string;
|
||||
@Field(() => Float, {
|
||||
description: 'Average days a closed ticket spent in it',
|
||||
})
|
||||
days!: number;
|
||||
@Field(() => Int, { description: 'Its part of the whole lead time, 0-100' })
|
||||
share!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Journey', {
|
||||
description:
|
||||
'Where the time of the tickets done in the last 90 days went, created to done',
|
||||
})
|
||||
export class JourneyGql {
|
||||
@Field(() => Int) samples!: number;
|
||||
@Field(() => Float, { nullable: true }) leadDays!: number | null;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'Part of the lead time in an in-progress status, 0-100',
|
||||
})
|
||||
flowEfficiency!: number | null;
|
||||
@Field(() => [JourneyStepGql]) steps!: JourneyStepGql[];
|
||||
}
|
||||
|
||||
@ObjectType('WaitingProposal')
|
||||
export class WaitingProposalGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() key!: string;
|
||||
@Field() title!: string;
|
||||
@Field(() => Int) votes!: number;
|
||||
@Field(() => Int, { description: 'Days since it came in' }) days!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ProposalsFunnel', {
|
||||
description: "The public's proposals, from received to done or discarded",
|
||||
})
|
||||
export class ProposalsFunnelGql {
|
||||
@Field(() => Int) received!: number;
|
||||
@Field(() => Int, { description: 'In the last 30 days' })
|
||||
receivedLately!: number;
|
||||
@Field(() => Int, {
|
||||
description: 'Open, not started, in no sprint nor release',
|
||||
})
|
||||
waiting!: number;
|
||||
@Field(() => Int) underWay!: number;
|
||||
@Field(() => Int) done!: number;
|
||||
@Field(() => Int) discarded!: number;
|
||||
@Field(() => [WaitingProposalGql], {
|
||||
description: 'Most voted waiting, at most 5',
|
||||
})
|
||||
topWaiting!: WaitingProposalGql[];
|
||||
}
|
||||
|
||||
@ObjectType('ProjectInsights', {
|
||||
description:
|
||||
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
|
||||
@@ -1062,6 +1123,16 @@ export class ProjectInsightsGql {
|
||||
description: 'One per active list custom field',
|
||||
})
|
||||
fields!: FieldBreakdownGql[];
|
||||
@Field(() => [ActivityDayGql], {
|
||||
description: 'The last 12 weeks from a Monday, oldest first (84 days)',
|
||||
})
|
||||
heatmap!: ActivityDayGql[];
|
||||
@Field(() => JourneyGql) journey!: JourneyGql;
|
||||
@Field(() => ProposalsFunnelGql, {
|
||||
nullable: true,
|
||||
description: 'Null while the project has never had a proposal',
|
||||
})
|
||||
proposals!: ProposalsFunnelGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('Presence', {
|
||||
|
||||
@@ -1117,6 +1117,53 @@ export class FieldBreakdownDto {
|
||||
@ApiProperty({ description: 'Open tickets with no value' }) none!: number;
|
||||
}
|
||||
|
||||
export class ActivityDayDto {
|
||||
@ApiProperty({ example: '2026-10-15' }) day!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class JourneyStepDto {
|
||||
@ApiProperty({ description: 'Status id of the project settings' })
|
||||
status!: string;
|
||||
@ApiProperty({ description: 'Average days a closed ticket spent in it' })
|
||||
days!: number;
|
||||
@ApiProperty({ description: 'Its part of the whole lead time, 0-100' })
|
||||
share!: number;
|
||||
}
|
||||
|
||||
export class JourneyDto {
|
||||
@ApiProperty({ description: 'Tickets done in the last 90 days' })
|
||||
samples!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) leadDays!: number | null;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Part of the lead time in an in-progress status, 0-100',
|
||||
})
|
||||
flowEfficiency!: number | null;
|
||||
@ApiProperty({ type: [JourneyStepDto] }) steps!: JourneyStepDto[];
|
||||
}
|
||||
|
||||
export class WaitingProposalDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty() key!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty() votes!: number;
|
||||
@ApiProperty({ description: 'Days since it came in' }) days!: number;
|
||||
}
|
||||
|
||||
export class ProposalsFunnelDto {
|
||||
@ApiProperty() received!: number;
|
||||
@ApiProperty({ description: 'In the last 30 days' }) receivedLately!: number;
|
||||
@ApiProperty({ description: 'Open, not started, in no sprint nor release' })
|
||||
waiting!: number;
|
||||
@ApiProperty() underWay!: number;
|
||||
@ApiProperty() done!: number;
|
||||
@ApiProperty() discarded!: number;
|
||||
@ApiProperty({ type: [WaitingProposalDto] })
|
||||
topWaiting!: WaitingProposalDto[];
|
||||
}
|
||||
|
||||
export class ProjectInsightsDto {
|
||||
@ApiProperty() generatedAt!: string;
|
||||
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||
@@ -1139,6 +1186,18 @@ export class ProjectInsightsDto {
|
||||
description: 'One per active list custom field',
|
||||
})
|
||||
fields!: FieldBreakdownDto[];
|
||||
@ApiProperty({
|
||||
type: [ActivityDayDto],
|
||||
description: 'The last 12 weeks from a Monday, oldest first (84 days)',
|
||||
})
|
||||
heatmap!: ActivityDayDto[];
|
||||
@ApiProperty({ type: JourneyDto }) journey!: JourneyDto;
|
||||
@ApiProperty({
|
||||
type: ProposalsFunnelDto,
|
||||
nullable: true,
|
||||
description: 'Null while the project has never had a proposal',
|
||||
})
|
||||
proposals!: ProposalsFunnelDto | null;
|
||||
}
|
||||
|
||||
export class PortfolioProjectDto {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
146
libs/web/tracker/presenters/src/lib/ui/insights/journey.ts
Normal file
146
libs/web/tracker/presenters/src/lib/ui/insights/journey.ts
Normal 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'}`;
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user