diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index f335746..3140f25 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,18 +2,18 @@ ## Qué resuelve -| Necesidad | Cómo se resuelve | -| ---------------- | ----------------------------------------------------------------------------------------------------- | -| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | -| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | -| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | -| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | -| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket | -| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | +| Necesidad | Cómo se resuelve | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | +| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | +| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | +| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index b2ac629..4b547bb 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -195,17 +195,17 @@ globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desac ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | ----------------------------------------------- | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Mis proyectos | -| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) | -| `/p/:key/issues/:issueKey` | Ticket | -| `/p/:key/releases` | Releases | -| `/p/:key/dashboard` | Seguimiento | -| `/p/:key/activity` | Actividad en vivo | -| `/p/:key/members` | Miembros | +| Ruta | Página | +| -------------------------- | ---------------------------------------------------------------------------------------- | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/auth/callback` | Retorno de Pireon (PKCE) | +| `/` | Mis proyectos | +| `/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/activity` | Actividad en vivo | +| `/p/:key/members` | Miembros | Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de los componentes (`withComponentInputBinding`). diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index d3692d4..bd78e31 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index fdf8a3a..15bcfb2 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index 18a91a4..faca69a 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -74,7 +74,7 @@ const ALL = ''; class="ts-filter" placeholder="Persona" [options]="assigneeOptions()" - [(value)]="assignee" + [(value)]="assigneeFilter" /> (); + /** `?assignee=` (from the tracking page's team list), bound by the router. */ + readonly assignee = input(); protected readonly board = inject(BoardFacade); protected readonly project = inject(ProjectFacade); @@ -239,7 +241,7 @@ export class BoardPage { viewChild.required>('reasonForm'); /* Filters */ - protected readonly assignee = signal(ALL); + protected readonly assigneeFilter = signal(ALL); protected readonly releaseFilter = signal(ALL); protected readonly area = signal(ALL); @@ -299,11 +301,15 @@ export class BoardPage { const release = this.release(); if (release) untracked(() => this.releaseFilter.set(release)); }); + effect(() => { + const assignee = this.assignee(); + if (assignee) untracked(() => this.assigneeFilter.set(assignee)); + }); // (Re)load whenever the project or a filter changes. effect(() => { const projectId = this.project.project()?.id; const filter = { - assigneeId: this.assignee() || undefined, + assigneeId: this.assigneeFilter() || undefined, releaseId: this.releaseFilter() || undefined, area: (this.area() || undefined) as IssueArea | undefined, }; diff --git a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts index 79849b1..48f466d 100644 --- a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts @@ -4,20 +4,22 @@ import { computed, inject, } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { BOARD_COLUMNS, IssueStatus, IssueType, + ProjectRole, } from '@task-stream/web/tracker/domain'; -import { initials, STATUS, TYPE } from '../ui/copy'; +import { initials, ROLE, ROLE_TONE, STATUS, TYPE } from '../ui/copy'; /** "Seguimiento": the state of the whole project at a glance (live). */ @Component({ selector: 'ts-dashboard-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PiSkeletonComponent, PiStatCardComponent], + imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent], template: ` @if (facade.stats(); as stats) {
@@ -66,28 +68,70 @@ import { initials, STATUS, TYPE } from '../ui/copy';
-
+
-

¿Quién está con qué?

- Tickets abiertos +

Equipo

