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

@@ -12,6 +12,7 @@ export * from './lib/facades/epics.facade';
export * from './lib/facades/insights.facade';
export * from './lib/facades/my-views.facade';
export * from './lib/facades/presence.facade';
export * from './lib/facades/portfolio.facade';
export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,18 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { PortfolioProject } from '@task-stream/web/tracker/domain';
export const portfolioPageEvents = eventGroup({
source: 'Portfolio Page',
events: {
opened: type<void>(),
},
});
export const portfolioApiEvents = eventGroup({
source: 'Portfolio API',
events: {
loaded: type<PortfolioProject[]>(),
loadFailed: type<string>(),
},
});

View File

@@ -0,0 +1,27 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { portfolioPageEvents } from '../events/portfolio.events';
import { PortfolioStore } from '../stores/portfolio.store';
/** «Cartera»: every active project of mine at a glance. */
@Injectable()
export class PortfolioFacade {
private readonly store = inject(PortfolioStore);
private readonly dispatch = injectDispatch(portfolioPageEvents);
readonly projects = this.store.projects;
/** False until the first load: show skeletons, not empty states. */
readonly loaded = this.store.loaded;
readonly loading = this.store.loading;
readonly error = this.store.error;
/** Loads it (again, to refresh). */
load(): void {
this.dispatch.opened();
}
}
/** Store + facade of the portfolio page. */
export function providePortfolioFacade(): Provider[] {
return [PortfolioStore, PortfolioFacade];
}

View File

@@ -0,0 +1,64 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
PortfolioProject,
ProjectsGateway,
} from '@task-stream/web/tracker/domain';
import { switchMap } from 'rxjs';
import {
portfolioApiEvents,
portfolioPageEvents,
} from '../events/portfolio.events';
import { describeError } from '../notifications';
interface PortfolioState {
projects: PortfolioProject[];
loaded: boolean;
loading: boolean;
error: string | null;
}
/** All my active projects at a glance. Scoped to the portfolio page. */
export const PortfolioStore = signalStore(
withState<PortfolioState>({
projects: [],
loaded: false,
loading: false,
error: null,
}),
withReducer(
on(portfolioPageEvents.opened, () => ({ loading: true, error: null })),
on(portfolioApiEvents.loaded, ({ payload }) => ({
projects: payload,
loaded: true,
loading: false,
})),
on(portfolioApiEvents.loadFailed, ({ payload }) => ({
loaded: true,
loading: false,
error: payload,
})),
),
withEventHandlers(
(_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
load$: events.on(portfolioPageEvents.opened).pipe(
switchMap(() =>
projects.portfolio().pipe(
mapResponse({
next: (list) => portfolioApiEvents.loaded(list),
error: (error) =>
portfolioApiEvents.loadFailed(describeError(error)),
}),
),
),
),
}),
),
);