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:
2026-10-15 18:27:46 +02:00
parent 8bf7d47b2e
commit affce615a7
35 changed files with 1603 additions and 80 deletions

View 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}`;
}
}

View File

@@ -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',

View File

@@ -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),