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:
@@ -1,18 +1,13 @@
|
||||
export enum IssueType {
|
||||
BUG = 'BUG',
|
||||
FEATURE = 'FEATURE',
|
||||
UI_CHANGE = 'UI_CHANGE',
|
||||
TASK = 'TASK',
|
||||
/**
|
||||
* What a status means, whatever each project calls it (statuses, ticket types
|
||||
* and areas are per-project settings, see ProjectSettings).
|
||||
*/
|
||||
export enum StatusCategory {
|
||||
TODO = 'TODO',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
QA = 'QA',
|
||||
}
|
||||
|
||||
export enum IssueArea {
|
||||
FRONTEND = 'FRONTEND',
|
||||
BACKEND = 'BACKEND',
|
||||
MOBILE = 'MOBILE',
|
||||
QA = 'QA',
|
||||
DESIGN = 'DESIGN',
|
||||
DEVOPS = 'DEVOPS',
|
||||
DONE = 'DONE',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
export enum IssuePriority {
|
||||
@@ -23,18 +18,6 @@ export enum IssuePriority {
|
||||
CRITICAL = 'CRITICAL',
|
||||
}
|
||||
|
||||
export enum IssueStatus {
|
||||
BACKLOG = 'BACKLOG',
|
||||
TODO = 'TODO',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
IN_REVIEW = 'IN_REVIEW',
|
||||
READY_FOR_QA = 'READY_FOR_QA',
|
||||
IN_QA = 'IN_QA',
|
||||
QA_FAILED = 'QA_FAILED',
|
||||
DONE = 'DONE',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
export enum IssueLinkType {
|
||||
BLOCKS = 'BLOCKS',
|
||||
DEPENDS_ON = 'DEPENDS_ON',
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
} from './enums';
|
||||
|
||||
export interface CreateProjectInput {
|
||||
@@ -15,10 +13,10 @@ export interface CreateProjectInput {
|
||||
|
||||
export interface CreateIssueInput {
|
||||
projectId: string;
|
||||
type: IssueType;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
area?: IssueArea | null;
|
||||
area?: string | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
@@ -34,7 +32,7 @@ export type UpdateIssueInput = Partial<
|
||||
|
||||
export interface ChangeStatusInput {
|
||||
issueId: string;
|
||||
status: IssueStatus;
|
||||
status: string;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
@@ -57,9 +55,17 @@ export interface CreateReleaseInput {
|
||||
targetDate?: string | null;
|
||||
}
|
||||
|
||||
/** "My work": tickets across all my projects. */
|
||||
export interface MyIssuesFilter {
|
||||
/** true: assigned to me. false: any ticket of my projects. */
|
||||
assignedToMe: boolean;
|
||||
categories?: StatusCategory[];
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface BoardFilter {
|
||||
projectId: string;
|
||||
assigneeId?: string;
|
||||
releaseId?: string;
|
||||
area?: IssueArea;
|
||||
area?: string;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
StatusCategory,
|
||||
} from './enums';
|
||||
import { ProjectSettings } from './workflow';
|
||||
|
||||
export interface UserRef {
|
||||
id: string;
|
||||
@@ -25,6 +24,7 @@ export interface Project {
|
||||
description: string | null;
|
||||
owner: UserRef;
|
||||
members: ProjectMember[];
|
||||
settings: ProjectSettings;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
@@ -42,10 +42,12 @@ export interface IssueSummary {
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: IssueType;
|
||||
area: IssueArea | null;
|
||||
/** Ids of the project's settings. */
|
||||
type: string;
|
||||
area: string | null;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
assignee: UserRef | null;
|
||||
release: { id: string; name: string } | null;
|
||||
@@ -55,6 +57,12 @@ export interface IssueSummary {
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** A page of tickets: the ones returned and how many match in total. */
|
||||
export interface IssuePage {
|
||||
items: IssueSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Issue extends IssueSummary {
|
||||
description: string | null;
|
||||
reporter: UserRef;
|
||||
@@ -62,7 +70,7 @@ export interface Issue extends IssueSummary {
|
||||
storyPoints: number | null;
|
||||
dueDate: string | null;
|
||||
links: IssueLink[];
|
||||
allowedTransitions: IssueStatus[];
|
||||
allowedTransitions: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -104,7 +112,7 @@ export interface CountBy<T = string> {
|
||||
export interface ProjectStats {
|
||||
total: number;
|
||||
open: number;
|
||||
byStatus: CountBy<IssueStatus>[];
|
||||
byStatus: CountBy[];
|
||||
byType: CountBy[];
|
||||
byPriority: CountBy[];
|
||||
byArea: CountBy[];
|
||||
@@ -112,11 +120,46 @@ export interface ProjectStats {
|
||||
}
|
||||
|
||||
export interface BoardColumn {
|
||||
status: IssueStatus;
|
||||
status: string;
|
||||
issues: IssueSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DurationStats {
|
||||
count: number;
|
||||
averageDays: number | null;
|
||||
/** 85% of tickets take at most this long. */
|
||||
p85Days: number | null;
|
||||
}
|
||||
|
||||
/** Statistics of a project over the last days. */
|
||||
export interface ProjectAnalytics {
|
||||
from: string;
|
||||
to: string;
|
||||
/** Per week (Monday, YYYY-MM-DD). */
|
||||
createdVsResolved: { week: string; created: number; resolved: number }[];
|
||||
/** Tickets in each status at the end of every day. */
|
||||
cumulativeFlow: {
|
||||
dates: string[];
|
||||
series: { status: string; counts: number[] }[];
|
||||
};
|
||||
/** Start of work → done. */
|
||||
cycleTime: DurationStats;
|
||||
/** Creation → done. */
|
||||
leadTime: DurationStats;
|
||||
throughput: { week: string; user: UserRef | null; count: number }[];
|
||||
qa: { passed: number; failed: number; failureRate: number | null };
|
||||
}
|
||||
|
||||
/** Scope vs finished tickets of a release, day by day. */
|
||||
export interface ReleaseBurnup {
|
||||
releaseId: string;
|
||||
targetDate: string | null;
|
||||
dates: string[];
|
||||
scope: number[];
|
||||
done: number[];
|
||||
}
|
||||
|
||||
export interface UserDirectoryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,41 +1,94 @@
|
||||
import { IssuePriority, IssueStatus, IssueType } from './enums';
|
||||
import { IssuePriority, StatusCategory } from './enums';
|
||||
|
||||
/** Statuses that require an explanation (the API rejects them without one). */
|
||||
export const REASON_REQUIRED: readonly IssueStatus[] = [
|
||||
IssueStatus.QA_FAILED,
|
||||
IssueStatus.CANCELLED,
|
||||
/** Colour tokens a status or ticket type can use (`--ts-c-*`). */
|
||||
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. */
|
||||
export const TYPE_ICONS = [
|
||||
'bug',
|
||||
'feature',
|
||||
'palette',
|
||||
'task',
|
||||
'qa',
|
||||
'flag',
|
||||
'star-fill',
|
||||
'rocket',
|
||||
'document',
|
||||
'light-bulb',
|
||||
'key',
|
||||
'support',
|
||||
] as const;
|
||||
|
||||
export interface WorkflowStatus {
|
||||
/** Stable id stored on tickets, e.g. IN_REVIEW. */
|
||||
id: string;
|
||||
name: string;
|
||||
category: StatusCategory;
|
||||
color: string;
|
||||
/** Entering it needs a reason (the API rejects it without one). */
|
||||
requiresReason: boolean;
|
||||
/** No new tickets or transitions into it; existing tickets keep it. */
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface StatusTransitions {
|
||||
from: string;
|
||||
to: string[];
|
||||
}
|
||||
|
||||
export interface IssueTypeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface AreaOption {
|
||||
id: string;
|
||||
name: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** Per-project configuration: statuses (board order), transitions, types and areas. */
|
||||
export interface ProjectSettings {
|
||||
statuses: WorkflowStatus[];
|
||||
transitions: StatusTransitions[];
|
||||
issueTypes: IssueTypeOption[];
|
||||
areas: AreaOption[];
|
||||
}
|
||||
|
||||
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
||||
StatusCategory.DONE,
|
||||
StatusCategory.CANCELLED,
|
||||
];
|
||||
|
||||
export const BOARD_COLUMNS: readonly IssueStatus[] = [
|
||||
IssueStatus.BACKLOG,
|
||||
IssueStatus.TODO,
|
||||
IssueStatus.IN_PROGRESS,
|
||||
IssueStatus.IN_REVIEW,
|
||||
IssueStatus.READY_FOR_QA,
|
||||
IssueStatus.IN_QA,
|
||||
IssueStatus.QA_FAILED,
|
||||
IssueStatus.DONE,
|
||||
/** Still needs someone's work. */
|
||||
export const OPEN_CATEGORIES: readonly StatusCategory[] = [
|
||||
StatusCategory.TODO,
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
];
|
||||
|
||||
export const STATUS_LABEL: Record<IssueStatus, string> = {
|
||||
[IssueStatus.BACKLOG]: 'Backlog',
|
||||
[IssueStatus.TODO]: 'To do',
|
||||
[IssueStatus.IN_PROGRESS]: 'In progress',
|
||||
[IssueStatus.IN_REVIEW]: 'In review',
|
||||
[IssueStatus.READY_FOR_QA]: 'Ready for QA',
|
||||
[IssueStatus.IN_QA]: 'In QA',
|
||||
[IssueStatus.QA_FAILED]: 'QA failed',
|
||||
[IssueStatus.DONE]: 'Done',
|
||||
[IssueStatus.CANCELLED]: 'Cancelled',
|
||||
};
|
||||
export function isFinished(category: StatusCategory): boolean {
|
||||
return FINISHED_CATEGORIES.includes(category);
|
||||
}
|
||||
|
||||
export const TYPE_LABEL: Record<IssueType, string> = {
|
||||
[IssueType.BUG]: 'Bug',
|
||||
[IssueType.FEATURE]: 'Feature',
|
||||
[IssueType.UI_CHANGE]: 'UI change',
|
||||
[IssueType.TASK]: 'Task',
|
||||
[IssueType.QA]: 'QA',
|
||||
};
|
||||
/** The default status where QA sends tickets back (highlighted in "my work"). */
|
||||
export const QA_FAILED = 'QA_FAILED';
|
||||
|
||||
export const PRIORITY_ORDER: readonly IssuePriority[] = [
|
||||
IssuePriority.CRITICAL,
|
||||
@@ -45,6 +98,85 @@ export const PRIORITY_ORDER: readonly IssuePriority[] = [
|
||||
IssuePriority.LOWEST,
|
||||
];
|
||||
|
||||
export function isFinished(status: IssueStatus): boolean {
|
||||
return status === IssueStatus.DONE || status === IssueStatus.CANCELLED;
|
||||
const EMPTY: ProjectSettings = {
|
||||
statuses: [],
|
||||
transitions: [],
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only lookups over a project's settings. Ids that are no longer
|
||||
* configured (or unknown) still get a readable fallback.
|
||||
*/
|
||||
export class Workflow {
|
||||
constructor(readonly settings: ProjectSettings = EMPTY) {}
|
||||
|
||||
status(id: string): WorkflowStatus {
|
||||
return (
|
||||
this.settings.statuses.find((s) => s.id === id) ?? {
|
||||
id,
|
||||
name: humanize(id),
|
||||
category: StatusCategory.TODO,
|
||||
color: 'slate',
|
||||
requiresReason: false,
|
||||
archived: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type(id: string): IssueTypeOption {
|
||||
return (
|
||||
this.settings.issueTypes.find((t) => t.id === id) ?? {
|
||||
id,
|
||||
name: humanize(id),
|
||||
icon: 'task',
|
||||
color: 'slate',
|
||||
archived: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
area(id: string): AreaOption {
|
||||
return (
|
||||
this.settings.areas.find((a) => a.id === id) ?? {
|
||||
id,
|
||||
name: humanize(id),
|
||||
archived: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Active ticket types (to create or change tickets). */
|
||||
activeTypes(): IssueTypeOption[] {
|
||||
return this.settings.issueTypes.filter((t) => !t.archived);
|
||||
}
|
||||
|
||||
activeAreas(): AreaOption[] {
|
||||
return this.settings.areas.filter((a) => !a.archived);
|
||||
}
|
||||
|
||||
requiresReason(id: string): boolean {
|
||||
return this.status(id).requiresReason;
|
||||
}
|
||||
|
||||
/** Active statuses a ticket in `from` can move to (the API has the last word). */
|
||||
allowedFrom(from: string): string[] {
|
||||
const targets =
|
||||
this.settings.transitions.find((t) => t.from === from)?.to ?? [];
|
||||
return targets.filter((to) =>
|
||||
this.settings.statuses.some((s) => s.id === to && !s.archived),
|
||||
);
|
||||
}
|
||||
|
||||
/** Palette-icon types carry a Figma frame (UI changes by default). */
|
||||
isDesignType(id: string): boolean {
|
||||
return this.type(id).icon === 'palette';
|
||||
}
|
||||
}
|
||||
|
||||
/** IN_REVIEW → "In review" (for ids missing from the settings). */
|
||||
function humanize(id: string): string {
|
||||
const text = id.replace(/_/g, ' ').toLowerCase();
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@ import {
|
||||
Comment,
|
||||
CommandResult,
|
||||
Issue,
|
||||
IssuePage,
|
||||
IssueSummary,
|
||||
Project,
|
||||
ProjectAnalytics,
|
||||
ProjectStats,
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
UserDirectoryEntry,
|
||||
} from '../models/models';
|
||||
import { ProjectSettings } from '../models/workflow';
|
||||
import {
|
||||
BoardFilter,
|
||||
ChangeStatusInput,
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
CreateReleaseInput,
|
||||
LinkIssuesInput,
|
||||
MemberInput,
|
||||
MyIssuesFilter,
|
||||
UpdateIssueInput,
|
||||
} from '../models/inputs';
|
||||
|
||||
@@ -31,6 +36,16 @@ export abstract class ProjectsGateway {
|
||||
abstract myProjects(): Observable<Project[]>;
|
||||
abstract project(idOrKey: string): Observable<Project>;
|
||||
abstract stats(projectId: string): Observable<ProjectStats>;
|
||||
/** Statistics of the last `days` days. */
|
||||
abstract analytics(
|
||||
projectId: string,
|
||||
days: number,
|
||||
): Observable<ProjectAnalytics>;
|
||||
/** Replaces statuses, transitions, types and areas (owners and admins). */
|
||||
abstract updateSettings(
|
||||
projectId: string,
|
||||
settings: ProjectSettings,
|
||||
): Observable<CommandResult>;
|
||||
abstract create(input: CreateProjectInput): Observable<CommandResult>;
|
||||
abstract addMember(input: MemberInput): Observable<CommandResult>;
|
||||
abstract changeMemberRole(input: MemberInput): Observable<CommandResult>;
|
||||
@@ -44,6 +59,8 @@ export abstract class IssuesGateway {
|
||||
abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
|
||||
abstract issue(idOrKey: string): Observable<Issue>;
|
||||
abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
|
||||
/** Tickets across all my projects (most recently updated first). */
|
||||
abstract mine(filter: MyIssuesFilter): Observable<IssuePage>;
|
||||
abstract create(input: CreateIssueInput): Observable<CommandResult>;
|
||||
abstract update(input: UpdateIssueInput): Observable<CommandResult>;
|
||||
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
|
||||
@@ -68,12 +85,15 @@ export abstract class CommentsGateway {
|
||||
|
||||
export abstract class ReleasesGateway {
|
||||
abstract list(projectId: string): Observable<Release[]>;
|
||||
/** Planned releases of all my projects, soonest target date first. */
|
||||
abstract upcoming(limit: number): Observable<Release[]>;
|
||||
abstract create(input: CreateReleaseInput): Observable<CommandResult>;
|
||||
abstract publish(
|
||||
releaseId: string,
|
||||
force: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract archive(releaseId: string): Observable<CommandResult>;
|
||||
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
|
||||
}
|
||||
|
||||
export abstract class ActivityGateway {
|
||||
|
||||
Reference in New Issue
Block a user