Each status can carry a WIP limit in the project settings; the board shows it next to the count, marks a column that goes over it and says so when a card is dropped there. The board can also be grouped in rows by person, epic, priority or type, remembered per browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
import {
|
|
BusinessRuleError,
|
|
ValidationError,
|
|
assertThat,
|
|
} from '@task-stream/api/shared/domain';
|
|
import { IssueArea, IssueStatus as S, IssueType } from '../issue/issue.enums';
|
|
|
|
/**
|
|
* What a status means, whatever it is called in each project: it keeps
|
|
* releases ("everything finished"), "waiting for QA" and statistics working
|
|
* with custom statuses.
|
|
*/
|
|
export enum StatusCategory {
|
|
TODO = 'TODO',
|
|
IN_PROGRESS = 'IN_PROGRESS',
|
|
QA = 'QA',
|
|
DONE = 'DONE',
|
|
CANCELLED = 'CANCELLED',
|
|
}
|
|
|
|
/** Colour tokens the web app knows (light and dark). */
|
|
export const SETTING_COLORS = [
|
|
'slate',
|
|
'stone',
|
|
'violet',
|
|
'blue',
|
|
'sky',
|
|
'teal',
|
|
'green',
|
|
'amber',
|
|
'orange',
|
|
'red',
|
|
'pink',
|
|
] as const;
|
|
export type SettingColor = (typeof SETTING_COLORS)[number];
|
|
|
|
/** Icons a ticket type can use (registered in the web app). */
|
|
export const TYPE_ICONS = [
|
|
'bug',
|
|
'feature',
|
|
'palette',
|
|
'task',
|
|
'qa',
|
|
'flag',
|
|
'star-fill',
|
|
'rocket',
|
|
'document',
|
|
'light-bulb',
|
|
'key',
|
|
'support',
|
|
] as const;
|
|
export type TypeIcon = (typeof TYPE_ICONS)[number];
|
|
|
|
export interface WorkflowStatus {
|
|
/** Stable id stored on tickets, e.g. IN_REVIEW. */
|
|
id: string;
|
|
name: string;
|
|
category: StatusCategory;
|
|
color: SettingColor;
|
|
/** Entering it needs a reason (kept in the history). */
|
|
requiresReason: boolean;
|
|
/** Archived: no new tickets or transitions into it; existing ones keep it. */
|
|
archived: boolean;
|
|
/**
|
|
* Work-in-progress limit: how many tickets the column should hold at most
|
|
* (null: no limit). A warning, not a lock: the board flags a full column.
|
|
*/
|
|
wipLimit?: number | null;
|
|
}
|
|
|
|
export interface IssueTypeOption {
|
|
id: string;
|
|
name: string;
|
|
icon: TypeIcon;
|
|
color: SettingColor;
|
|
archived: boolean;
|
|
}
|
|
|
|
export interface AreaOption {
|
|
id: string;
|
|
name: string;
|
|
archived: boolean;
|
|
}
|
|
|
|
/** Per-project configuration of the tracker. */
|
|
export interface ProjectSettings {
|
|
/** In board order. New tickets start in the first active TODO status. */
|
|
statuses: WorkflowStatus[];
|
|
/** Allowed moves: status id → status ids it can go to. */
|
|
transitions: Record<string, string[]>;
|
|
issueTypes: IssueTypeOption[];
|
|
areas: AreaOption[];
|
|
}
|
|
|
|
const status = (
|
|
id: string,
|
|
name: string,
|
|
category: StatusCategory,
|
|
color: SettingColor,
|
|
requiresReason = false,
|
|
): WorkflowStatus => ({
|
|
id,
|
|
name,
|
|
category,
|
|
color,
|
|
requiresReason,
|
|
archived: false,
|
|
});
|
|
|
|
/** What every project starts with (and what projects created before settings had). */
|
|
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
|
statuses: [
|
|
status(S.BACKLOG, 'Backlog', StatusCategory.TODO, 'stone'),
|
|
status(S.TODO, 'Por hacer', StatusCategory.TODO, 'slate'),
|
|
status(S.IN_PROGRESS, 'En curso', StatusCategory.IN_PROGRESS, 'violet'),
|
|
status(S.IN_REVIEW, 'En revisión', StatusCategory.IN_PROGRESS, 'blue'),
|
|
status(S.READY_FOR_QA, 'Listo para QA', StatusCategory.QA, 'amber'),
|
|
status(S.IN_QA, 'En QA', StatusCategory.QA, 'orange'),
|
|
status(S.QA_FAILED, 'QA fallido', StatusCategory.IN_PROGRESS, 'red', true),
|
|
status(S.DONE, 'Hecho', StatusCategory.DONE, 'green'),
|
|
status(S.CANCELLED, 'Cancelado', StatusCategory.CANCELLED, 'stone', true),
|
|
],
|
|
// QA is a first-class step: READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
|
|
transitions: {
|
|
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
|
|
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
|
|
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
|
|
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
|
|
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
|
|
[S.IN_QA]: [S.DONE, S.QA_FAILED],
|
|
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
|
|
[S.DONE]: [S.TODO, S.IN_PROGRESS],
|
|
[S.CANCELLED]: [S.BACKLOG],
|
|
},
|
|
issueTypes: [
|
|
{
|
|
id: IssueType.BUG,
|
|
name: 'Bug',
|
|
icon: 'bug',
|
|
color: 'red',
|
|
archived: false,
|
|
},
|
|
{
|
|
id: IssueType.FEATURE,
|
|
name: 'Feature',
|
|
icon: 'feature',
|
|
color: 'violet',
|
|
archived: false,
|
|
},
|
|
{
|
|
id: IssueType.UI_CHANGE,
|
|
name: 'Cambio de UI',
|
|
icon: 'palette',
|
|
color: 'pink',
|
|
archived: false,
|
|
},
|
|
{
|
|
id: IssueType.TASK,
|
|
name: 'Tarea',
|
|
icon: 'task',
|
|
color: 'slate',
|
|
archived: false,
|
|
},
|
|
{
|
|
id: IssueType.QA,
|
|
name: 'QA',
|
|
icon: 'qa',
|
|
color: 'amber',
|
|
archived: false,
|
|
},
|
|
],
|
|
areas: [
|
|
{ id: IssueArea.FRONTEND, name: 'Frontend', archived: false },
|
|
{ id: IssueArea.BACKEND, name: 'Backend', archived: false },
|
|
{ id: IssueArea.MOBILE, name: 'Mobile', archived: false },
|
|
{ id: IssueArea.QA, name: 'QA', archived: false },
|
|
{ id: IssueArea.DESIGN, name: 'Diseño', archived: false },
|
|
{ id: IssueArea.DEVOPS, name: 'DevOps', archived: false },
|
|
],
|
|
};
|
|
|
|
export function defaultProjectSettings(): ProjectSettings {
|
|
return structuredClone(DEFAULT_PROJECT_SETTINGS);
|
|
}
|
|
|
|
const ID = /^[A-Z][A-Z0-9_]{0,29}$/;
|
|
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
|
|
|
/** Read-only view of a project's workflow, used by tickets and queries. */
|
|
export class ProjectWorkflow {
|
|
constructor(readonly settings: ProjectSettings) {}
|
|
|
|
static default(): ProjectWorkflow {
|
|
return new ProjectWorkflow(DEFAULT_PROJECT_SETTINGS);
|
|
}
|
|
|
|
status(id: string): WorkflowStatus | undefined {
|
|
return this.settings.statuses.find((s) => s.id === id);
|
|
}
|
|
|
|
/** Most tickets the status should hold (null: no limit). */
|
|
wipLimit(id: string): number | null {
|
|
return this.status(id)?.wipLimit ?? null;
|
|
}
|
|
|
|
/** Name to show, also for ids no longer configured. */
|
|
statusName(id: string): string {
|
|
return (
|
|
this.status(id)?.name ??
|
|
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.name ??
|
|
id
|
|
);
|
|
}
|
|
|
|
typeName(id: string): string {
|
|
return (
|
|
this.settings.issueTypes.find((t) => t.id === id)?.name ??
|
|
DEFAULT_PROJECT_SETTINGS.issueTypes.find((t) => t.id === id)?.name ??
|
|
id
|
|
);
|
|
}
|
|
|
|
areaName(id: string): string {
|
|
return (
|
|
this.settings.areas.find((a) => a.id === id)?.name ??
|
|
DEFAULT_PROJECT_SETTINGS.areas.find((a) => a.id === id)?.name ??
|
|
id
|
|
);
|
|
}
|
|
|
|
categoryOf(id: string): StatusCategory {
|
|
return (
|
|
this.status(id)?.category ??
|
|
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.category ??
|
|
StatusCategory.TODO
|
|
);
|
|
}
|
|
|
|
isFinished(id: string): boolean {
|
|
return FINISHED.includes(this.categoryOf(id));
|
|
}
|
|
|
|
/** Where new tickets start: the first active status of category TODO. */
|
|
initialStatus(): string {
|
|
const initial = this.settings.statuses.find(
|
|
(s) => !s.archived && s.category === StatusCategory.TODO,
|
|
);
|
|
if (!initial)
|
|
throw new BusinessRuleError('The project has no active starting status');
|
|
return initial.id;
|
|
}
|
|
|
|
/** Active statuses a ticket can move to from `from`. */
|
|
allowedFrom(from: string): string[] {
|
|
return (this.settings.transitions[from] ?? []).filter(
|
|
(to) => this.status(to) && !this.status(to)?.archived,
|
|
);
|
|
}
|
|
|
|
canTransition(from: string, to: string): boolean {
|
|
return this.allowedFrom(from).includes(to);
|
|
}
|
|
|
|
requiresReason(id: string): boolean {
|
|
return this.status(id)?.requiresReason ?? false;
|
|
}
|
|
|
|
assertActiveType(id: string): void {
|
|
const type = this.settings.issueTypes.find((t) => t.id === id);
|
|
if (!type) throw new ValidationError(`Unknown ticket type ${id}`);
|
|
if (type.archived)
|
|
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
|
|
}
|
|
|
|
assertActiveArea(id: string | null | undefined): void {
|
|
if (id === null || id === undefined) return;
|
|
const area = this.settings.areas.find((a) => a.id === id);
|
|
if (!area) throw new ValidationError(`Unknown area ${id}`);
|
|
if (area.archived)
|
|
throw new BusinessRuleError(`Area ${area.name} is archived`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks and normalises new settings against the previous ones. Values can be
|
|
* added, renamed, reordered or archived, never deleted: tickets, history and
|
|
* statistics keep referring to them.
|
|
*/
|
|
export function validateProjectSettings(
|
|
next: ProjectSettings,
|
|
previous: ProjectSettings,
|
|
): ProjectSettings {
|
|
const statuses = next.statuses.map((s) => ({
|
|
id: normId(s.id),
|
|
name: normName(s.name),
|
|
category: s.category,
|
|
color: s.color,
|
|
requiresReason: !!s.requiresReason,
|
|
archived: !!s.archived,
|
|
// Only when set: settings saved unchanged must compare equal.
|
|
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
|
|
}));
|
|
const issueTypes = next.issueTypes.map((t) => ({
|
|
id: normId(t.id),
|
|
name: normName(t.name),
|
|
icon: t.icon,
|
|
color: t.color,
|
|
archived: !!t.archived,
|
|
}));
|
|
const areas = next.areas.map((a) => ({
|
|
id: normId(a.id),
|
|
name: normName(a.name),
|
|
archived: !!a.archived,
|
|
}));
|
|
|
|
for (const [label, list, before] of [
|
|
['estado', statuses, previous.statuses],
|
|
['tipo', issueTypes, previous.issueTypes],
|
|
['área', areas, previous.areas],
|
|
] as const) {
|
|
const ids = list.map((item) => item.id);
|
|
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
assertThat(!duplicate, `Hay dos ${label}s con el id ${duplicate}`);
|
|
const removed = before.find((item) => !ids.includes(item.id));
|
|
if (removed) {
|
|
throw new BusinessRuleError(
|
|
`No se puede eliminar el ${label} «${removed.name}»: archívalo para que no se use más`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const s of statuses) {
|
|
assertThat(
|
|
Object.values(StatusCategory).includes(s.category),
|
|
`Categoría no válida en el estado ${s.name}`,
|
|
);
|
|
assertThat(
|
|
SETTING_COLORS.includes(s.color),
|
|
`Color no válido en el estado ${s.name}`,
|
|
);
|
|
assertThat(
|
|
s.wipLimit === undefined ||
|
|
(Number.isInteger(s.wipLimit) && s.wipLimit >= 1 && s.wipLimit <= 999),
|
|
`El límite WIP del estado ${s.name} tiene que ser un número entre 1 y 999`,
|
|
);
|
|
}
|
|
for (const t of issueTypes) {
|
|
assertThat(
|
|
TYPE_ICONS.includes(t.icon),
|
|
`Icono no válido en el tipo ${t.name}`,
|
|
);
|
|
assertThat(
|
|
SETTING_COLORS.includes(t.color),
|
|
`Color no válido en el tipo ${t.name}`,
|
|
);
|
|
}
|
|
const active = statuses.filter((s) => !s.archived);
|
|
assertThat(
|
|
active.some((s) => s.category === StatusCategory.TODO),
|
|
'Hace falta al menos un estado activo «pendiente» donde empiecen los tickets',
|
|
);
|
|
assertThat(
|
|
active.some((s) => s.category === StatusCategory.DONE),
|
|
'Hace falta al menos un estado activo «hecho»',
|
|
);
|
|
assertThat(
|
|
issueTypes.some((t) => !t.archived),
|
|
'Hace falta al menos un tipo de ticket activo',
|
|
);
|
|
|
|
const known = new Set(statuses.map((s) => s.id));
|
|
const transitions: Record<string, string[]> = {};
|
|
for (const [from, targets] of Object.entries(next.transitions ?? {})) {
|
|
const source = normId(from);
|
|
assertThat(
|
|
known.has(source),
|
|
`Transición desde un estado que no existe: ${from}`,
|
|
);
|
|
const to = [...new Set(targets.map(normId))];
|
|
for (const target of to) {
|
|
assertThat(
|
|
known.has(target),
|
|
`Transición hacia un estado que no existe: ${target}`,
|
|
);
|
|
assertThat(
|
|
target !== source,
|
|
`Un estado no puede pasar a sí mismo (${source})`,
|
|
);
|
|
}
|
|
if (to.length) transitions[source] = to;
|
|
}
|
|
|
|
return { statuses, transitions, issueTypes, areas };
|
|
}
|
|
|
|
function normId(id: string): string {
|
|
const value = (id ?? '').trim().toUpperCase();
|
|
assertThat(
|
|
ID.test(value),
|
|
`Id no válido «${id}»: mayúsculas, números y _, empezando por letra`,
|
|
);
|
|
return value;
|
|
}
|
|
|
|
function normName(name: string): string {
|
|
const value = (name ?? '').trim();
|
|
assertThat(
|
|
value.length > 0 && value.length <= 40,
|
|
'Los nombres llevan 1-40 caracteres',
|
|
);
|
|
return value;
|
|
}
|