Files
task-stream/docs/04-cliente-web.md
Josep Piguillem 233c46e9fa feat: push notifications on each device (Web Push)
- What reaches the bell also reaches the devices where the person
  turned «Avisos en este dispositivo» on, with TaskStream closed: the
  Notifier sends each new notification encrypted (aes128gcm) and signed
  with VAPID, and forgets subscriptions the push service dropped
  (404/410). A failure never blocks the event stream.
- The API keeps one subscription per device (push_subscriptions) and
  exposes the VAPID public key in the notification settings; GraphQL
  subscribePush/unsubscribePush, REST /me/push-subscriptions. Without
  VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY push is off.
- The web registers /push-sw.js, a service worker that only shows the
  notification and opens its ticket on a tap (no caching, served
  without cache by nginx), and a manifest that makes it installable,
  which iPhones need for push.
- The bell's toggles show their text next to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:38:07 +02:00

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

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