"¿Quién está con qué?" only listed people with open tickets. The tracking page now shows the whole team: every member with their role and open tickets (0 included), busiest first, plus former members who still have tickets and the unassigned count. Each person links to the board filtered by them: the board accepts ?assignee= like ?release=. Role colours move to ui/copy.ts (shared with the members page). Docs and Word/PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 KiB
4. Cliente web
Angular 22 (componentes standalone, zoneless, Signal Forms) en apps/web, con la misma arquitectura hexagonal que la
API repartida en libs/web/*.
4.1 Librerías
| Librería | Contenido |
|---|---|
web/shared/infrastructure |
Cliente Apollo (provideGraphql: HTTP + graphql-ws), API_CONFIG, puerto AccessTokenSource |
web/shared/ui |
Integración de @pi/ui (providePiUi), modo oscuro, iconos, ToastService, DrawerService, AppShellComponent |
web/identity/domain |
Session, CurrentUser, AuthConfig y puertos AuthGateway, ProfileGateway, SessionStorage, AuthorizationFlow |
web/identity/application |
AuthFacade (pública); por dentro AuthStore y eventos authPageEvents, authApiEvents, sessionEvents |
web/identity/infrastructure |
/api/auth/* (HTTP), me (GraphQL), localStorage, PKCE con Web Crypto; enlaza AccessTokenSource con la AuthFacade |
web/identity/presenters |
Página de login, callback de Pireon, authGuard / guestGuard, menú de usuario |
web/tracker/domain |
Modelos, enums, workflow y puertos (ProjectsGateway, IssuesGateway, TrackerRealtimeGateway…) |
web/tracker/application |
Facades ProjectsFacade, ProjectFacade, BoardFacade, IssueFacade y puerto Notifications; por dentro, stores y eventos |
web/tracker/infrastructure |
Adaptadores GraphQL con operaciones tipadas (codegen) |
web/tracker/presenters |
Páginas: proyectos, tablero, ticket, releases, seguimiento, actividad y miembros |
4.2 Estado: NgRx Signals con eventos
Las páginas no llaman a servicios ni a los stores: hablan con una facade, que lee el estado como señales y traduce cada intención en un evento. Los stores reaccionan con reducers (estado) y con handlers (efectos), y los efectos emiten nuevos eventos con el resultado.
Cada store agrupa sus eventos por origen:
*PageEvents: lo que hace el usuario (opened,issueMoved,issueCreateRequested…).*ApiEvents: lo que responde la API (loaded,loadFailed,commandSucceeded…).*RealtimeEvents: lo que llega por subscription (issueChanged,activityAdded…).
Ejemplo real (BoardStore, simplificado):
export const boardPageEvents = eventGroup({
source: 'Board Page',
events: {
opened: type<BoardFilter>(),
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(),
},
});
export const BoardStore = signalStore(
withState<BoardState>({ filter: null, columns: [], loading: false, … }),
withReducer(
on(boardPageEvents.opened, ({ payload }) => ({ filter: payload, loading: true })),
on(boardApiEvents.loaded, ({ payload }) => ({ columns: payload, loading: false })),
// Optimista: la tarjeta se mueve al instante…
on(boardPageEvents.issueMoved, ({ payload }, s) => ({ columns: moveIssue(s.columns, payload.issueId, payload.to) })),
on(boardRealtimeEvents.issueChanged, ({ payload }, s) => ({ columns: applyIssueChange(s.columns, payload.issue, s.filter) })),
),
withEventHandlers((store, events = inject(Events), issues = inject(IssuesGateway)) => ({
load$: events.on(boardPageEvents.opened).pipe(switchMap(({ payload }) => issues.board(payload).pipe(
mapResponse({ next: boardApiEvents.loaded, error: (e) => boardApiEvents.loadFailed(describeError(e)) }),
))),
// …y vuelve a su sitio si la API rechaza el cambio.
move$: events.on(boardPageEvents.issueMoved).pipe(mergeMap(({ payload }) =>
issues.changeStatus({ issueId: payload.issueId, status: payload.to, reason: payload.reason }).pipe(
mapResponse({ next: () => null, error: (e) => boardApiEvents.issueMoveFailed(describeError(e)) }),
))),
})),
);
Facades
Cada store tiene su facade en application, y es lo único que exportan esas librerías (junto al puerto
Notifications): los stores y los eventos quedan como detalle interno.
@Injectable()
export class BoardFacade {
private readonly store = inject(BoardStore);
private readonly dispatch = injectDispatch(boardPageEvents);
readonly columns = this.store.columns; // estado: señales de solo lectura
readonly loading = this.store.loading;
/** Abre el tablero si es otro proyecto; si es el mismo, solo cambia el filtro. */
show(filter: BoardFilter): void {
const same = this.store.filter()?.projectId === filter.projectId;
if (same) this.dispatch.filterChanged(filter);
else this.dispatch.opened(filter);
}
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
}
}
export const provideBoardFacade = () => [BoardStore, BoardFacade];
// En la página
@Component({ providers: [provideBoardFacade()], … })
export class BoardPage {
protected readonly board = inject(BoardFacade);
drop(event) { this.board.moveIssue(issueId, to); }
}
Qué gana la página:
- Intenciones, no eventos:
board.moveIssue(…)en lugar de conocerboardPageEventsy la forma de su payload. - Contexto implícito:
ProjectFacadeeIssueFacadeponen el id del proyecto o del ticket abierto (project.addMember(userId),issue.addComment(body)); si aún no ha cargado, el comando se ignora. - Un sitio para la lógica de presentación: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
- Tests sin plantilla: la facade se prueba con gateways falsos (
facades.spec.ts).
| Facade | Store | Ámbito | Responsabilidad |
|---|---|---|---|
AuthFacade |
AuthStore |
raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
ProjectsFacade |
ProjectsStore |
raíz | Mis proyectos; se actualiza cuando me añaden o quitan (myProjectsChanged) |
ProjectFacade |
ProjectStore |
ruta /p/:key |
Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios |
BoardFacade |
BoardStore |
ruta board |
Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
IssueFacade |
IssueStore |
ruta issues/:issueKey |
Ticket, comentarios en vivo, historial y todas sus acciones |
Las de ámbito de ruta se registran con provideProjectFacade(), provideBoardFacade() o provideIssueFacade() en los
providers del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
hijas de /p/:key reciben la misma ProjectFacade.
Otros contextos también usan la facade: los stores del tracker leen el usuario de AuthFacade, y la infraestructura
de identidad (SessionTokenSource) le pide el token para el cliente GraphQL.
Tiempo real y consistencia
- Cada store se suscribe solo a lo que muestra (
issueChanges(projectId),commentChanges(issueId)…). - Los cambios se fusionan por
version: si llega un evento más antiguo que lo que hay en pantalla, se ignora. - Tras crear algo, la query puede devolver not found unos milisegundos (el read model es eventualmente consistente); los stores lo reintentan hasta 5 veces.
- Los resultados de los comandos se comunican con el puerto
Notifications(en la app, toasts depi-snack-bar).
4.3 GraphQL
-
Apollo (
apollo-angular+@apollo/client4) con la caché desactivada: los stores de NgRx son la única fuente de verdad. -
Las subscriptions van por
graphql-wscon el token enconnectionParams; si la API respondeUNAUTHENTICATED, la sesión se cierra. -
Las operaciones están en
libs/web/*/infrastructure/src/lib/graphql/*.graphql. Tras cambiar la API o una operación:npx nx serve api # reescribe apps/api/schema.gql npx nx run web:codegen # regenera generated.ts (TypedDocumentNode) -
Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (
adapters/mappers.ts).
4.4 @pi/ui
La librería de componentes de Pireon se instala desde su Nexus (scope @pi, configurado en .npmrc; el token va en
~/.npmrc). Se integra igual que en landing-web, y conviene conocer sus particularidades:
| Particularidad | Cómo se resuelve |
|---|---|
styles/common/index.scss no compila con Material ≥ 19 |
apps/web/src/styles.scss importa sus partes una a una y declara el tema con mat.theme |
| No trae modo oscuro | Redefinimos sus tokens --pi-color-* bajo html.dark-mode .pi-shell (paleta Pireon, igual que la landing) |
PiThemeService persiste mal el modo oscuro ("false" se lee como verdadero) |
AppearanceService guarda su propia preferencia |
No trae SVGs: pi-icon busca en el registro de Material |
shared/ui/src/lib/icons.ts registra los iconos en línea, incluidos los que usa la propia librería (eye, eye-off, add…) |
| Sus textos (errores de campos, "Cerrar") van por ngx-translate | providePiStrings() los registra al arrancar, sin peticiones HTTP |
Varios inputs son obligatorios (input.required) |
pi-note necesita gap, status, title y text |
| Los campos usan Signal Forms | [formField]="form.campo" para formularios; [(value)] para controles sueltos |
Drawers y modales se abren con MatDialog |
DrawerService.open(título, template) con getDrawerConfigs de la librería |
pi-search-field es un buscador plegable (un icono) |
Para un campo de búsqueda normal se usa pi-text-field |
| button globales: min-width, padding y radio con selectores muy específicos | Los botones propios de la app usan .ts-icon-button; pi-action-button size="w100" necesita box-sizing (en styles.scss) |
| Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar ((touch)) |
Componentes en uso: pi-action-button, pi-icon-button, pi-icon, pi-note, pi-text-field, pi-email-field,
pi-password-field, pi-textarea, pi-select-field, pi-segmented-control, pi-skeleton, pi-spinner,
pi-empty-state, pi-divider, pi-drawer y pi-snack-bar.
4.5 Diseño
TaskStream es un producto de la plataforma Pireon y se ve como tal: comparte con pireon.dev (landing-web) el logo,
la paleta, la tipografía, la cabecera y las superficies. @pi/ui aporta los controles; lo propio vive en
apps/web/src/styles.scss como tokens CSS, con su versión oscura bajo html.dark-mode .pi-shell.
| De pireon.dev | En TaskStream |
|---|---|
Logo (logo.png, favicon.ico) |
apps/web/public, pintado por LogoComponent |
Rampas primary (#a347e1) y accent (#1879bf) sacadas del logo |
Las mismas brand-ramp / dark-ramp |
Inter; fondo #f7f7fa (oscuro #0f1116) |
Igual (--ts-font-body, --ts-bg) |
| Cabecera translúcida con desenfoque, borde y sombra al hacer scroll | AppShellComponent, con «Pireon | TaskStream» |
| Luces difusas violeta y azul arriba de cada página | .ts-main::before/::after |
| Eyebrow azul en mayúsculas y titulares con un tramo en degradado | .ts-eyebrow y .ts-gradient-text |
Tarjeta con lavado violeta→azul y borde en degradado (landing-card) |
.ts-panel (paneles) y .ts-card-link (tarjetas que navegan, con elevación) |
Cifras con pi-stat-card |
Resumen de Seguimiento |
Encima, el lenguaje del producto: --ts-font-mono (Geist Mono) solo para datos (claves WEB-12, contadores,
versiones) y un color por estado (--ts-s-*) y por tipo de ticket (--ts-t-*), el mismo en tablero, tarjetas,
gráficas e historial. Salen de tone en ui/copy.ts y las plantillas los aplican con [style.--tone]; utilidades
globales como .ts-pill o .ts-key leen esa variable. Las animaciones se desactivan con prefers-reduced-motion.
4.6 Rutas
| Ruta | Página |
|---|---|
/login |
Inicio de sesión (solo sin sesión) |
/auth/callback |
Retorno de Pireon (PKCE) |
/ |
Mis proyectos |
/p/:key/board |
Tablero kanban (?release= y ?assignee= preseleccionan los filtros) |
/p/:key/issues/:issueKey |
Ticket |
/p/:key/releases |
Releases |
/p/:key/dashboard |
Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
/p/:key/activity |
Actividad en vivo |
/p/:key/members |
Miembros |
Todo salvo /login y /auth/callback está protegido por authGuard. Los parámetros de ruta llegan como inputs de
los componentes (withComponentInputBinding).