- 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>
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 conocerboardPageEventsy la forma de su payload. - Contexto implícito:
ProjectFacadeeIssueFacadeponen 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 enrich-text.ts; la de TipTap usa otro formato).- Pegar una captura la sube como adjunto y la deja como
; 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.tscomprueba 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 depi-snack-bar).
4.3 GraphQL
-
Apollo (
apollo-angular+@apollo/client4) con la caché desactivada: los stores de NgRx son la única fuente de verdad. -
Las subscriptions van por
graphql-wscon el token enconnectionParams; si la API respondeUNAUTHENTICATED, 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__typenamea 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-*enstyles.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./explorary 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:
GraphqlRealtimeGatewayno 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).