Files
task-stream/libs/web/tracker/presenters/src/lib/ui/copy.ts
Josep Piguillem 0e301ad545 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>
2026-10-06 20:53:45 +02:00

240 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Release,
IssueLinkType,
IssuePriority,
ProjectRole,
ReleaseStatus,
SettingColor,
StatusCategory,
Workflow,
} from '@task-stream/web/tracker/domain';
type ChipColor =
| 'accent'
| 'grey'
| 'highlighted'
| 'primary'
| 'success'
| 'warn';
/* UI copy (es) and the visual language of the tracker. */
/** 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 }
> = {
[StatusCategory.TODO]: {
label: 'Pendiente',
hint: 'Aún no empezado. Los tickets nuevos entran en el primero.',
tone: tone('slate'),
},
[StatusCategory.IN_PROGRESS]: {
label: 'En curso',
hint: 'Alguien trabaja en él (mide el tiempo de ciclo).',
tone: tone('violet'),
},
[StatusCategory.QA]: {
label: 'QA',
hint: 'Esperando o en pruebas. Salir a «hecho» cuenta como QA superado.',
tone: tone('amber'),
},
[StatusCategory.DONE]: {
label: 'Hecho',
hint: 'Terminado: cuenta como resuelto en releases y estadísticas.',
tone: tone('green'),
},
[StatusCategory.CANCELLED]: {
label: 'Cancelado',
hint: 'Descartado: no ocupa columna ni cuenta en el alcance.',
tone: tone('stone'),
},
};
/** 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. */
export const PRIORITY_LEVEL: Record<IssuePriority, number> = {
[IssuePriority.CRITICAL]: 4,
[IssuePriority.HIGH]: 3,
[IssuePriority.MEDIUM]: 2,
[IssuePriority.LOW]: 1,
[IssuePriority.LOWEST]: 0,
};
export const PRIORITY: Record<
IssuePriority,
{ label: string; color: ChipColor }
> = {
[IssuePriority.CRITICAL]: { label: 'Crítica', color: 'warn' },
[IssuePriority.HIGH]: { label: 'Alta', color: 'accent' },
[IssuePriority.MEDIUM]: { label: 'Media', color: 'primary' },
[IssuePriority.LOW]: { label: 'Baja', color: 'grey' },
[IssuePriority.LOWEST]: { label: 'Mínima', color: 'grey' },
};
export const LINK: Record<IssueLinkType, { outward: string; inward: string }> =
{
[IssueLinkType.BLOCKS]: {
outward: 'bloquea a',
inward: 'está bloqueado por',
},
[IssueLinkType.DEPENDS_ON]: {
outward: 'depende de',
inward: 'es dependencia de',
},
[IssueLinkType.TESTS]: { outward: 'prueba', inward: 'es probado por' },
[IssueLinkType.DUPLICATES]: {
outward: 'duplica a',
inward: 'está duplicado por',
},
[IssueLinkType.RELATES_TO]: {
outward: 'relacionado con',
inward: 'relacionado con',
},
};
/** Colour of each project role (pills in members and tracking). */
export const ROLE_TONE: Record<ProjectRole, string> = {
[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, string> = {
[ProjectRole.OWNER]: 'Propietario',
[ProjectRole.ADMIN]: 'Admin',
[ProjectRole.MEMBER]: 'Miembro',
[ProjectRole.VIEWER]: 'Lector',
};
export const RELEASE_STATUS: Record<
ReleaseStatus,
{ label: string; color: ChipColor }
> = {
[ReleaseStatus.PLANNED]: { label: 'Planificada', color: 'primary' },
[ReleaseStatus.RELEASED]: { label: 'Publicada', color: 'success' },
[ReleaseStatus.ARCHIVED]: { label: 'Archivada', color: 'grey' },
};
export const options = <T extends string>(
record: Record<T, string | { label: string }>,
) =>
(Object.keys(record) as T[]).map((value) => {
const entry = record[value];
return { value, label: typeof entry === 'string' ? entry : entry.label };
});
/** "hace 5 min", "ayer"… */
export function timeAgo(iso: string, now = Date.now()): string {
const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
const rtf = new Intl.RelativeTimeFormat('es', { numeric: 'auto' });
if (seconds < 60) return rtf.format(-seconds, 'second');
if (seconds < 3600) return rtf.format(-Math.round(seconds / 60), 'minute');
if (seconds < 86400) return rtf.format(-Math.round(seconds / 3600), 'hour');
return rtf.format(-Math.round(seconds / 86400), 'day');
}
const PROJECT_TONES = [
'var(--ts-t-feature)',
'var(--ts-s-review)',
'var(--ts-t-ui)',
'var(--ts-t-qa)',
'var(--ts-s-done)',
'var(--ts-s-qa)',
];
/** A stable colour per project (from its key), so it is recognisable everywhere. */
export function projectTone(key: string): string {
let hash = 0;
for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
return PROJECT_TONES[hash % PROJECT_TONES.length];
}
/** "Ana García" → "AG". */
export function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
/** "14:05" today, "3 oct" otherwise (logs and histories). */
export function clock(iso: string, now = new Date()): string {
const date = new Date(iso);
return date.toDateString() === now.toDateString()
? 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('-'));
}