+ {{ team().length }} + {{ team().length === 1 ? 'persona' : 'personas' }} · tickets + abiertos
-
@@ -239,6 +283,68 @@ import { initials, STATUS, TYPE } from '../ui/copy'; border: 1px dashed var(--ts-line-strong); color: var(--ts-muted); } + /* Team: everyone in the project, busiest first; each row opens their board. */ + .ts-team { + display: grid; + margin: 0 -0.5rem; + padding: 0; + list-style: none; + li { + margin: 0; + } + } + .ts-team__row { + display: grid; + grid-template-columns: 1.75rem minmax(0, 1fr) minmax(4rem, 8rem) 2rem 1rem; + align-items: center; + gap: 0.75rem; + min-height: 2.75rem; + padding: 0.375rem 0.5rem; + border-radius: 0.75rem; + color: inherit; + font-size: 0.875rem; + text-decoration: none; + transition: background-color 0.15s ease; + } + a.ts-team__row:hover { + background: var(--pi-color-primary-25); + .ts-team__go { + opacity: 1; + transform: none; + } + } + a.ts-team__row:focus-visible { + outline: 2px solid var(--ts-brand); + outline-offset: 1px; + } + .ts-team__who { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem 0.5rem; + min-width: 0; + } + .ts-team__go { + color: var(--ts-brand); + opacity: 0; + transform: translateX(-3px); + transition: + opacity 0.15s ease, + transform 0.15s ease; + } + @media (max-width: 520px) { + /* On a phone the number says enough: give the room to name and role. */ + .ts-team__row { + grid-template-columns: 1.75rem minmax(0, 1fr) 2rem 1rem; + } + .ts-team__row .ts-bar { + display: none; + } + } + .ts-rows__count.is-zero { + color: var(--ts-muted); + font-weight: 400; + } .ts-bar { height: 0.625rem; border-radius: 0.25rem; @@ -330,6 +436,40 @@ export class DashboardPage { ...(this.facade.stats()?.byAssignee.map((r) => r.count) ?? [1]), ), ); + + /** + * Every member with their open tickets (0 included), busiest first. People + * who still have tickets but left the project are listed too. + */ + protected readonly team = computed(() => { + const load = new Map(); + for (const row of this.facade.stats()?.byAssignee ?? []) { + if (row.user) + load.set(row.user.id, { name: row.user.name, count: row.count }); + } + const members = this.facade.members().map((member) => ({ + id: member.user.id, + name: member.user.name, + role: member.role as ProjectRole | null, + open: load.get(member.user.id)?.count ?? 0, + })); + const former = [...load.entries()] + .filter(([id]) => !members.some((m) => m.id === id)) + .map(([id, { name, count }]) => ({ + id, + name, + role: null, + open: count, + })); + return [...members, ...former].sort( + (a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'), + ); + }); + protected readonly unassigned = computed( + () => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0, + ); + protected readonly roleLabel = (role: ProjectRole) => ROLE[role]; + protected readonly roleTone = (role: ProjectRole) => ROLE_TONE[role]; protected readonly typeOf = (key: string) => TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' }; } diff --git a/libs/web/tracker/presenters/src/lib/pages/members.page.ts b/libs/web/tracker/presenters/src/lib/pages/members.page.ts index ce7f9fb..ae94d17 100644 --- a/libs/web/tracker/presenters/src/lib/pages/members.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/members.page.ts @@ -16,14 +16,7 @@ import { import { AuthFacade } from '@task-stream/web/identity/application'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectMember, ProjectRole } from '@task-stream/web/tracker/domain'; -import { initials, ROLE } from '../ui/copy'; - -const ROLE_TONE: Record = { - [ProjectRole.OWNER]: 'var(--ts-brand)', - [ProjectRole.ADMIN]: 'var(--ts-s-review)', - [ProjectRole.MEMBER]: 'var(--ts-s-todo)', - [ProjectRole.VIEWER]: 'var(--ts-s-cancelled)', -}; +import { initials, ROLE, ROLE_TONE } from '../ui/copy'; /** Team of the project. Users come from Pireon. */ @Component({ diff --git a/libs/web/tracker/presenters/src/lib/ui/copy.ts b/libs/web/tracker/presenters/src/lib/ui/copy.ts index a846eb5..363a8f2 100644 --- a/libs/web/tracker/presenters/src/lib/ui/copy.ts +++ b/libs/web/tracker/presenters/src/lib/ui/copy.ts @@ -173,6 +173,14 @@ export const LINK: Record = }, }; +/** Colour of each project role (pills in members and tracking). */ +export const ROLE_TONE: Record = { + [ProjectRole.OWNER]: 'var(--ts-brand)', + [ProjectRole.ADMIN]: 'var(--ts-s-review)', + [ProjectRole.MEMBER]: 'var(--ts-s-todo)', + [ProjectRole.VIEWER]: 'var(--ts-s-cancelled)', +}; + export const ROLE: Record = { [ProjectRole.OWNER]: 'Propietario', [ProjectRole.ADMIN]: 'Admin',