Files
task-stream/docs/04-cliente-web.md
Josep Piguillem f2001aca88 feat: formatted text, a list view, ticket details, public proposals and votes
Formatted text
- Descriptions, comments, replies and release notes render Markdown,
  sanitised with DOMPurify (tested against scripts, handlers and
  javascript: links). A pasted image is uploaded and written as
  ![name](attachment:<id>); the web fetches it with the reader's session,
  so it works on private projects. Every text box has a preview. The
  attachment upload now answers its attachmentId.

List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
  text, open/closed, type, priority and assignee; status, priority and
  assignee change in place; bulk edit (the backlog's bar, now shared).

Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
  when reading with one query per board or page; shown on cards, list
  rows and the ticket.

Public proposals
- A public project can take bug reports and ideas from anyone
  (acceptsProposals): POST /api/explore/projects/{project}/proposals and
  the form at /p/:key/proponer. They become tickets labelled «propuesta»,
  opened by TaskStream; a honeypot field and 5 per address every 15
  minutes keep spam out. No e-mail is asked: it would be public.

Votes
- Anyone signed in votes on a public project's tickets, members on a
  private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
  voted on each ticket, buttons on the ticket and the roadmap, a column
  in the list. The showcase carries votes and the open proposals.

MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:36:11 +02:00

23 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-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
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.

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). 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.

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), mi trabajo (ts-my-work) y mis proyectos
/explorar Proyectos públicos (sin sesión)
/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/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 y áreas; archivar en lugar de borrar
/p/:key/activity Actividad en vivo
/p/:key/members Miembros

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).