Files
task-stream/docs/04-cliente-web.md
Josep Piguillem affce615a7 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>
2026-10-15 18:27:46 +02:00

33 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, lista, ticket, propuestas, releases, seguimiento, actividad y miembros; ts-markdown (texto con formato, saneado con DOMPurify), ts-rich-editor, ts-bulk-bar, ts-vote

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: string; 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: string, 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)
HomeFacade HomeStore raíz «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña
MyViewsFacade MyViewsStore raíz Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida
ExploreFacade ExploreStore raíz Proyectos públicos («Explorar»), también sin sesión
ProjectFacade ProjectStore ruta /p/:key Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (workflow, saveSettings) y estadísticas (loadAnalytics, loadBurnup)
BoardFacade BoardStore ruta board Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo
ListFacade ListStore ruta list Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros
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.

Notificaciones push

En la campana, «Avisos en este dispositivo» (si la API tiene claves VAPID y el navegador admite push) registra /push-sw.js, pide permiso, suscribe el navegador y manda la suscripción a la API (PushFacade + el puerto PushDevice, con BrowserPushDevice como adaptador). El service worker solo hace push: muestra el aviso y, al pulsarlo, enfoca una pestaña de TaskStream y la lleva al ticket, o abre una. No guarda nada en caché, así que un despliegue nunca deja una versión vieja; nginx sirve push-sw.js y manifest.webmanifest sin caché. Con el manifiesto la web se puede instalar, que en iPhone es condición para recibir push (iOS 16.4+).

Calendario

«Mi trabajo» tiene un botón «Calendario» que abre ts-calendar-feed: crea la URL secreta, la copia, la renueva o la desactiva (CalendarFacade, sin store). La URL es absoluta, compuesta con restUrl y el origen de la página, porque es lo que se pega en la aplicación de calendario.

Texto con formato

Descripciones y comentarios se escriben con ts-rich-editor, un editor visual sobre TipTap (ProseMirror) que lee y escribe Markdown, que es lo que guarda la API:

  • Barra con negrita, cursiva, tachado, código, título, listas, tareas, cita, bloque de código y enlace; Ctrl/Cmd+Enter envía.
  • @ sugiere a los miembros del proyecto y escribe la mención como siempre, @[Nombre](id) (un lector propio en rich-text.ts; la de TipTap usa otro formato).
  • Pegar una captura la sube como adjunto y la deja como ![nombre](attachment:<id>); dentro del editor se descarga con la sesión, reintentando unos instantes porque la API tarda en listar el adjunto nuevo.
  • Entiende también tablas, para que editar un texto antiguo no las pierda (rich-text.spec.ts comprueba la ida y vuelta).
  • Funciona con [(value)] y con [formField] de los formularios de señales.
  • Va en un trozo aparte (unos 150 kB comprimido) que solo se descarga en el ticket, el tablero y el formulario de propuestas.

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.

  • El cliente manda un ping cada 10 s y, si no llega el pong en 5 s, cierra el socket y reconecta (una conexión medio muerta, p. ej. tras suspender el portátil o pasar por un proxy, no se nota de otro modo). Al reconectar, RealtimeConnection.reconnected$ avisa y cada store vuelve a pedir lo que muestra, porque lo que pasó mientras tanto no llega por las subscriptions.

  • 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). Lo que se lee y luego se reenvía como input (la configuración del proyecto) se copia campo a campo: Apollo añade __typename a cada objeto y los input types de GraphQL lo rechazan.

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, o anulan esas reglas con !important (votos, reacciones); 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.

Configuración del proyecto en la UI

Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: ProjectFacade.workflow() envuelve project.settings (Workflow en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un estado pide motivo. statusLook/typeLook de ui/copy.ts lo convierten en etiqueta y tono (--ts-c-<color>, con su versión oscura). Un id que ya no está configurado sigue mostrándose (IN_REVIEW → «In review»). «Mi trabajo» mezcla proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.

En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos archivados no se ofrecen al crear. Los tipos con icono palette piden un frame de Figma.

Los campos personalizados se definen en Configuración (el tipo se elige al crearlos y luego queda fijo; las opciones de una lista, una por línea). En la ficha, ui/custom-field.ts pone el control de cada tipo: las listas y las fechas se guardan al elegir; el texto y los números, al salir del campo. En la lista, cada campo de opciones añade un filtro y los valores del ticket se ven bajo su título.

Gráficas

Las de Estadísticas son SVG propios (ui/series-chart.ts), no pi-line-chart: hacen falta áreas apiladas y una paleta con versión oscura, que los gráficos de @pi/ui (Chart.js con colores fijos) no traen. Siguen las reglas de visualización de la plataforma:

  • Colores --ts-viz-* en styles.scss, validados contra la superficie clara y la oscura (separación para daltonismo, contraste): dos series categóricas (azul y naranja), una rampa ordinal de un solo tono para las etapas del flujo y bueno/crítico reservados para QA superado/fallido, siempre con icono y texto.
  • Líneas de 2 px, rejilla de 1 px continua, separación de 2 px entre bandas apiladas, una sola escala por gráfica.
  • Cruceta que se ajusta al día o semana más cercano y un único tooltip con todas las series; con teclado, ← → Inicio Fin sobre la gráfica.
  • Leyenda con dos o más series, etiqueta de valor al final de cada línea solo si no se solapan, y «Ver los datos» con la tabla, para que nada dependa del color ni del ratón.
  • Mientras se recalcula un periodo, se queda la gráfica anterior atenuada.

Proyectos públicos y visitantes sin sesión

  • Solo la portada (/) exige sesión. /explorar y las páginas de proyecto (/p/:key/…) no: la API decide qué ve cada visitante, y las páginas ya ocultan lo que no puede cambiar (canContribute, canManage).
  • Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (returnUrl).
  • El tiempo real necesita sesión: GraphqlRealtimeGateway no abre subscriptions sin token (si lo hiciera, la API cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
  • Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes abierto, la subscription avisa (ProjectChanged.deleted) y también se vuelve a la portada.

4.6 Rutas

Ruta Página
/login Inicio de sesión (solo sin sesión)
/auth/callback Retorno de Pireon (PKCE)
/ Inicio: «Ir a…» (WEB-12), mis vistas guardadas (ts-my-views: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (ts-my-work) y mis proyectos
/cartera Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas
/explorar Proyectos públicos (sin sesión)
/p/:key/board Tablero kanban (?release= y ?assignee= preseleccionan los filtros)
/p/:key/issues/:issueKey Ticket. Encima del título, quién más lo está viendo o editando ahora (PresenceFacade: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno)
/p/:key/releases Releases
/p/:key/report/:kind/:id Informe para imprimir de un sprint (sprint) o una release (release): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (styles.scss)
/p/:key/roadmap Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea
/p/:key/dashboard Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: ?missing=estimate, area, assignee o ?staleDays=90), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio
/p/:key/analytics Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año)
/p/:key/settings Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, equipos (con sus miembros), definición de «Hecho» y campos personalizados; archivar en lugar de borrar
/p/:key/activity Actividad en vivo
/p/:key/members Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos

Solo / está protegida por authGuard (redirige a /login?returnUrl=…); /login y /auth/callback son para quien no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de los componentes (withComponentInputBinding).