Files
task-stream/docs/04-cliente-web.md
Josep Piguillem f60d4c8346 feat: presence on tickets
A ticket's page beats every 10 s (touchPresence / PUT
/issues/{issue}/presence, with whether I am editing) and gets back who
else is there, editing ones first. Entries live in Redis, one hash per
ticket, and fade after 30 s without a beat; leaving the page takes me out
at once. Only for people who can read the ticket; never in the event
store. The ticket shows «Bob está editando · Ana también lo está viendo»
above its title; editing means a ticket or comment form is open or a
comment is being written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:53:47 +02:00

304 lines
32 KiB
Markdown

# 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):
```ts
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.
```ts
@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];
```
```ts
// 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](https://tiptap.dev) (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:
```sh
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 |
| `/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 |
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`).