Files
task-stream/docs/04-cliente-web.md
Josep Piguillem 240ea9a539 feat(web): team list on the tracking dashboard
"¿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>
2026-10-03 16:14:46 +02:00

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 conocer boardPageEvents y la forma de su payload.
  • Contexto implícito: ProjectFacade e IssueFacade ponen 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 de pi-snack-bar).

4.3 GraphQL

  • Apollo (apollo-angular + @apollo/client 4) con la caché desactivada: los stores de NgRx son la única fuente de verdad.

  • Las subscriptions van por graphql-ws con el token en connectionParams; si la API responde UNAUTHENTICATED, 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).