- Each person can subscribe their dates in Google Calendar, Outlook or
Apple Calendar: GET /api/calendar/{token}.ics (no session) brings
their unfinished tickets with a due date (assigned or followed), the
planned and running sprints and the planned releases of their
projects, as all-day events linking to the web. The token is made,
renewed or dropped under /me/calendar (and in GraphQL); in the web,
from «Calendario» in «Mi trabajo».
- The backlog panel keeps the selected rows' background inside its
rounded corners.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
24 KiB
Markdown
287 lines
24 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 |
|
|
| `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.
|
|
|
|
### 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 ``; 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.
|
|
|
|
### 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`).
|