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 = { 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 = { 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.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.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.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', [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 = ( record: Record, ) => (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('-')); }