# 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(), issueMoved: type<{ issueId: string; to: string; reason?: string | null }>(), }, }); export const BoardStore = signalStore( withState({ 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` | ruta `/` (portada) | «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 | | `MyHomeFacade` | `MyHomeStore` | ruta `/` (portada) | El resto de la portada (`myHome`): agenda, actividad, cifras de la semana, sprints, tarjetas, sugerencias, primeros pasos y quién está; se recarga cada minuto y al volver a la pestaña | | `MyViewsFacade` | `MyViewsStore` | ruta `/` (portada) | 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` | ruta `explore` | 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, prioridad, estimación y persona cambiadas desde la tarjeta, 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 | En la raíz solo queda lo que usa la cabecera: la sesión (`AuthFacade`), mis proyectos para la paleta de comandos (`ProjectsFacade`) y la campana (`InboxFacade`, `PushFacade`). La paleta y la campana se cargan con `@defer (on idle; on interaction)`: su código y sus consultas no están en el arranque, y mientras tanto la cabecera muestra una copia quieta con la misma caja, así que nada se mueve al llegar. Lo demás es de su página: - Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los `providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`, `provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`, `provideSharedDashboardFacade()`): cargan con la página y el inyector de la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga. - `SharingFacade` (el enlace para clientes en Configuración) va en los `providers` de su propio componente. - Las de un proyecto o un ticket 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`. Los adaptadores siguen el mismo reparto: `provideTrackerShellInfrastructure()` (proyectos, tickets, avisos, push y tiempo real) va en la raíz, y `provideTrackerPagesInfrastructure()` (el resto) en la ruta del tracker, que se carga en diferido (`apps/web/src/app/tracker.routes.ts`). Las librerías web declaran `"sideEffects": false` (`libs/web/package.json`), para que el empaquetador deje en cada trozo solo lo que usa y no todo lo que exporta un `index.ts`. 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:)`; 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-`, 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. `/explore` 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 en General la casilla «Proyecto público» (propietario y admins) y, en Zona peligrosa 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: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/portfolio` | 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; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/explore` | Proyectos públicos (sin sesión) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace) | | `/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, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | | `/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), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | | `/p/:key/activity` | Actividad en vivo | | `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | | `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | | `/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`). Las rutas van en inglés; las antiguas en castellano (`/cartera`, `/explorar`, `/p/:key/proponer`) redirigen a las nuevas, para que sigan valiendo los enlaces ya compartidos.