feat: portfolio of all my projects
/cartera (and GraphQL portfolio, REST /api/me/portfolio, MCP get_portfolio) shows every active project I belong to (not archived, not as a guest) at a glance: open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and the open epics with their progress. It reuses the dashboard's computations. A release with no tickets now reads «Sin tickets» instead of «Todo hecho», here and on the dashboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
345
libs/web/tracker/presenters/src/lib/pages/portfolio.page.ts
Normal file
345
libs/web/tracker/presenters/src/lib/pages/portfolio.page.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiEmptyStateComponent,
|
||||
PiNoteComponent,
|
||||
PiSkeletonComponent,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
PortfolioFacade,
|
||||
providePortfolioFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { ReleaseForecast, SprintPulse } from '@task-stream/web/tracker/domain';
|
||||
import { projectTone } from '../ui/copy';
|
||||
import { verdictOf } from '../ui/insights/release-forecast';
|
||||
|
||||
const short = (day: string | null) =>
|
||||
day
|
||||
? new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
: '—';
|
||||
|
||||
/** «Cartera»: every active project of mine at a glance. */
|
||||
@Component({
|
||||
selector: 'ts-portfolio-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiEmptyStateComponent,
|
||||
PiNoteComponent,
|
||||
PiSkeletonComponent,
|
||||
],
|
||||
providers: [providePortfolioFacade()],
|
||||
template: `
|
||||
<header class="ts-page-header">
|
||||
<div>
|
||||
<p class="ts-eyebrow">Pireon · TaskStream</p>
|
||||
<h1>Cartera</h1>
|
||||
<p>
|
||||
Tus proyectos activos de un vistazo: lo abierto, lo que está en
|
||||
riesgo, el sprint en curso, las próximas releases y las épicas.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-secondary"
|
||||
[disabled]="portfolio.loading()"
|
||||
(click)="portfolio.load()"
|
||||
>
|
||||
{{ portfolio.loading() ? 'Actualizando…' : 'Actualizar' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (portfolio.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudo cargar la cartera"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else if (!portfolio.loaded()) {
|
||||
<div class="ts-portfolio">
|
||||
<pi-skeleton shape="block" height="16rem" />
|
||||
<pi-skeleton shape="block" height="16rem" />
|
||||
</div>
|
||||
} @else if (!portfolio.projects().length) {
|
||||
<pi-empty-state
|
||||
icon="folder"
|
||||
title="Ningún proyecto activo"
|
||||
description="Aquí salen los proyectos de los que eres miembro (no los archivados ni en los que eres invitado)."
|
||||
/>
|
||||
} @else {
|
||||
<div class="ts-portfolio">
|
||||
@for (p of portfolio.projects(); track p.projectId) {
|
||||
<article
|
||||
class="ts-panel ts-portfolio__card"
|
||||
[style.--tone]="tone(p.key)"
|
||||
[attr.aria-labelledby]="'pf-' + p.projectId"
|
||||
>
|
||||
<header class="ts-portfolio__head">
|
||||
<a
|
||||
class="ts-portfolio__key"
|
||||
[routerLink]="['/p', p.key, 'dashboard']"
|
||||
>{{ p.key }}</a
|
||||
>
|
||||
<h2 [id]="'pf-' + p.projectId">
|
||||
<a [routerLink]="['/p', p.key, 'dashboard']">{{ p.name }}</a>
|
||||
</h2>
|
||||
</header>
|
||||
<p class="ts-portfolio__counts">
|
||||
<span
|
||||
><strong>{{ p.open }}</strong> abiertos de {{ p.total }}</span
|
||||
>
|
||||
<span [class.is-warn]="p.overdue"
|
||||
><strong>{{ p.overdue }}</strong> vencidos</span
|
||||
>
|
||||
<span [class.is-warn]="p.blocked"
|
||||
><strong>{{ p.blocked }}</strong> bloqueados</span
|
||||
>
|
||||
<span [class.is-warn]="p.stuck"
|
||||
><strong>{{ p.stuck }}</strong> atascados</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h3>Sprint</h3>
|
||||
@if (p.sprint; as s) {
|
||||
<div class="ts-portfolio__row">
|
||||
<a [routerLink]="['/p', p.key, 'sprints']">{{ s.name }}</a>
|
||||
@if (s.behind) {
|
||||
<span
|
||||
class="ts-chip"
|
||||
style="--chip: var(--ts-c-red, #d14343)"
|
||||
>No llega</span
|
||||
>
|
||||
}
|
||||
<span class="ts-bar"
|
||||
><span
|
||||
[style.width.%]="share(s.donePoints, s.points)"
|
||||
></span
|
||||
></span>
|
||||
<span class="ts-mono"
|
||||
>{{ s.donePoints }}/{{ s.points }} pts</span
|
||||
>
|
||||
</div>
|
||||
<p class="ts-portfolio__sub">{{ sprintWhen(s) }}</p>
|
||||
} @else {
|
||||
<p class="ts-portfolio__sub">Sin sprint en curso.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Próximas releases</h3>
|
||||
@for (r of p.releases; track r.releaseId) {
|
||||
<div class="ts-portfolio__row">
|
||||
<a [routerLink]="['/p', p.key, 'releases']">{{ r.name }}</a>
|
||||
<span class="ts-chip" [style.--chip]="verdict(r).tone">{{
|
||||
verdict(r).label
|
||||
}}</span>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="share(r.finished, r.total)"></span
|
||||
></span>
|
||||
<span class="ts-mono">{{ r.finished }}/{{ r.total }}</span>
|
||||
</div>
|
||||
<p class="ts-portfolio__sub">
|
||||
Objetivo {{ day(r.targetDate) }} · probable
|
||||
{{ day(r.likelyDate) }}
|
||||
</p>
|
||||
} @empty {
|
||||
<p class="ts-portfolio__sub">Ninguna planificada.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Épicas</h3>
|
||||
@for (e of p.epics; track e.issue.id) {
|
||||
<div class="ts-portfolio__row">
|
||||
<a
|
||||
class="ts-portfolio__epic"
|
||||
[routerLink]="['/p', p.key, 'issues', e.issue.key]"
|
||||
><span class="ts-key">{{ e.issue.key }}</span>
|
||||
{{ e.issue.title }}</a
|
||||
>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="e.progress.percent"></span
|
||||
></span>
|
||||
<span class="ts-mono">{{ e.progress.percent }}%</span>
|
||||
</div>
|
||||
@if (e.issue.dueDate) {
|
||||
<p class="ts-portfolio__sub">
|
||||
Fecha límite {{ day(e.issue.dueDate) }}
|
||||
</p>
|
||||
}
|
||||
} @empty {
|
||||
<p class="ts-portfolio__sub">Ninguna épica abierta.</p>
|
||||
}
|
||||
</section>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.ts-page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ts-portfolio {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 26rem), 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.ts-portfolio__card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-top: 3px solid var(--tone);
|
||||
}
|
||||
.ts-portfolio__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.625rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-portfolio__key {
|
||||
color: var(--tone) !important;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-portfolio__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
margin: 0;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.8125rem;
|
||||
.is-warn strong {
|
||||
color: var(--ts-c-red, #d14343);
|
||||
}
|
||||
}
|
||||
section {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 0.125rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ts-portfolio__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto 5rem auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
> a {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-mono {
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.ts-portfolio__row:not(:has(.ts-chip)) {
|
||||
grid-template-columns: minmax(0, 1fr) 5rem auto;
|
||||
}
|
||||
.ts-portfolio__epic .ts-key {
|
||||
margin-right: 0.375rem;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-portfolio__sub {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-chip {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--chip) 14%, transparent);
|
||||
color: var(--chip);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-bar {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--tone, var(--ts-brand));
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PortfolioPage implements OnInit {
|
||||
protected readonly portfolio = inject(PortfolioFacade);
|
||||
protected readonly tone = projectTone;
|
||||
protected readonly day = short;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.portfolio.load();
|
||||
}
|
||||
|
||||
protected share(done: number, total: number): number {
|
||||
return total ? Math.round((done / total) * 100) : 0;
|
||||
}
|
||||
|
||||
protected verdict(release: ReleaseForecast) {
|
||||
return verdictOf(release);
|
||||
}
|
||||
|
||||
protected sprintWhen(sprint: SprintPulse): string {
|
||||
const left = sprint.daysLeft;
|
||||
const when =
|
||||
left < 0
|
||||
? 'terminó'
|
||||
: left === 0
|
||||
? 'último día'
|
||||
: `quedan ${left} ${left === 1 ? 'día' : 'días'}`;
|
||||
return `${short(sprint.startDate)} – ${short(sprint.endDate)} · ${when}`;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/projects.page').then((m) => m.ProjectsPage),
|
||||
},
|
||||
{
|
||||
path: 'cartera',
|
||||
canActivate: [authGuard],
|
||||
title: 'Cartera · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/portfolio.page').then((m) => m.PortfolioPage),
|
||||
},
|
||||
{
|
||||
path: 'explorar',
|
||||
title: 'Proyectos públicos · TaskStream',
|
||||
|
||||
@@ -16,7 +16,7 @@ const short = (day: string | null) =>
|
||||
})
|
||||
: '—';
|
||||
|
||||
const VERDICT: Record<
|
||||
export const VERDICT: Record<
|
||||
ReleaseForecast['verdict'],
|
||||
{ label: string; tone: string }
|
||||
> = {
|
||||
@@ -28,6 +28,16 @@ const VERDICT: Record<
|
||||
UNKNOWN: { label: 'Sin ritmo aún', tone: 'var(--ts-muted)' },
|
||||
};
|
||||
|
||||
/** Its verdict; a release with no tickets yet has nothing to forecast. */
|
||||
export function verdictOf(release: ReleaseForecast): {
|
||||
label: string;
|
||||
tone: string;
|
||||
} {
|
||||
return release.total === 0
|
||||
? { label: 'Sin tickets', tone: 'var(--ts-muted)' }
|
||||
: VERDICT[release.verdict];
|
||||
}
|
||||
|
||||
/**
|
||||
* Planned releases with the date they would likely be finished at the
|
||||
* team's real pace (Monte Carlo over the last six weeks).
|
||||
@@ -104,7 +114,7 @@ export class ReleaseForecastComponent {
|
||||
protected readonly rows = computed(() =>
|
||||
this.releases().map((r) => ({
|
||||
...r,
|
||||
...VERDICT[r.verdict],
|
||||
...verdictOf(r),
|
||||
share: r.total ? (r.finished / r.total) * 100 : 0,
|
||||
target: short(r.targetDate),
|
||||
likely: short(r.likelyDate),
|
||||
|
||||
Reference in New Issue
Block a user