feat: per-project workflow settings, statistics and "my work" home

Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:53:45 +02:00
parent 240ea9a539
commit 0e301ad545
143 changed files with 10291 additions and 1052 deletions

View File

@@ -1,12 +1,12 @@
import { DatasetColor } from '@pi/ui';
import {
IssueArea,
Release,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
SettingColor,
StatusCategory,
Workflow,
} from '@task-stream/web/tracker/domain';
type ChipColor =
@@ -16,111 +16,88 @@ type ChipColor =
| 'primary'
| 'success'
| 'warn';
type IconColor =
| 'primary'
| 'accent'
| 'warn'
| 'success'
| 'grey'
| 'highlighted';
/* UI copy (es) and the visual language of the tracker. */
/** `tone`: CSS colour token of the status/type (see `--ts-s-*`, `--ts-t-*` in styles.scss). */
export const STATUS: Record<
IssueStatus,
{ label: string; color: ChipColor; chart: DatasetColor; tone: string }
/** CSS colour of a settings colour token (`--ts-c-*` in styles.scss). */
export const tone = (color: string) => `var(--ts-c-${color}, var(--ts-muted))`;
/** A status as the UI shows it: the project's name and colour. */
export function statusLook(workflow: Workflow, id: string) {
const status = workflow.status(id);
return {
label: status.name,
tone: tone(status.color),
category: status.category,
};
}
/** A ticket type as the UI shows it: name, icon and colour. */
export function typeLook(workflow: Workflow, id: string) {
const type = workflow.type(id);
return { label: type.name, icon: type.icon, tone: tone(type.color) };
}
/** What each category means, whatever the project calls its statuses. */
export const CATEGORY: Record<
StatusCategory,
{ label: string; hint: string; tone: string }
> = {
[IssueStatus.BACKLOG]: {
label: 'Backlog',
color: 'grey',
chart: DatasetColor.grey,
tone: 'var(--ts-s-backlog)',
[StatusCategory.TODO]: {
label: 'Pendiente',
hint: 'Aún no empezado. Los tickets nuevos entran en el primero.',
tone: tone('slate'),
},
[IssueStatus.TODO]: {
label: 'Por hacer',
color: 'grey',
chart: DatasetColor.grey300,
tone: 'var(--ts-s-todo)',
},
[IssueStatus.IN_PROGRESS]: {
[StatusCategory.IN_PROGRESS]: {
label: 'En curso',
color: 'primary',
chart: DatasetColor.primary,
tone: 'var(--ts-s-progress)',
hint: 'Alguien trabaja en él (mide el tiempo de ciclo).',
tone: tone('violet'),
},
[IssueStatus.IN_REVIEW]: {
label: 'En revisión',
color: 'accent',
chart: DatasetColor.accent,
tone: 'var(--ts-s-review)',
[StatusCategory.QA]: {
label: 'QA',
hint: 'Esperando o en pruebas. Salir a «hecho» cuenta como QA superado.',
tone: tone('amber'),
},
[IssueStatus.READY_FOR_QA]: {
label: 'Listo para QA',
color: 'highlighted',
chart: DatasetColor.highlighted,
tone: 'var(--ts-s-ready)',
},
[IssueStatus.IN_QA]: {
label: 'En QA',
color: 'highlighted',
chart: DatasetColor.highlighted50,
tone: 'var(--ts-s-qa)',
},
[IssueStatus.QA_FAILED]: {
label: 'QA fallido',
color: 'warn',
chart: DatasetColor.warn,
tone: 'var(--ts-s-failed)',
},
[IssueStatus.DONE]: {
[StatusCategory.DONE]: {
label: 'Hecho',
color: 'success',
chart: DatasetColor.success,
tone: 'var(--ts-s-done)',
hint: 'Terminado: cuenta como resuelto en releases y estadísticas.',
tone: tone('green'),
},
[IssueStatus.CANCELLED]: {
[StatusCategory.CANCELLED]: {
label: 'Cancelado',
color: 'grey',
chart: DatasetColor.grey50,
tone: 'var(--ts-s-cancelled)',
hint: 'Descartado: no ocupa columna ni cuenta en el alcance.',
tone: tone('stone'),
},
};
export const TYPE: Record<
IssueType,
{ label: string; icon: string; color: IconColor; tone: string }
> = {
[IssueType.BUG]: {
label: 'Bug',
icon: 'bug',
color: 'warn',
tone: 'var(--ts-t-bug)',
},
[IssueType.FEATURE]: {
label: 'Feature',
icon: 'feature',
color: 'primary',
tone: 'var(--ts-t-feature)',
},
[IssueType.UI_CHANGE]: {
label: 'Cambio de UI',
icon: 'palette',
color: 'accent',
tone: 'var(--ts-t-ui)',
},
[IssueType.TASK]: {
label: 'Tarea',
icon: 'task',
color: 'grey',
tone: 'var(--ts-t-task)',
},
[IssueType.QA]: {
label: 'QA',
icon: 'qa',
color: 'highlighted',
tone: 'var(--ts-t-qa)',
},
/** Names of the icons a ticket type can pick (TYPE_ICONS). */
export const ICON_LABEL: Record<string, string> = {
bug: 'Bicho',
feature: 'Estrella',
palette: 'Paleta',
task: 'Casilla',
qa: 'Lupa',
flag: 'Bandera',
'star-fill': 'Estrella rellena',
rocket: 'Cohete',
document: 'Documento',
'light-bulb': 'Bombilla',
key: 'Llave',
support: 'Soporte',
};
export const COLOR_LABEL: Record<SettingColor, string> = {
slate: 'Pizarra',
stone: 'Piedra',
violet: 'Violeta',
blue: 'Azul',
sky: 'Cielo',
teal: 'Verde azulado',
green: 'Verde',
amber: 'Ámbar',
orange: 'Naranja',
red: 'Rojo',
pink: 'Rosa',
};
/** Signal strength of a priority, drawn as bars (1–4) like a phone signal. */
@@ -143,15 +120,6 @@ export const PRIORITY: Record<
[IssuePriority.LOWEST]: { label: 'Mínima', color: 'grey' },
};
export const AREA: Record<IssueArea, string> = {
[IssueArea.FRONTEND]: 'Frontend',
[IssueArea.BACKEND]: 'Backend',
[IssueArea.MOBILE]: 'Mobile',
[IssueArea.QA]: 'QA',
[IssueArea.DESIGN]: 'Diseño',
[IssueArea.DEVOPS]: 'DevOps',
};
export const LINK: Record<IssueLinkType, { outward: string; inward: string }> =
{
[IssueLinkType.BLOCKS]: {
@@ -249,3 +217,23 @@ export function clock(iso: string, now = new Date()): string {
? date.toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
: date.toLocaleDateString('es', { day: 'numeric', month: 'short' });
}
/** "publicada hace 2 días", "objetivo 17 oct · en 14 d", "objetivo 3 oct · retrasada 2 d". */
export function releaseWhen(release: Release, now = Date.now()): string {
if (release.releasedAt)
return `publicada ${timeAgo(release.releasedAt, now)}`;
if (!release.targetDate) return 'sin fecha';
const target = new Date(release.targetDate);
const days = Math.ceil((target.getTime() - now) / 86_400_000);
const date = target.toLocaleDateString('es', {
day: 'numeric',
month: 'short',
});
if (days < 0) return `objetivo ${date} · retrasada ${-days} d`;
return days === 0 ? 'objetivo hoy' : `objetivo ${date} · en ${days} d`;
}
/** WEB-12 → WEB (the project key prefix of a ticket key). */
export function projectKeyOf(issueKey: string): string {
return issueKey.slice(0, issueKey.lastIndexOf('-'));
}