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

@@ -8,7 +8,8 @@ Adaptadores GraphQL del tracker.
## Contenido
- `graphql/*.graphql`: operaciones; `generated.ts` lo genera `npx nx run web:codegen`.
- `adapters/`: implementaciones de los gateways y `mappers.ts` (GraphQL → dominio).
- `adapters/`: implementaciones de los gateways y `mappers.ts` (GraphQL → dominio, y la configuración de vuelta a
input sin `__typename`).
- `provideTrackerInfrastructure()`.
## Comandos

View File

@@ -7,6 +7,7 @@ import {
CommentsGateway,
CreateReleaseInput,
Release,
ReleaseBurnup,
ReleasesGateway,
UserDirectoryEntry,
UsersGateway,
@@ -21,7 +22,9 @@ import {
EditCommentDocument,
IssueCommentsDocument,
PublishReleaseDocument,
ReleaseBurnupDocument,
ReleasesDocument,
UpcomingReleasesDocument,
SearchUsersDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@@ -66,6 +69,12 @@ export class GraphqlReleasesGateway extends ReleasesGateway {
.pipe(map((d) => d.project.releases.map(toRelease)));
}
upcoming(limit: number): Observable<Release[]> {
return this.gql
.query(UpcomingReleasesDocument, { limit })
.pipe(map((d) => d.upcomingReleases.map(toRelease)));
}
create(input: CreateReleaseInput): Observable<CommandResult> {
return this.gql
.mutate(CreateReleaseDocument, { input })
@@ -83,6 +92,12 @@ export class GraphqlReleasesGateway extends ReleasesGateway {
.mutate(ArchiveReleaseDocument, { releaseId })
.pipe(map((d) => toCommandResult(d.archiveRelease)));
}
burnup(releaseId: string): Observable<ReleaseBurnup> {
return this.gql
.query(ReleaseBurnupDocument, { id: releaseId })
.pipe(map((d) => d.release.burnup));
}
}
@Injectable()

View File

@@ -6,9 +6,11 @@ import {
CommandResult,
CreateIssueInput,
Issue,
IssuePage,
IssuesGateway,
IssueSummary,
LinkIssuesInput,
MyIssuesFilter,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
@@ -19,6 +21,7 @@ import {
CreateIssueDocument,
IssueDocument,
LinkIssuesDocument,
MyIssuesDocument,
SearchIssuesDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
@@ -42,7 +45,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(
map((d) =>
d.project.board.map((column) => ({
status: column.status as BoardColumn['status'],
status: column.status,
total: column.total,
issues: column.issues.map(toIssueSummary),
})),
@@ -56,6 +59,21 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => toIssue(d.issue)));
}
mine(filter: MyIssuesFilter): Observable<IssuePage> {
return this.gql
.query(MyIssuesDocument, {
assignedToMe: filter.assignedToMe,
categories: filter.categories as never,
limit: filter.limit,
})
.pipe(
map((d) => ({
items: d.myIssues.items.map(toIssueSummary),
total: d.myIssues.total,
})),
);
}
search(projectId: string, text: string): Observable<IssueSummary[]> {
return this.gql
.query(SearchIssuesDocument, { filter: { projectId, search: text } })

View File

@@ -4,6 +4,8 @@ import {
CreateProjectInput,
MemberInput,
Project,
ProjectAnalytics,
ProjectSettings,
ProjectsGateway,
ProjectStats,
} from '@task-stream/web/tracker/domain';
@@ -13,12 +15,19 @@ import {
ChangeProjectMemberRoleDocument,
CreateProjectDocument,
MyProjectsDocument,
ProjectAnalyticsDocument,
ProjectDocument,
ProjectStatsDocument,
RemoveProjectMemberDocument,
UpdateProjectSettingsDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toProject, toStats } from './mappers';
import {
toCommandResult,
toProject,
toSettingsInput,
toStats,
} from './mappers';
@Injectable()
export class GraphqlProjectsGateway extends ProjectsGateway {
@@ -42,6 +51,24 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats)));
}
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
return this.gql
.query(ProjectAnalyticsDocument, { idOrKey: projectId, days })
.pipe(map((d) => d.project.analytics));
}
updateSettings(
projectId: string,
settings: ProjectSettings,
): Observable<CommandResult> {
return this.gql
.mutate(UpdateProjectSettingsDocument, {
projectId,
settings: toSettingsInput(settings),
})
.pipe(map((d) => toCommandResult(d.updateProjectSettings)));
}
create(input: CreateProjectInput): Observable<CommandResult> {
return this.gql
.mutate(CreateProjectDocument, { input })

View File

@@ -5,6 +5,7 @@ import type {
Issue,
IssueSummary,
Project,
ProjectSettings,
ProjectStats,
Release,
} from '@task-stream/web/tracker/domain';
@@ -15,6 +16,7 @@ import type {
IssueFieldsFragment,
IssueSummaryFieldsFragment,
ProjectFieldsFragment,
ProjectSettingsInput,
ProjectStatsQuery,
ReleaseFieldsFragment,
} from '../graphql/generated';
@@ -27,7 +29,13 @@ import type {
const as = <T>(value: unknown): T => value as T;
export const toProject = (p: ProjectFieldsFragment): Project => as<Project>(p);
export const toProject = (p: ProjectFieldsFragment): Project => ({
...as<Project>(p),
// Plain values (no __typename): pages compare and send them back as input.
settings: as<ProjectSettings>(
toSettingsInput(as<ProjectSettings>(p.settings)),
),
});
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
as<IssueSummary>(i);
export const toIssue = (i: IssueFieldsFragment): Issue => as<Issue>(i);
@@ -53,3 +61,35 @@ export function toStats(
): ProjectStats {
return as<ProjectStats>(stats);
}
/**
* Domain → GraphQL input, field by field: settings read from the API carry
* `__typename`, which input types reject.
*/
export function toSettingsInput(
settings: ProjectSettings,
): ProjectSettingsInput {
return {
statuses: settings.statuses.map((s) => ({
id: s.id,
name: s.name,
category: s.category,
color: s.color,
requiresReason: s.requiresReason,
archived: s.archived,
})),
transitions: settings.transitions.map((t) => ({ from: t.from, to: t.to })),
issueTypes: settings.issueTypes.map((t) => ({
id: t.id,
name: t.name,
icon: t.icon,
color: t.color,
archived: t.archived,
})),
areas: settings.areas.map((a) => ({
id: a.id,
name: a.name,
archived: a.archived,
})),
};
}

View File

@@ -19,6 +19,32 @@ fragment ProjectFields on Project {
...UserRefFields
}
}
settings {
statuses {
id
name
category
color
requiresReason
archived
}
transitions {
from
to
}
issueTypes {
id
name
icon
color
archived
}
areas {
id
name
archived
}
}
}
fragment IssueSummaryFields on Issue {
@@ -29,6 +55,7 @@ fragment IssueSummaryFields on Issue {
area
title
status
statusCategory
priority
labels
commentCount

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,4 @@
query Board(
$idOrKey: String!
$releaseId: ID
$assigneeId: ID
$area: IssueArea
) {
query Board($idOrKey: String!, $releaseId: ID, $assigneeId: ID, $area: String) {
project(idOrKey: $idOrKey) {
id
board(
@@ -105,3 +100,20 @@ mutation DeleteComment($commentId: ID!) {
...CommandResultFields
}
}
query MyIssues(
$assignedToMe: Boolean!
$categories: [StatusCategory!]
$limit: Int!
) {
myIssues(
assignedToMe: $assignedToMe
categories: $categories
limit: $limit
) {
total
items {
...IssueSummaryFields
}
}
}

View File

@@ -73,3 +73,56 @@ query SearchUsers($search: String!) {
email
}
}
mutation UpdateProjectSettings(
$projectId: ID!
$settings: ProjectSettingsInput!
) {
updateProjectSettings(projectId: $projectId, settings: $settings) {
...CommandResultFields
}
}
query ProjectAnalytics($idOrKey: String!, $days: Int!) {
project(idOrKey: $idOrKey) {
id
analytics(days: $days) {
from
to
createdVsResolved {
week
created
resolved
}
cumulativeFlow {
dates
series {
status
counts
}
}
cycleTime {
count
averageDays
p85Days
}
leadTime {
count
averageDays
p85Days
}
throughput {
week
count
user {
...UserRefFields
}
}
qa {
passed
failed
failureRate
}
}
}
}

View File

@@ -35,3 +35,27 @@ query Activity($projectId: ID!, $issueId: ID) {
...ActivityFields
}
}
query UpcomingReleases($limit: Int!) {
upcomingReleases(limit: $limit) {
...ReleaseFields
progress {
total
finished
percent
}
}
}
query ReleaseBurnup($id: ID!) {
release(id: $id) {
id
burnup {
releaseId
targetDate
dates
scope
done
}
}
}