diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 689eaad..8850357 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -433,6 +433,9 @@ describe('task-stream (e2e)', () => { '/api/projects/{project}/issues', '/api/issues/{issue}/status', '/api/releases/{releaseId}/publish', + '/api/projects/{project}/settings', + '/api/projects/{project}/analytics', + '/api/releases/{releaseId}/burnup', '/api/users/me', ]), ); @@ -552,9 +555,323 @@ describe('task-stream (e2e)', () => { ); expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } }); + // --- My work across projects --- + const mine = await eventually( + () => rest('token-qa', 'GET', '/me/issues'), + (r) => r.body?.['items']?.some((i: { key: string }) => i.key === beKey), + ); + // Created by Quinn but assigned to nobody: not in "assigned to me". + expect(mine.body['items'].map((i: { key: string }) => i.key)).not.toContain( + qaKey, + ); + const todo = await rest( + 'token-ana', + 'GET', + '/me/issues?assignedToMe=false&status=TODO', + ); + expect(todo.body['items'].map((i: { key: string }) => i.key)).toContain( + beKey, + ); + const viaGraphql = await gql( + 'token-qa', + `{ myIssues { total items { key assignee { id } } } }`, + ); + expect(viaGraphql['myIssues'].items).toEqual( + expect.arrayContaining([{ key: beKey, assignee: { id: '103' } }]), + ); + await rest('token-ana', 'POST', `/projects/${key}/releases`, { + name: 'v9.9.9', + targetDate: '2099-01-01', + }); + await eventually( + () => rest('token-ana', 'GET', '/me/releases?limit=50'), + (r) => + Array.isArray(r.body) && + r.body.some((rel: { name: string }) => rel.name === 'v9.9.9'), + ); + // Not a member of the project: none of its tickets. + const outsider = await rest('token-eve', 'GET', '/me/issues'); + expect( + outsider.body['items'].map((i: { key: string }) => i.key), + ).not.toContain(beKey); + // --- Same authorisation as GraphQL --- expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe( 404, ); }); + + it('lets each project customise its workflow, types and areas, and measures it', async () => { + const key = 'S' + Date.now().toString(36).slice(-5).toUpperCase(); + const projectId = ( + await gql( + 'token-ana', + `mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`, + { i: { key, name: 'Custom workflow' } }, + ) + ).createProject.id; + await eventually( + () => + gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, { + k: key, + }), + (d) => !!d.project, + ); + await gql( + 'token-ana', + `mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`, + { i: { projectId, userId: '102', role: 'MEMBER' } }, + ); + + // --- Every project starts with the default settings --- + const settingsQuery = `query($k: String!) { project(idOrKey: $k) { settings { + statuses { id name category color requiresReason archived } + transitions { from to } + issueTypes { id name icon color archived } + areas { id name archived } + } } }`; + const { settings } = (await gql('token-ana', settingsQuery, { k: key })) + .project; + expect(settings.statuses[0]).toEqual({ + id: 'BACKLOG', + name: 'Backlog', + category: 'TODO', + color: 'stone', + requiresReason: false, + archived: false, + }); + expect(settings.areas.map((a: { id: string }) => a.id)).toContain('MOBILE'); + + // --- A staging step, a Spike type, a Data area; Mobile archived --- + type Item = { id: string; archived?: boolean }; + const reviewAt = settings.statuses.findIndex( + (s: Item) => s.id === 'IN_REVIEW', + ); + settings.statuses.splice(reviewAt + 1, 0, { + id: 'STAGING', + name: 'En staging', + category: 'QA', + color: 'teal', + requiresReason: false, + archived: false, + }); + settings.transitions = settings.transitions.map( + (t: { from: string; to: string[] }) => + t.from === 'IN_REVIEW' + ? { from: 'IN_REVIEW', to: ['STAGING', 'IN_PROGRESS'] } + : t, + ); + settings.transitions.push({ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] }); + settings.issueTypes.push({ + id: 'SPIKE', + name: 'Spike', + icon: 'light-bulb', + color: 'teal', + archived: false, + }); + settings.areas = settings.areas.map((a: Item) => + a.id === 'MOBILE' ? { ...a, archived: true } : a, + ); + settings.areas.push({ id: 'DATA', name: 'Datos', archived: false }); + + const save = (token: string, next: object) => + gql( + token, + `mutation($p: ID!, $s: ProjectSettingsInput!) { updateProjectSettings(projectId: $p, settings: $s) { version } }`, + { p: projectId, s: next }, + ); + await expect(save('token-bob', settings)).rejects.toThrow(/owner|admin/i); + await expect( + save('token-ana', { + ...settings, + areas: settings.areas.filter((a: Item) => a.id !== 'QA'), + }), + ).rejects.toThrow(/archívalo/); + await save('token-ana', settings); + await eventually( + () => gql('token-ana', settingsQuery, { k: key }), + (d) => d.project.settings.statuses.some((s: Item) => s.id === 'STAGING'), + ); + + // --- Tickets follow the project's settings --- + const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key } }`; + await expect( + gql('token-bob', create, { + i: { projectId, type: 'EPIC', title: 'Unknown type' }, + }), + ).rejects.toThrow(/Unknown ticket type/); + await expect( + gql('token-bob', create, { + i: { projectId, type: 'TASK', title: 'Old area', area: 'MOBILE' }, + }), + ).rejects.toThrow(/archived/); + const spike = ( + await gql('token-bob', create, { + i: { + projectId, + type: 'SPIKE', + title: 'Evaluate a column store', + area: 'DATA', + assigneeId: '102', + }, + }) + ).createIssue; + + const release = ( + await gql( + 'token-ana', + `mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`, + { i: { projectId, name: 'v1.0.0' } }, + ) + ).createRelease; + await eventually( + () => + gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, { + id: release.id, + }), + (d) => !!d.release, + ); + await gql( + 'token-ana', + `mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`, + { i: spike.id, r: release.id }, + ); + + const move = (status: string) => + gql( + 'token-bob', + `mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`, + { i: { issueId: spike.id, status } }, + ); + for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW']) + await move(status); + const inReview = await eventually( + () => + gql( + 'token-bob', + `query($k: String!) { issue(idOrKey: $k) { status statusCategory allowedTransitions } }`, + { k: spike.key }, + ), + (d) => d.issue.status === 'IN_REVIEW', + ); + expect(inReview.issue).toEqual({ + status: 'IN_REVIEW', + statusCategory: 'IN_PROGRESS', + allowedTransitions: ['STAGING', 'IN_PROGRESS'], + }); + await expect(move('READY_FOR_QA')).rejects.toThrow(/Cannot move/); + await move('STAGING'); + await move('DONE'); + + // --- Board, history, releases and statistics use the categories --- + const { project } = await eventually( + () => + gql( + 'token-ana', + `query($k: String!) { project(idOrKey: $k) { + board { status total } + stats { total open } + releases { progress { finished percent } burnup { scope done } } + analytics(days: 30) { + createdVsResolved { created resolved } + cumulativeFlow { series { status counts } } + cycleTime { count } + leadTime { count } + throughput { user { id } count } + qa { passed failed failureRate } + } + } }`, + { k: key }, + ), + (d) => d.project.analytics.cycleTime.count === 1, + ); + const columns = project.board.map((c: { status: string }) => c.status); + expect(columns.indexOf('STAGING')).toBe(columns.indexOf('IN_REVIEW') + 1); + expect(columns).not.toContain('CANCELLED'); + expect(project.stats).toEqual({ total: 1, open: 0 }); + expect(project.releases[0].progress).toEqual({ finished: 1, percent: 100 }); + expect(project.releases[0].burnup.scope.at(-1)).toBe(1); + expect(project.releases[0].burnup.done.at(-1)).toBe(1); + const analytics = project.analytics; + const sum = ( + rows: { created: number; resolved: number }[], + f: 'created' | 'resolved', + ) => rows.reduce((total, row) => total + row[f], 0); + expect(sum(analytics.createdVsResolved, 'created')).toBe(1); + expect(sum(analytics.createdVsResolved, 'resolved')).toBe(1); + expect(analytics.cumulativeFlow.series).toEqual([ + expect.objectContaining({ status: 'DONE' }), + ]); + expect(analytics.leadTime.count).toBe(1); + expect(analytics.throughput).toEqual([{ user: { id: '102' }, count: 1 }]); + // STAGING is a QA-category step: leaving it to DONE is a QA pass. + expect(analytics.qa).toEqual({ passed: 1, failed: 0, failureRate: 0 }); + + const history = await gql( + 'token-ana', + `query($k: String!) { issue(idOrKey: $k) { history { summary } } }`, + { k: spike.key }, + ); + expect( + history.issue.history.map((h: { summary: string }) => h.summary), + ).toContain( + `Bob (frontend) movió ${spike.key} de En revisión a En staging`, + ); + const done = await gql( + 'token-bob', + `{ myIssues(categories: [DONE]) { items { key } } }`, + ); + expect(done.myIssues.items).toEqual( + expect.arrayContaining([{ key: spike.key }]), + ); + + // --- Same over REST --- + const viaRest = await rest('token-ana', 'GET', `/projects/${key}`); + expect(viaRest.body['settings']['transitions']).toEqual( + expect.arrayContaining([ + { from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] }, + ]), + ); + const renamed = viaRest.body['settings']; + renamed.statuses = renamed.statuses.map((s: Item & { name: string }) => + s.id === 'STAGING' ? { ...s, name: 'Preproducción' } : s, + ); + expect( + (await rest('token-ana', 'PUT', `/projects/${key}/settings`, renamed)) + .status, + ).toBe(200); + const removed = await rest( + 'token-ana', + 'PUT', + `/projects/${key}/settings`, + { ...renamed, statuses: [] }, + ); + expect(removed.status).toBe(422); + expect(removed.body['message']).toMatch(/archívalo/); + const neon = await rest('token-ana', 'PUT', `/projects/${key}/settings`, { + ...renamed, + areas: [...renamed.areas, { id: 'X', name: 'X', color: 'neon' }], + issueTypes: [ + ...renamed.issueTypes, + { id: 'Y', name: 'Y', icon: 'bug', color: 'neon' }, + ], + }); + expect(neon.status).toBe(400); + const stats = await rest( + 'token-ana', + 'GET', + `/projects/${key}/analytics?days=30`, + ); + expect(stats.status).toBe(200); + expect(stats.body['cycleTime']['count']).toBe(1); + const burnup = await rest( + 'token-ana', + 'GET', + `/releases/${release.id}/burnup`, + ); + expect(burnup.body).toMatchObject({ releaseId: release.id }); + expect( + (await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status, + ).toBe(404); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 13c7a53..36e24ec 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -634,7 +634,7 @@ "pireon": [] } ], - "summary": "A project with its members", + "summary": "A project with its members and settings", "tags": [ "Projects" ] @@ -749,6 +749,118 @@ ] } }, + "/api/projects/{project}/settings": { + "put": { + "description": "Replaces the whole configuration (send what GET /projects/{project} returns, changed). Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them.", + "operationId": "ProjectsController_updateSettings", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectSettingsBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Statuses, transitions, ticket types and areas (owner/admin)", + "tags": [ + "Projects" + ] + } + }, "/api/projects/{project}/board": { "get": { "operationId": "ProjectsController_board", @@ -786,8 +898,10 @@ "name": "area", "required": false, "in": "query", + "description": "An area id", "schema": { - "$ref": "#/components/schemas/IssueArea" + "example": "FRONTEND", + "type": "string" } }, { @@ -956,6 +1070,103 @@ ] } }, + "/api/projects/{project}/analytics": { + "get": { + "description": "Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail.", + "operationId": "ProjectsController_analytics", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "days", + "required": false, + "in": "query", + "schema": { + "minimum": 7, + "maximum": 365, + "default": 90, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectAnalyticsDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Statistics of the last `days` days", + "tags": [ + "Projects" + ] + } + }, "/api/projects/{project}/activity": { "get": { "operationId": "ProjectsController_activity", @@ -1413,6 +1624,145 @@ ] } }, + "/api/me/issues": { + "get": { + "description": "Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).", + "operationId": "IssuesController_mine", + "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "default": 0, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "assignedToMe", + "required": false, + "in": "query", + "description": "true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)", + "schema": { + "default": true, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "status", + "required": false, + "in": "query", + "description": "Status ids. Repeat the parameter or separate with commas", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "category", + "required": false, + "in": "query", + "description": "Whatever each project calls its statuses", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatusCategory" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuePageDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "My work: tickets across all my projects", + "tags": [ + "Issues" + ] + } + }, "/api/projects/{project}/issues": { "get": { "operationId": "IssuesController_search", @@ -1460,11 +1810,22 @@ "name": "status", "required": false, "in": "query", - "description": "Repeat the parameter or separate with commas", + "description": "Status ids. Repeat the parameter or separate with commas", "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/IssueStatus" + "type": "string" + } + } + }, + { + "name": "category", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatusCategory" } } }, @@ -1472,10 +1833,11 @@ "name": "type", "required": false, "in": "query", + "description": "Ticket type ids", "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/IssueType" + "type": "string" } } }, @@ -1483,10 +1845,11 @@ "name": "area", "required": false, "in": "query", + "description": "Area ids", "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/IssueArea" + "type": "string" } } }, @@ -1615,7 +1978,7 @@ ] }, "post": { - "description": "Starts in BACKLOG. The response carries its key (e.g. WEB-13).", + "description": "Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13).", "operationId": "IssuesController_create", "parameters": [ { @@ -3017,6 +3380,95 @@ ] } }, + "/api/me/releases": { + "get": { + "operationId": "ReleasesController_upcoming", + "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 50, + "default": 10, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Planned releases of all my projects, soonest target date first", + "tags": [ + "Releases" + ] + } + }, "/api/projects/{project}/releases": { "get": { "operationId": "ReleasesController_list", @@ -3475,6 +3927,85 @@ ] } }, + "/api/releases/{releaseId}/burnup": { + "get": { + "operationId": "ReleasesController_burnup", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseBurnupDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Burnup: tickets in the release and finished ones, day by day", + "tags": [ + "Releases" + ] + } + }, "/api/releases/{releaseId}/issues": { "get": { "operationId": "ReleasesController_issuesOf", @@ -4120,6 +4651,210 @@ "role" ] }, + "StatusCategory": { + "type": "string", + "enum": [ + "TODO", + "IN_PROGRESS", + "QA", + "DONE", + "CANCELLED" + ] + }, + "SettingColor": { + "type": "string", + "enum": [ + "slate", + "stone", + "violet", + "blue", + "sky", + "teal", + "green", + "amber", + "orange", + "red", + "pink" + ] + }, + "WorkflowStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "IN_REVIEW", + "description": "Stable id on tickets" + }, + "name": { + "type": "string", + "example": "En revisión" + }, + "category": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusCategory" + } + ] + }, + "color": { + "allOf": [ + { + "$ref": "#/components/schemas/SettingColor" + } + ] + }, + "requiresReason": { + "type": "boolean", + "description": "Entering it needs a reason" + }, + "archived": { + "type": "boolean", + "description": "No new tickets or transitions into it" + } + }, + "required": [ + "id", + "name", + "category", + "color", + "requiresReason", + "archived" + ] + }, + "StatusTransitionsDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "example": "IN_QA" + }, + "to": { + "example": [ + "DONE", + "QA_FAILED" + ], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "from", + "to" + ] + }, + "TypeIcon": { + "type": "string", + "enum": [ + "bug", + "feature", + "palette", + "task", + "qa", + "flag", + "star-fill", + "rocket", + "document", + "light-bulb", + "key", + "support" + ] + }, + "IssueTypeOptionDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "BUG" + }, + "name": { + "type": "string", + "example": "Bug" + }, + "icon": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeIcon" + } + ] + }, + "color": { + "allOf": [ + { + "$ref": "#/components/schemas/SettingColor" + } + ] + }, + "archived": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "icon", + "color", + "archived" + ] + }, + "AreaOptionDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "FRONTEND" + }, + "name": { + "type": "string", + "example": "Frontend" + }, + "archived": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "archived" + ] + }, + "ProjectSettingsDto": { + "type": "object", + "properties": { + "statuses": { + "description": "In board order", + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowStatusDto" + } + }, + "transitions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatusTransitionsDto" + } + }, + "issueTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueTypeOptionDto" + } + }, + "areas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AreaOptionDto" + } + } + }, + "required": [ + "statuses", + "transitions", + "issueTypes", + "areas" + ] + }, "ProjectDto": { "type": "object", "properties": { @@ -4149,6 +4884,9 @@ "$ref": "#/components/schemas/ProjectMemberDto" } }, + "settings": { + "$ref": "#/components/schemas/ProjectSettingsDto" + }, "createdAt": { "type": "string", "format": "date-time" @@ -4169,6 +4907,7 @@ "description", "owner", "members", + "settings", "createdAt", "updatedAt", "version" @@ -4235,39 +4974,166 @@ } } }, - "IssueArea": { - "type": "string", - "enum": [ - "FRONTEND", - "BACKEND", - "MOBILE", - "QA", - "DESIGN", - "DEVOPS" + "WorkflowStatusBodyDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "IN_REVIEW", + "description": "Never change an existing id" + }, + "name": { + "type": "string", + "example": "En revisión", + "maxLength": 40 + }, + "category": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusCategory" + } + ] + }, + "color": { + "allOf": [ + { + "$ref": "#/components/schemas/SettingColor" + } + ] + }, + "requiresReason": { + "type": "object", + "default": false + }, + "archived": { + "type": "object", + "default": false + } + }, + "required": [ + "id", + "name", + "category", + "color" ] }, - "IssueStatus": { - "type": "string", - "enum": [ - "BACKLOG", - "TODO", - "IN_PROGRESS", - "IN_REVIEW", - "READY_FOR_QA", - "IN_QA", - "QA_FAILED", - "DONE", - "CANCELLED" + "StatusTransitionsBodyDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "example": "IN_QA" + }, + "to": { + "example": [ + "DONE", + "QA_FAILED" + ], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "from", + "to" ] }, - "IssueType": { - "type": "string", - "enum": [ - "BUG", - "FEATURE", - "UI_CHANGE", - "TASK", - "QA" + "IssueTypeOptionBodyDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "SPIKE" + }, + "name": { + "type": "string", + "example": "Spike", + "maxLength": 40 + }, + "icon": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeIcon" + } + ] + }, + "color": { + "allOf": [ + { + "$ref": "#/components/schemas/SettingColor" + } + ] + }, + "archived": { + "type": "object", + "default": false + } + }, + "required": [ + "id", + "name", + "icon", + "color" + ] + }, + "AreaOptionBodyDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "DATA" + }, + "name": { + "type": "string", + "example": "Datos", + "maxLength": 40 + }, + "archived": { + "type": "object", + "default": false + } + }, + "required": [ + "id", + "name" + ] + }, + "ProjectSettingsBodyDto": { + "type": "object", + "properties": { + "statuses": { + "description": "In board order. Values cannot be removed: archive them instead.", + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowStatusBodyDto" + } + }, + "transitions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatusTransitionsBodyDto" + } + }, + "issueTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueTypeOptionBodyDto" + } + }, + "areas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AreaOptionBodyDto" + } + } + }, + "required": [ + "statuses", + "transitions", + "issueTypes", + "areas" ] }, "IssuePriority": { @@ -4374,23 +5240,14 @@ "description": "Human key" }, "type": { - "allOf": [ - { - "$ref": "#/components/schemas/IssueType" - } - ] + "type": "string", + "example": "BUG", + "description": "A ticket type id" }, "area": { "type": "string", - "enum": [ - "FRONTEND", - "BACKEND", - "MOBILE", - "QA", - "DESIGN", - "DEVOPS" - ], - "nullable": true + "nullable": true, + "example": "FRONTEND" }, "title": { "type": "string" @@ -4400,17 +5257,22 @@ "nullable": true }, "status": { + "type": "string", + "example": "IN_PROGRESS", + "description": "A status id" + }, + "statusCategory": { "allOf": [ { - "$ref": "#/components/schemas/IssueStatus" + "$ref": "#/components/schemas/StatusCategory" } ] }, "allowedTransitions": { + "description": "Statuses this ticket can move to next (project's workflow)", "type": "array", - "description": "Statuses this ticket can move to next", "items": { - "$ref": "#/components/schemas/IssueStatus" + "type": "string" } }, "priority": { @@ -4493,6 +5355,7 @@ "title", "description", "status", + "statusCategory", "allowedTransitions", "priority", "reporter", @@ -4513,11 +5376,8 @@ "type": "object", "properties": { "status": { - "allOf": [ - { - "$ref": "#/components/schemas/IssueStatus" - } - ] + "type": "string", + "example": "IN_PROGRESS" }, "issues": { "type": "array", @@ -4540,11 +5400,9 @@ "type": "object", "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/IssueStatus" - } - ] + "type": "string", + "example": "IN_PROGRESS", + "description": "Status id" }, "count": { "type": "number" @@ -4645,6 +5503,203 @@ "byAssignee" ] }, + "CreatedResolvedDto": { + "type": "object", + "properties": { + "week": { + "type": "string", + "format": "date", + "description": "Monday of the week" + }, + "created": { + "type": "number" + }, + "resolved": { + "type": "number" + } + }, + "required": [ + "week", + "created", + "resolved" + ] + }, + "FlowSeriesDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "IN_PROGRESS" + }, + "counts": { + "description": "One value per date", + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "status", + "counts" + ] + }, + "CumulativeFlowDto": { + "type": "object", + "properties": { + "dates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "series": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FlowSeriesDto" + } + } + }, + "required": [ + "dates", + "series" + ] + }, + "DurationStatsDto": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "averageDays": { + "type": "number", + "nullable": true + }, + "p85Days": { + "type": "number", + "nullable": true, + "description": "85% of tickets take at most this long" + } + }, + "required": [ + "count", + "averageDays", + "p85Days" + ] + }, + "ThroughputDto": { + "type": "object", + "properties": { + "week": { + "type": "string", + "format": "date" + }, + "user": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserRefDto" + } + ] + }, + "count": { + "type": "number" + } + }, + "required": [ + "week", + "user", + "count" + ] + }, + "QaStatsDto": { + "type": "object", + "properties": { + "passed": { + "type": "number", + "description": "Left QA to a done status" + }, + "failed": { + "type": "number", + "description": "Left QA back to work" + }, + "failureRate": { + "type": "number", + "nullable": true, + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "passed", + "failed", + "failureRate" + ] + }, + "ProjectAnalyticsDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "createdVsResolved": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreatedResolvedDto" + } + }, + "cumulativeFlow": { + "description": "Tickets in each status at the end of every day", + "allOf": [ + { + "$ref": "#/components/schemas/CumulativeFlowDto" + } + ] + }, + "cycleTime": { + "description": "Start of work → done", + "allOf": [ + { + "$ref": "#/components/schemas/DurationStatsDto" + } + ] + }, + "leadTime": { + "description": "Creation → done", + "allOf": [ + { + "$ref": "#/components/schemas/DurationStatsDto" + } + ] + }, + "throughput": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThroughputDto" + } + }, + "qa": { + "$ref": "#/components/schemas/QaStatsDto" + } + }, + "required": [ + "from", + "to", + "createdVsResolved", + "cumulativeFlow", + "cycleTime", + "leadTime", + "throughput", + "qa" + ] + }, "ActivityDto": { "type": "object", "properties": { @@ -4755,15 +5810,9 @@ }, "area": { "type": "string", - "enum": [ - "FRONTEND", - "BACKEND", - "MOBILE", - "QA", - "DESIGN", - "DEVOPS" - ], - "nullable": true + "nullable": true, + "example": "FRONTEND", + "description": "An area id of the project's settings" }, "priority": { "allOf": [ @@ -4796,11 +5845,9 @@ "format": "date" }, "type": { - "allOf": [ - { - "$ref": "#/components/schemas/IssueType" - } - ] + "type": "string", + "example": "BUG", + "description": "A ticket type id of the project's settings" }, "title": { "type": "string", @@ -4833,15 +5880,9 @@ }, "area": { "type": "string", - "enum": [ - "FRONTEND", - "BACKEND", - "MOBILE", - "QA", - "DESIGN", - "DEVOPS" - ], - "nullable": true + "nullable": true, + "example": "FRONTEND", + "description": "An area id of the project's settings" }, "priority": { "allOf": [ @@ -4874,11 +5915,9 @@ "format": "date" }, "type": { - "allOf": [ - { - "$ref": "#/components/schemas/IssueType" - } - ] + "type": "string", + "example": "FEATURE", + "description": "A ticket type id" }, "title": { "type": "string", @@ -4890,16 +5929,14 @@ "type": "object", "properties": { "status": { - "allOf": [ - { - "$ref": "#/components/schemas/IssueStatus" - } - ] + "type": "string", + "example": "IN_REVIEW", + "description": "A status id of the project's settings, among the ticket's allowedTransitions" }, "reason": { "type": "string", "nullable": true, - "description": "Required for QA_FAILED and CANCELLED" + "description": "Required by statuses with requiresReason (e.g. QA_FAILED)" } }, "required": [ @@ -5162,6 +6199,47 @@ "byStatus" ] }, + "ReleaseBurnupDto": { + "type": "object", + "properties": { + "releaseId": { + "type": "string", + "format": "uuid" + }, + "targetDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "dates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "scope": { + "description": "Tickets in the release (not cancelled)", + "type": "array", + "items": { + "type": "number" + } + }, + "done": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "releaseId", + "targetDate", + "dates", + "scope", + "done" + ] + }, "PublishReleaseDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 6eca3c6..eb2c7be 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -13,20 +13,32 @@ type Activity { type: String! } +type AreaOption { + archived: Boolean! + id: String! + name: String! +} + +input AreaOptionInput { + archived: Boolean! = false + id: String! + name: String! +} + type BoardColumn { issues: [Issue!]! - status: IssueStatus! + status: String! total: Int! } input ChangeIssueStatusInput { issueId: ID! - """ - Required for QA_FAILED and CANCELLED - """ + """Required by statuses with requiresReason""" reason: String - status: IssueStatus! + + """A status id""" + status: String! } """ @@ -52,9 +64,7 @@ type CommentChange { action: String! actorId: ID! - """ - Null when deleted - """ + """Null when deleted""" comment: Comment commentId: ID! issueId: ID! @@ -69,9 +79,7 @@ type CommentPage { type CountByAssignee { count: Int! - """ - Null = unassigned - """ + """Null = unassigned""" user: UserRef } @@ -82,18 +90,19 @@ type CountByKey { type CountByStatus { count: Int! - key: IssueStatus! + + """Status id""" + key: String! } input CreateIssueInput { - area: IssueArea + """An area id""" + area: String assigneeId: ID description: String dueDate: String - """ - Figma frame for UI changes - """ + """Figma frame for UI changes""" figmaUrl: String labels: [String!] priority: IssuePriority @@ -101,15 +110,15 @@ input CreateIssueInput { releaseId: ID storyPoints: Int title: String! - type: IssueType! + + """A ticket type id""" + type: String! } input CreateProjectInput { description: String - """ - 2-10 chars, e.g. "TS" → tickets TS-1, TS-2... - """ + """2-10 chars, e.g. "TS" → tickets TS-1, TS-2...""" key: String! name: String! } @@ -117,20 +126,44 @@ input CreateProjectInput { input CreateReleaseInput { description: String - """ - e.g. v1.4.0 - """ + """e.g. v1.4.0""" name: String! projectId: ID! targetDate: String } +type CreatedResolved { + created: Int! + resolved: Int! + + """Monday of the week (YYYY-MM-DD)""" + week: String! +} + +type CumulativeFlow { + dates: [String!]! + series: [FlowSeries!]! +} + +type DurationStats { + averageDays: Float + count: Int! + + """85% of tickets take at most this long""" + p85Days: Float +} + +type FlowSeries { + counts: [Int!]! + status: String! +} + type Issue { - """ - Statuses this ticket can move to next - """ - allowedTransitions: [IssueStatus!]! - area: IssueArea + """Statuses this ticket can move to next (project's workflow)""" + allowedTransitions: [String!]! + + """An area id""" + area: String assignee: UserRef commentCount: Int! comments(limit: Int! = 50, offset: Int! = 0): CommentPage! @@ -139,21 +172,15 @@ type Issue { dueDate: String figmaUrl: String - """ - History of this ticket - """ + """History of this ticket""" history(limit: Int! = 50): [Activity!]! id: ID! - """ - Human key, e.g. TS-42 - """ + """Human key, e.g. TS-42""" key: String! labels: [String!]! - """ - Linked tickets I can see - """ + """Linked tickets I can see""" linkedIssues: [Issue!]! links: [IssueLink!]! number: Int! @@ -162,26 +189,19 @@ type Issue { projectKey: String! release: ReleaseRef reporter: UserRef! - status: IssueStatus! + + """A status id of the project's settings""" + status: String! + statusCategory: StatusCategory! storyPoints: Int title: String! - type: IssueType! + + """A ticket type id of the project's settings""" + type: String! updatedAt: String! version: Int! } -""" -Team/layer: links FE ↔ BE ↔ QA work -""" -enum IssueArea { - BACKEND - DESIGN - DEVOPS - FRONTEND - MOBILE - QA -} - type IssueChange { action: String! actorId: ID! @@ -190,26 +210,23 @@ type IssueChange { } input IssueFilterInput { - areas: [IssueArea!] + areas: [String!] assigneeId: ID + categories: [StatusCategory!] labels: [String!] priorities: [IssuePriority!] projectId: ID! releaseId: ID - """ - Matches key, title or description - """ + """Matches key, title or description""" search: String - statuses: [IssueStatus!] - types: [IssueType!] + statuses: [String!] + types: [String!] unassigned: Boolean } type IssueLink { - """ - OUTWARD: this issue → other. INWARD: other → this issue. - """ + """OUTWARD: this issue → other. INWARD: other → this issue.""" direction: LinkDirection! issueId: ID! issueKey: String! @@ -237,24 +254,20 @@ enum IssuePriority { MEDIUM } -enum IssueStatus { - BACKLOG - CANCELLED - DONE - IN_PROGRESS - IN_QA - IN_REVIEW - QA_FAILED - READY_FOR_QA - TODO +type IssueTypeOption { + archived: Boolean! + color: String! + icon: String! + id: String! + name: String! } -enum IssueType { - BUG - FEATURE - QA - TASK - UI_CHANGE +input IssueTypeOptionInput { + archived: Boolean! = false + color: String! + icon: String! + id: String! + name: String! } enum LinkDirection { @@ -269,53 +282,39 @@ input LinkIssuesInput { } type Me { - """ - Roles inside the task-stream OAuth client - """ + """Roles inside the task-stream OAuth client""" appRoles: [String!]! email: String! id: ID! isActive: Boolean! name: String! - """ - Global roles in Pireon - """ + """Global roles in Pireon""" roles: [String!]! } type Mutation { addComment(body: String!, issueId: ID!): CommandResult! - """ - Add a Pireon user to the project - """ + """Add a Pireon user to the project""" addProjectMember(input: ProjectMemberInput!): CommandResult! archiveRelease(releaseId: ID!): CommandResult! - """ - Assign (or unassign with null) a project member - """ + """Assign (or unassign with null) a project member""" assignIssue(assigneeId: ID, issueId: ID!): CommandResult! - """ - Move a ticket through the workflow (incl. QA) - """ + """Move a ticket through the workflow (incl. QA)""" changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! createIssue(input: CreateIssueInput!): CommandResult! createProject(input: CreateProjectInput!): CommandResult! createRelease(input: CreateReleaseInput!): CommandResult! - """ - Author or project admin - """ + """Author or project admin""" deleteComment(commentId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult! - """ - Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE - """ + """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" linkIssues(input: LinkIssuesInput!): CommandResult! """ @@ -324,26 +323,27 @@ type Mutation { publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! - """ - Put a ticket in a release (null removes it) - """ + """Put a ticket in a release (null removes it)""" setIssueRelease(issueId: ID!, releaseId: ID): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! updateIssue(input: UpdateIssueInput!): CommandResult! updateProject(input: UpdateProjectInput!): CommandResult! + + """ + Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted. + """ + updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult! updateRelease(input: UpdateReleaseInput!): CommandResult! } type Project { """ - Kanban board, one column per status + Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA """ - board( - area: IssueArea - assigneeId: ID - perColumn: Int! = 50 - releaseId: ID - ): [BoardColumn!]! + analytics(days: Int! = 90): ProjectAnalytics! + + """Kanban board, one column per status""" + board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]! createdAt: String! description: String id: ID! @@ -352,15 +352,33 @@ type Project { name: String! owner: UserRef! releases: [Release!]! + settings: ProjectSettings! - """ - Project tracking dashboard - """ + """Project tracking dashboard""" stats: ProjectStats! updatedAt: String! version: Int! } +type ProjectAnalytics { + createdVsResolved: [CreatedResolved!]! + + """Tickets in each status at the end of every day""" + cumulativeFlow: CumulativeFlow! + + """Start of work → done""" + cycleTime: DurationStats! + from: String! + + """Creation → done""" + leadTime: DurationStats! + qa: QaStats! + + """Finished per person and week""" + throughput: [Throughput!]! + to: String! +} + type ProjectChange { action: String! actorId: ID! @@ -377,9 +395,7 @@ input ProjectMemberInput { projectId: ID! role: ProjectRole! = MEMBER - """ - Pireon user id - """ + """Pireon user id""" userId: ID! } @@ -390,12 +406,28 @@ enum ProjectRole { VIEWER } +"""Statuses (board order), transitions, ticket types and areas""" +type ProjectSettings { + areas: [AreaOption!]! + issueTypes: [IssueTypeOption!]! + statuses: [WorkflowStatus!]! + transitions: [StatusTransitions!]! +} + +""" +The whole configuration (replaces the previous one). Statuses go in board order. +""" +input ProjectSettingsInput { + areas: [AreaOptionInput!]! + issueTypes: [IssueTypeOptionInput!]! + statuses: [WorkflowStatusInput!]! + transitions: [StatusTransitionsInput!]! +} + type ProjectStats { byArea: [CountByKey!]! - """ - Open tickets per person - """ + """Open tickets per person""" byAssignee: [CountByAssignee!]! byPriority: [CountByKey!]! byStatus: [CountByStatus!]! @@ -404,63 +436,59 @@ type ProjectStats { total: Int! } +type QaStats { + failed: Int! + failureRate: Float + passed: Int! +} + type Query { - """ - Project activity feed (newest first) - """ + """Project activity feed (newest first)""" activity( - """ - ISO date cursor - """ + """ISO date cursor""" before: String issueId: ID limit: Int! = 50 projectId: ID! ): [Activity!]! issue( - """ - Issue id or key (e.g. "TS-42") - """ + """Issue id or key (e.g. "TS-42")""" idOrKey: String! ): Issue! - issues( - filter: IssueFilterInput! - limit: Int! = 50 - offset: Int! = 0 - ): IssuePage! + issues(filter: IssueFilterInput!, limit: Int! = 50, offset: Int! = 0): IssuePage! - """ - The authenticated user - """ + """The authenticated user""" me: Me! """ - Projects where I am a member + Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA]) """ + myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage! + + """Projects where I am a member""" myProjects: [Project!]! project( - """ - Project id or key (e.g. "TS") - """ + """Project id or key (e.g. "TS")""" idOrKey: String! ): Project! release(id: ID!): Release! + + """Planned releases of all my projects, soonest target date first""" + upcomingReleases(limit: Int! = 10): [Release!]! user(id: String!): User - """ - Search users by name or email (to assign / add members) - """ + """Search users by name or email (to assign / add members)""" users(limit: Int! = 20, search: String! = ""): [User!]! } type Release { + """Scope vs finished tickets, day by day""" + burnup: ReleaseBurnup! createdAt: String! description: String id: ID! - """ - Tickets included in this release - """ + """Tickets included in this release""" issues(limit: Int! = 100, offset: Int! = 0): IssuePage! name: String! progress: ReleaseProgress! @@ -471,6 +499,16 @@ type Release { version: Int! } +type ReleaseBurnup { + dates: [String!]! + done: [Int!]! + releaseId: ID! + + """Tickets in the release (not cancelled)""" + scope: [Int!]! + targetDate: String +} + type ReleaseChange { action: String! actorId: ID! @@ -496,41 +534,61 @@ enum ReleaseStatus { RELEASED } +""" +What a status means, whatever each project calls it. DONE and CANCELLED are finished. +""" +enum StatusCategory { + CANCELLED + DONE + IN_PROGRESS + QA + TODO +} + +type StatusTransitions { + from: String! + to: [String!]! +} + +input StatusTransitionsInput { + from: String! + to: [String!]! +} + type Subscription { - """ - Live activity feed - """ + """Live activity feed""" activityAdded(projectId: ID!): Activity! commentChanged(issueId: ID!): CommentChange! - """ - Tickets created/updated/moved in a project (board in realtime) - """ + """Tickets created/updated/moved in a project (board in realtime)""" issueChanged(projectId: ID!): IssueChange! - """ - Projects I am added to / removed from - """ + """Projects I am added to / removed from""" myProjectsChanged: ProjectChange! projectChanged(projectId: ID!): ProjectChange! releaseChanged(projectId: ID!): ReleaseChange! } +type Throughput { + count: Int! + user: UserRef + week: String! +} + input UpdateIssueInput { - area: IssueArea + """An area id""" + area: String description: String dueDate: String - """ - Figma frame for UI changes - """ + """Figma frame for UI changes""" figmaUrl: String issueId: ID! labels: [String!] priority: IssuePriority storyPoints: Int title: String - type: IssueType + type: String } input UpdateProjectInput { @@ -546,9 +604,7 @@ input UpdateReleaseInput { targetDate: String } -""" -User managed by the Pireon identity server -""" +"""User managed by the Pireon identity server""" type User { email: String! id: ID! @@ -560,3 +616,28 @@ type UserRef { id: ID! name: String! } + +type WorkflowStatus { + """No new tickets or transitions into it""" + archived: Boolean! + category: StatusCategory! + + """Colour token: slate, violet, green…""" + color: String! + + """Stable id stored on tickets, e.g. IN_REVIEW""" + id: String! + name: String! + + """Entering it needs a reason""" + requiresReason: Boolean! +} + +input WorkflowStatusInput { + archived: Boolean! = false + category: StatusCategory! + color: String! + id: String! + name: String! + requiresReason: Boolean! = false +} \ No newline at end of file diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 42358da..bf05a6e 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -19,6 +19,8 @@ describe('appRoutes', () => { 'issues/:issueKey', 'releases', 'dashboard', + 'analytics', + 'settings', 'activity', 'members', ]); diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index e9fff8f..084847f 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -114,6 +114,31 @@ html { --ts-t-ui: #d6409f; --ts-t-task: #64748b; --ts-t-qa: #d97706; + + // Palette of the per-project settings (statuses and ticket types pick one). + --ts-c-slate: #64748b; + --ts-c-stone: #8b8b9a; + --ts-c-violet: #a347e1; + --ts-c-blue: #1879bf; + --ts-c-sky: #0284c7; + --ts-c-teal: #0f766e; + --ts-c-green: #15803d; + --ts-c-amber: #b7791f; + --ts-c-orange: #ea580c; + --ts-c-red: #dc2626; + --ts-c-pink: #d6409f; + + // Charts (validated with the dataviz checks against --ts-surface, light and + // dark): two categorical slots, an ordinal ramp for flow stages and the + // reserved good/critical status colours. + --ts-viz-1: #2a78d6; + --ts-viz-2: #eb6834; + --ts-viz-ramp-1: #86b6ef; + --ts-viz-ramp-2: #5598e7; + --ts-viz-ramp-3: #256abf; + --ts-viz-ramp-4: #104281; + --ts-viz-good: #0ca30c; + --ts-viz-critical: #d03b3b; } html, @@ -316,6 +341,19 @@ h3 { } } +/* Text for screen readers only (same helper as pireon.dev). */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + .ts-prewrap { white-space: pre-wrap; } @@ -501,6 +539,25 @@ html.dark-mode .pi-shell { --ts-t-task: #94a3b8; --ts-t-qa: #fbbf24; + --ts-c-slate: #94a3b8; + --ts-c-stone: #8a91a4; + --ts-c-violet: #ae5de5; + --ts-c-blue: #339be6; + --ts-c-sky: #38bdf8; + --ts-c-teal: #2dd4bf; + --ts-c-green: #34c98a; + --ts-c-amber: #eab308; + --ts-c-orange: #fb923c; + --ts-c-red: #f07070; + --ts-c-pink: #f472b6; + + --ts-viz-1: #3987e5; + --ts-viz-2: #d95926; + --ts-viz-ramp-1: #184f95; + --ts-viz-ramp-2: #2a78d6; + --ts-viz-ramp-3: #6da7ec; + --ts-viz-ramp-4: #b7d3f6; + --pi-color-grey-10: #12141a; --pi-color-grey-25: #161922; --pi-color-grey-50: #1a1e28; diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 3140f25..8980721 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,18 +2,21 @@ ## Qué resuelve -| Necesidad | Cómo se resuelve | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | -| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | -| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | -| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | -| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | -| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | +| Necesidad | Cómo se resuelve | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | +| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | +| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | +| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | +| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | +| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | +| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/03-api.md b/docs/03-api.md index a8e14df..6627430 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -11,7 +11,8 @@ classDiagram key: "TS" name members: (userId, role)[] - addMember() changeMemberRole() removeMember() updateDetails() + settings: estados · transiciones · tipos · áreas + addMember() changeMemberRole() removeMember() updateDetails() updateSettings() } class Issue { key: "TS-42" @@ -47,7 +48,39 @@ classDiagram Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que el proyecto existe. -### Workflow de un ticket +### Configuración por proyecto + +Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos +empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o +`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`). + +| Valor | Campos | Por defecto | +| ------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Estado | `id`, `name`, `category`, `color`, `requiresReason`, `archived` (el orden es el del tablero) | `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `READY_FOR_QA`, `IN_QA`, `QA_FAILED`, `DONE`, `CANCELLED` | +| Tipo | `id`, `name`, `icon`, `color`, `archived` | `BUG`, `FEATURE`, `UI_CHANGE`, `TASK`, `QA` | +| Área | `id`, `name`, `archived` | `FRONTEND`, `BACKEND`, `MOBILE`, `QA`, `DESIGN`, `DEVOPS` | + +La **categoría** de un estado dice qué significa, se llame como se llame: `TODO` (pendiente), `IN_PROGRESS`, `QA`, +`DONE` y `CANCELLED`. Releases («todo terminado» = `DONE` o `CANCELLED`), «Mi trabajo», el seguimiento y las +estadísticas trabajan con categorías, así que siguen funcionando con estados propios. Los tickets guardan el id del +estado y, en el read model, su categoría (`Issue.statusCategory`). + +Reglas: + +- **No se borra lo que ya existe: se archiva.** Tickets, historial y estadísticas siguen apuntando a ese id. Un valor + archivado no se ofrece para tickets nuevos ni recibe transiciones; los tickets que ya lo tienen lo conservan. +- Los ids son estables (`A-Z`, `0-9`, `_`, hasta 30, empezando por letra); los nombres, de 1 a 40 caracteres, se + pueden cambiar cuando se quiera. +- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un + tipo activo. +- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo. +- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos: + `bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`. + +Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la +llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen. + +### Workflow por defecto ```mermaid stateDiagram-v2 @@ -76,9 +109,10 @@ stateDiagram-v2 CANCELLED --> BACKLOG ``` -- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial. +- `QA_FAILED` y `CANCELLED` exigen un **motivo** (`requiresReason`), que queda en el historial. - No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA). -- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual. +- `Issue.allowedTransitions` devuelve los estados activos a los que puede pasar el ticket según el workflow de su + proyecto. ### Otras reglas @@ -96,12 +130,34 @@ stateDiagram-v2 Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`, `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream). -| Agregado | Eventos | -| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` | -| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` | -| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | -| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` | +| Agregado | Eventos | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` | +| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` | +| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | +| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` | + +`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del +cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado. + +### Estadísticas + +Una proyección más (`issue_timeline` en MongoDB) guarda cada paso de cada ticket: a qué estado pasó, con qué categoría, +quién lo tenía asignado, y cuándo entró o salió de una release. Las estadísticas se calculan sobre ella con funciones +puras (`application/src/lib/analytics`): + +| Métrica | Definición | +| --------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Creados vs. resueltos | Por semana (lunes, UTC): tickets creados y tickets que entraron en una categoría `DONE` | +| Flujo acumulado | Tickets en cada estado al final de cada día | +| Tiempo de ciclo | Del primer paso a `IN_PROGRESS` o `QA` hasta `DONE`; media y percentil 85 | +| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 | +| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados | +| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido | +| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos | + +Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa, +reconstruye el read model (ver [Despliegue](07-despliegue.md)). ## 3.2 GraphQL @@ -109,28 +165,30 @@ Todas las operaciones requieren `Authorization: Bearer ` (en su ### Queries -| Query | Devuelve | -| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `me` | El usuario autenticado, con sus roles en Pireon | -| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | -| `user(id)` | Un usuario | -| `myProjects` | Proyectos de los que soy miembro | -| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` | -| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) | -| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | -| `release(id)` | Una release. Campos calculados: `progress`, `issues` | -| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | +| Query | Devuelve | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `me` | El usuario autenticado, con sus roles en Pireon | +| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | +| `user(id)` | Un usuario | +| `myProjects` | Proyectos de los que soy miembro | +| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) | +| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | +| `project(idOrKey)` | Un proyecto con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` | +| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) | +| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | +| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | +| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | ### Mutations Todas devuelven `CommandResult { id, key, version }`. -| Ámbito | Mutations | -| ----------- | ----------------------------------------------------------------------------------------------------------------- | -| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | -| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` | -| Comentarios | `addComment`, `editComment`, `deleteComment` | -| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | +| Ámbito | Mutations | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | +| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` | +| Comentarios | `addComment`, `editComment`, `deleteComment` | +| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | ### Subscriptions @@ -140,14 +198,23 @@ Todas devuelven `CommandResult { id, key, version }`. | `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) | | `activityAdded(projectId)` | Hay una nueva entrada en el feed | | `releaseChanged(projectId)` | Cambia una release | -| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto | +| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto | | `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno | Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403. ### Ejemplos +Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas. + ```graphql +mutation ($settings: ProjectSettingsInput!) { + # La configuración entera (la que devuelve project.settings, cambiada). + updateProjectSettings(projectId: "…", settings: $settings) { + version + } +} + mutation { createProject(input: { key: "SHOP", name: "Checkout revamp" }) { id @@ -157,7 +224,7 @@ mutation { } mutation { - createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) { + createIssue(input: { projectId: "…", type: "BUG", title: "El botón de pagar no responde en Safari", area: "FRONTEND", priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) { id key version @@ -165,7 +232,7 @@ mutation { } mutation { - changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) { + changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) { version } } @@ -210,6 +277,27 @@ query { percent } } + analytics(days: 90) { + createdVsResolved { + week + created + resolved + } + cycleTime { + averageDays + p85Days + } + qa { + failureRate + } + } + } + release(id: "…") { + burnup { + dates + scope + done + } } issue(idOrKey: "SHOP-1") { status @@ -295,16 +383,17 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). -| Recurso | Rutas | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | -| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `…/board`, `…/stats`, `…/activity`, `…/members` (POST, PATCH, DELETE) | -| Tickets | `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | -| Releases | `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| Recurso | Rutas | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | +| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | -Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que +Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por +comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para tiempo real, las subscriptions de GraphQL. diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 4b547bb..732316f 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -37,7 +37,7 @@ export const boardPageEvents = eventGroup({ source: 'Board Page', events: { opened: type(), - issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(), + issueMoved: type<{ issueId: string; to: string; reason?: string | null }>(), }, }); @@ -84,7 +84,7 @@ export class BoardFacade { else this.dispatch.opened(filter); } - moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void { + moveIssue(issueId: string, to: string, reason?: string | null): void { this.dispatch.issueMoved({ issueId, to, reason }); } } @@ -109,13 +109,14 @@ Qué gana la página: - **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres… - **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`). -| Facade | Store | Ámbito | Responsabilidad | -| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- | -| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | -| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | -| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios | -| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | -| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones | +| Facade | Store | Ámbito | Responsabilidad | +| ---------------- | --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | +| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | +| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña | +| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) | +| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | +| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones | Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los `providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas @@ -145,7 +146,9 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL. npx nx run web:codegen # regenera generated.ts (TypedDocumentNode) ``` -- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). +- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). Lo + que se lee y luego se reenvía como input (la configuración del proyecto) se copia campo a campo: Apollo añade + `__typename` a cada objeto y los input types de GraphQL lo rechazan. ## 4.4 `@pi/ui` @@ -193,19 +196,48 @@ versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`), gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`. +### Configuración del proyecto en la UI + +Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve +`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un +estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-`, con su +versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla +proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría. + +En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos +archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma. + +### Gráficas + +Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una +paleta con versión oscura, que los gráficos de `@pi/ui` (Chart.js con colores fijos) no traen. Siguen las reglas de +visualización de la plataforma: + +- Colores `--ts-viz-*` en `styles.scss`, validados contra la superficie clara y la oscura (separación para daltonismo, + contraste): dos series categóricas (azul y naranja), una rampa ordinal de un solo tono para las etapas del flujo y + bueno/crítico reservados para QA superado/fallido, siempre con icono y texto. +- Líneas de 2 px, rejilla de 1 px continua, separación de 2 px entre bandas apiladas, una sola escala por gráfica. +- Cruceta que se ajusta al día o semana más cercano y un único tooltip con todas las series; con teclado, ← → Inicio + Fin sobre la gráfica. +- Leyenda con dos o más series, etiqueta de valor al final de cada línea solo si no se solapan, y «Ver los datos» con + la tabla, para que nada dependa del color ni del ratón. +- Mientras se recalcula un periodo, se queda la gráfica anterior atenuada. + ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | ---------------------------------------------------------------------------------------- | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Mis proyectos | -| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | -| `/p/:key/issues/:issueKey` | Ticket | -| `/p/:key/releases` | Releases | -| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | -| `/p/:key/activity` | Actividad en vivo | -| `/p/:key/members` | Miembros | +| Ruta | Página | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/auth/callback` | Retorno de Pireon (PKCE) | +| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | +| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | +| `/p/:key/issues/:issueKey` | Ticket | +| `/p/:key/releases` | Releases | +| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | +| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | +| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar | +| `/p/:key/activity` | Actividad en vivo | +| `/p/:key/members` | Miembros | Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de los componentes (`withComponentInputBinding`). diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 6a1394e..9829e09 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -120,3 +120,8 @@ MongoDB solo contiene proyecciones, así que se puede regenerar desde el event s 3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;` 4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas. Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados. + +Hazlo también una vez tras desplegar la configuración por proyecto y las estadísticas si quieres la historia completa +de los tickets anteriores: sin reconstruir, la línea temporal (`issue_timeline`) solo empieza con los cambios nuevos y +las estadísticas usan, para lo anterior, la creación y el estado actual de cada ticket. La categoría de los estados no +lo necesita: la API la rellena al arrancar en los tickets que no la tienen. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index bd78e31..d009759 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 15bcfb2..dd0bb99 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/README.md b/libs/api/tracker/application/README.md index dd772e4..ba545e8 100644 --- a/libs/api/tracker/application/README.md +++ b/libs/api/tracker/application/README.md @@ -7,10 +7,14 @@ Casos de uso del tracker (CQRS). ## Contenido -- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions). +- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions), + `WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s). - `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`. -- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`). -- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model, notificaciones en tiempo real y feed de actividad. +- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`); + `SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»). +- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket), + notificaciones en tiempo real y feed de actividad. +- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests). - `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`. - `TrackerApplicationModule.withInfrastructure(...)`. diff --git a/libs/api/tracker/application/project.json b/libs/api/tracker/application/project.json index 11d9e6f..5a45b9c 100644 --- a/libs/api/tracker/application/project.json +++ b/libs/api/tracker/application/project.json @@ -3,10 +3,7 @@ "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/api/tracker/application/src", "projectType": "library", - "tags": [ - "scope:api-tracker", - "type:application" - ], + "tags": ["scope:api-tracker", "type:application"], "// targets": "to see all targets run: nx show project api-tracker-application --web", "targets": {} } diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 31c8118..867450b 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -6,4 +6,5 @@ export * from './lib/issues.service'; export * from './lib/comments.service'; export * from './lib/releases.service'; export * from './lib/changes.service'; +export * from './lib/workflows.service'; export * from './lib/tracker-application.module'; diff --git a/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts b/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts new file mode 100644 index 0000000..3891c75 --- /dev/null +++ b/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts @@ -0,0 +1,184 @@ +import { + IssueReadModel, + IssueTimelineEntry, + ReleaseReadModel, + ReleaseStatus, + StatusCategory as C, +} from '@task-stream/api/tracker/domain'; +import { + computeProjectAnalytics, + computeReleaseBurnup, + weekOf, +} from './analytics'; + +const ana = { id: 'ana', name: 'Ana' }; +const bob = { id: 'bob', name: 'Bob' }; + +const issue = ( + id: string, + createdAt: string, + status: string, + category: C, + releaseId: string | null = null, +): IssueReadModel => + ({ + id, + key: id, + createdAt, + status, + statusCategory: category, + reporter: ana, + assignee: bob, + release: releaseId ? { id: releaseId, name: 'v1' } : null, + }) as IssueReadModel; + +let seq = 0; +const step = ( + issueId: string, + at: string, + status: string, + category: C, + assigneeId: string | null = 'bob', +): IssueTimelineEntry => ({ + id: `${issueId}:${++seq}`, + issueId, + projectId: 'p', + at, + kind: 'status', + status, + category, + assigneeId, +}); + +/* + * A: created Mon 5 Oct, started Tue 6, to QA Wed 7, QA failed Thu 8, back to QA and done Fri 9. + * B: created Mon 5 Oct, started Mon 12, QA Tue 13, done Wed 14 (by Ana). + * C: created Tue 13 Oct, still in backlog. + */ +const issues = [ + issue('A', '2026-10-05T09:00:00Z', 'DONE', C.DONE, 'rel'), + issue('B', '2026-10-05T10:00:00Z', 'DONE', C.DONE, 'rel'), + issue('C', '2026-10-13T10:00:00Z', 'BACKLOG', C.TODO), +]; +const timeline: IssueTimelineEntry[] = [ + step('A', '2026-10-05T09:00:00Z', 'BACKLOG', C.TODO), + { + id: 'A:r', + issueId: 'A', + projectId: 'p', + at: '2026-10-05T09:00:01Z', + kind: 'release', + releaseId: 'rel', + }, + step('A', '2026-10-06T09:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS), + step('A', '2026-10-07T09:00:00Z', 'IN_QA', C.QA), + step('A', '2026-10-08T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS), + step('A', '2026-10-09T08:00:00Z', 'IN_QA', C.QA), + step('A', '2026-10-09T09:00:00Z', 'DONE', C.DONE), + step('B', '2026-10-05T10:00:00Z', 'BACKLOG', C.TODO), + { + id: 'B:r', + issueId: 'B', + projectId: 'p', + at: '2026-10-12T08:00:00Z', + kind: 'release', + releaseId: 'rel', + }, + step('B', '2026-10-12T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS), + step('B', '2026-10-13T10:00:00Z', 'IN_QA', C.QA), + step('B', '2026-10-14T10:00:00Z', 'DONE', C.DONE, 'ana'), +]; + +describe('project analytics', () => { + const stats = computeProjectAnalytics({ + issues, + timeline, + statusOrder: ['BACKLOG', 'IN_PROGRESS', 'IN_QA', 'QA_FAILED', 'DONE'], + from: new Date('2026-10-05T00:00:00Z'), + to: new Date('2026-10-14T23:59:59Z'), + }); + + it('counts created and resolved tickets per week (Monday)', () => { + expect(weekOf(Date.parse('2026-10-11T12:00:00Z'))).toBe('2026-10-05'); + expect(stats.createdVsResolved).toEqual([ + { week: '2026-10-05', created: 2, resolved: 1 }, + { week: '2026-10-12', created: 1, resolved: 1 }, + ]); + }); + + it('measures cycle time (start → done) and lead time (created → done)', () => { + // A: 3 days of cycle, 4 of lead. B: 2 and 9. + expect(stats.cycleTime).toEqual({ count: 2, averageDays: 2.5, p85Days: 3 }); + expect(stats.leadTime).toEqual({ count: 2, averageDays: 6.5, p85Days: 9 }); + }); + + it('counts QA passes and failures', () => { + expect(stats.qa).toEqual({ passed: 2, failed: 1, failureRate: 0.333 }); + }); + + it('attributes throughput to who had the ticket', () => { + expect(stats.throughput).toEqual([ + { week: '2026-10-05', user: bob, count: 1 }, + { week: '2026-10-12', user: ana, count: 1 }, + ]); + }); + + it('builds the cumulative flow day by day, in the workflow order', () => { + const flow = stats.cumulativeFlow; + expect(flow.dates).toHaveLength(10); + expect(flow.series.map((s) => s.status)).toEqual([ + 'BACKLOG', + 'IN_PROGRESS', + 'IN_QA', + 'QA_FAILED', + 'DONE', + ]); + const at = (status: string, day: string) => + flow.series.find((s) => s.status === status)?.counts[ + flow.dates.indexOf(day) + ]; + expect(at('BACKLOG', '2026-10-05')).toBe(2); + expect(at('QA_FAILED', '2026-10-08')).toBe(1); + expect(at('DONE', '2026-10-14')).toBe(2); + expect(at('BACKLOG', '2026-10-14')).toBe(1); // C + }); + + it('falls back to creation + current status for tickets without timeline', () => { + const old = computeProjectAnalytics({ + issues: [issue('OLD', '2026-10-01T00:00:00Z', 'TODO', C.TODO)], + timeline: [], + statusOrder: [], + from: new Date('2026-10-01T00:00:00Z'), + to: new Date('2026-10-02T23:00:00Z'), + }); + expect(old.cumulativeFlow.series).toEqual([ + { status: 'TODO', counts: [1, 1] }, + ]); + }); +}); + +describe('release burnup', () => { + it('tracks scope and done tickets of the release per day', () => { + const release = { + id: 'rel', + createdAt: '2026-10-05T08:00:00Z', + releasedAt: '2026-10-14T18:00:00Z', + targetDate: '2026-10-15', + status: ReleaseStatus.RELEASED, + } as ReleaseReadModel; + const burnup = computeReleaseBurnup({ + release, + issues, + timeline, + now: new Date('2026-10-20T00:00:00Z'), + }); + const at = (series: number[], day: string) => + series[burnup.dates.indexOf(day)]; + expect(burnup.dates[0]).toBe('2026-10-05'); + expect(burnup.dates.at(-1)).toBe('2026-10-14'); + expect(at(burnup.scope, '2026-10-05')).toBe(1); // A + expect(at(burnup.scope, '2026-10-12')).toBe(2); // + B + expect(at(burnup.done, '2026-10-09')).toBe(1); + expect(at(burnup.done, '2026-10-14')).toBe(2); + }); +}); diff --git a/libs/api/tracker/application/src/lib/analytics/analytics.ts b/libs/api/tracker/application/src/lib/analytics/analytics.ts new file mode 100644 index 0000000..fb07501 --- /dev/null +++ b/libs/api/tracker/application/src/lib/analytics/analytics.ts @@ -0,0 +1,315 @@ +import { + DurationStats, + IssueReadModel, + IssueTimelineEntry, + ProjectAnalyticsReadModel, + ReleaseBurnupReadModel, + ReleaseReadModel, + StatusCategory, + UserRefReadModel, +} from '@task-stream/api/tracker/domain'; + +/* + * Statistics are computed from each ticket's timeline (status and release + * steps). Pure functions: no Nest, no database, easy to test. + */ + +const DAY = 86_400_000; +const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA]; + +interface Step { + at: number; + status: string; + category: StatusCategory; + assigneeId: string | null; +} + +/** "2026-10-06" for a timestamp (UTC). */ +export const dayOf = (time: number) => + new Date(time).toISOString().slice(0, 10); + +/** Monday of the ISO week of a timestamp (UTC), as "YYYY-MM-DD". */ +export function weekOf(time: number): string { + const date = new Date(time); + const shift = (date.getUTCDay() + 6) % 7; + return dayOf( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() - shift, + ), + ); +} + +/** End of each UTC day between two instants (inclusive). */ +function days(from: number, to: number): number[] { + const out: number[] = []; + const first = Date.UTC( + new Date(from).getUTCFullYear(), + new Date(from).getUTCMonth(), + new Date(from).getUTCDate(), + ); + for (let day = first; day <= to; day += DAY) out.push(day + DAY - 1); + return out; +} + +function weeks(from: number, to: number): string[] { + const out: string[] = []; + for (let day = Date.parse(weekOf(from)); day <= to; day += 7 * DAY) + out.push(dayOf(day)); + return out; +} + +/** Status steps per ticket; tickets with no timeline get one from their creation. */ +function stepsByIssue( + issues: IssueReadModel[], + timeline: IssueTimelineEntry[], +): Map { + const steps = new Map(); + for (const entry of timeline) { + if (entry.kind !== 'status' || !entry.status || !entry.category) continue; + const list = steps.get(entry.issueId) ?? []; + list.push({ + at: Date.parse(entry.at), + status: entry.status, + category: entry.category, + assigneeId: entry.assigneeId ?? null, + }); + steps.set(entry.issueId, list); + } + for (const issue of issues) { + if (!steps.has(issue.id)) { + steps.set(issue.id, [ + { + at: Date.parse(issue.createdAt), + status: issue.status, + category: issue.statusCategory, + assigneeId: issue.assignee?.id ?? null, + }, + ]); + } + steps.get(issue.id)?.sort((a, b) => a.at - b.at); + } + return steps; +} + +/** Last step at or before `time`. */ +function stepAt(steps: Step[], time: number): Step | null { + let current: Step | null = null; + for (const step of steps) { + if (step.at > time) break; + current = step; + } + return current; +} + +function durations(values: number[]): DurationStats { + if (values.length === 0) + return { count: 0, averageDays: null, p85Days: null }; + const sorted = [...values].sort((a, b) => a - b); + const average = values.reduce((sum, v) => sum + v, 0) / values.length; + const p85 = sorted[Math.max(0, Math.ceil(0.85 * sorted.length) - 1)]; + const round = (ms: number) => Math.round((ms / DAY) * 10) / 10; + return { + count: values.length, + averageDays: round(average), + p85Days: round(p85), + }; +} + +export interface AnalyticsInput { + issues: IssueReadModel[]; + timeline: IssueTimelineEntry[]; + /** Status ids in the project's order (series follow it). */ + statusOrder: string[]; + from: Date; + to: Date; +} + +export function computeProjectAnalytics( + input: AnalyticsInput, +): ProjectAnalyticsReadModel { + const from = input.from.getTime(); + const to = input.to.getTime(); + const inRange = (time: number) => time >= from && time <= to; + const steps = stepsByIssue(input.issues, input.timeline); + const byId = new Map(input.issues.map((issue) => [issue.id, issue])); + const people = new Map(); + for (const issue of input.issues) { + if (issue.assignee) people.set(issue.assignee.id, issue.assignee); + people.set(issue.reporter.id, issue.reporter); + } + + // Created vs resolved, per week. + const weekKeys = weeks(from, to); + const created = new Map(weekKeys.map((week) => [week, 0])); + const resolved = new Map(weekKeys.map((week) => [week, 0])); + for (const issue of input.issues) { + const at = Date.parse(issue.createdAt); + if (inRange(at)) + created.set(weekOf(at), (created.get(weekOf(at)) ?? 0) + 1); + } + + // Walk every transition once: resolutions, cycle/lead time, throughput, QA. + const cycle: number[] = []; + const lead: number[] = []; + const throughput = new Map< + string, + { week: string; user: string | null; count: number } + >(); + let passed = 0; + let failed = 0; + for (const [issueId, list] of steps) { + const issue = byId.get(issueId); + for (let i = 1; i < list.length; i++) { + const previous = list[i - 1]; + const step = list[i]; + if (!inRange(step.at)) continue; + if ( + previous.category === StatusCategory.QA && + step.category !== StatusCategory.QA + ) { + if (step.category === StatusCategory.DONE) passed++; + else if (step.category !== StatusCategory.CANCELLED) failed++; + } + if ( + step.category !== StatusCategory.DONE || + previous.category === StatusCategory.DONE + ) + continue; + const week = weekOf(step.at); + resolved.set(week, (resolved.get(week) ?? 0) + 1); + if (issue) lead.push(step.at - Date.parse(issue.createdAt)); + const start = list.slice(0, i).find((s) => STARTED.includes(s.category)); + if (start) cycle.push(step.at - start.at); + const key = `${week}|${step.assigneeId ?? ''}`; + const row = throughput.get(key) ?? { + week, + user: step.assigneeId, + count: 0, + }; + row.count++; + throughput.set(key, row); + } + } + + // Cumulative flow: tickets per status at the end of each day. + const dayEnds = days(from, to); + const counts = new Map(); + for (const list of steps.values()) { + dayEnds.forEach((end, index) => { + const step = stepAt(list, end); + if (!step) return; + const series = + counts.get(step.status) ?? new Array(dayEnds.length).fill(0); + series[index]++; + counts.set(step.status, series); + }); + } + const order = (status: string) => { + const index = input.statusOrder.indexOf(status); + return index === -1 ? Number.MAX_SAFE_INTEGER : index; + }; + + return { + from: input.from.toISOString(), + to: input.to.toISOString(), + createdVsResolved: weekKeys.map((week) => ({ + week, + created: created.get(week) ?? 0, + resolved: resolved.get(week) ?? 0, + })), + cumulativeFlow: { + dates: dayEnds.map(dayOf), + series: [...counts.entries()] + .sort(([a], [b]) => order(a) - order(b)) + .map(([status, values]) => ({ status, counts: values })), + }, + cycleTime: durations(cycle), + leadTime: durations(lead), + throughput: [...throughput.values()] + .sort((a, b) => a.week.localeCompare(b.week) || b.count - a.count) + .map((row) => ({ + week: row.week, + user: row.user + ? (people.get(row.user) ?? { id: row.user, name: '?' }) + : null, + count: row.count, + })), + qa: { + passed, + failed, + failureRate: + passed + failed + ? Math.round((failed / (passed + failed)) * 1000) / 1000 + : null, + }, + }; +} + +export interface BurnupInput { + release: ReleaseReadModel; + issues: IssueReadModel[]; + timeline: IssueTimelineEntry[]; + now: Date; +} + +/** + * Per day, from the release's creation until it ships (or today): tickets in + * it (scope, cancelled ones aside) and how many of those are done. + */ +export function computeReleaseBurnup( + input: BurnupInput, +): ReleaseBurnupReadModel { + const { release } = input; + const steps = stepsByIssue(input.issues, input.timeline); + const membership = new Map< + string, + { at: number; releaseId: string | null }[] + >(); + for (const entry of input.timeline) { + if (entry.kind !== 'release') continue; + const list = membership.get(entry.issueId) ?? []; + list.push({ at: Date.parse(entry.at), releaseId: entry.releaseId ?? null }); + membership.set(entry.issueId, list); + } + for (const issue of input.issues) { + if (!membership.has(issue.id) && issue.release) { + membership.set(issue.id, [ + { at: Date.parse(issue.createdAt), releaseId: issue.release.id }, + ]); + } + membership.get(issue.id)?.sort((a, b) => a.at - b.at); + } + + const end = release.releasedAt + ? Date.parse(release.releasedAt) + : input.now.getTime(); + // At most the last 180 days, so a long-lived release stays readable. + const start = Math.max(Date.parse(release.createdAt), end - 179 * DAY); + const dayEnds = days(start, end); + const scope: number[] = []; + const done: number[] = []; + for (const time of dayEnds) { + let inScope = 0; + let finished = 0; + for (const [issueId, list] of membership) { + const last = list.filter((m) => m.at <= time).at(-1); + if (last?.releaseId !== release.id) continue; + const step = stepAt(steps.get(issueId) ?? [], time); + if (!step || step.category === StatusCategory.CANCELLED) continue; + inScope++; + if (step.category === StatusCategory.DONE) finished++; + } + scope.push(inScope); + done.push(finished); + } + + return { + releaseId: release.id, + targetDate: release.targetDate, + dates: dayEnds.map(dayOf), + scope, + done, + }; +} diff --git a/libs/api/tracker/application/src/lib/commands/change-issue-status.command-handler.ts b/libs/api/tracker/application/src/lib/commands/change-issue-status.command-handler.ts index f4da79d..26960e6 100644 --- a/libs/api/tracker/application/src/lib/commands/change-issue-status.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/change-issue-status.command-handler.ts @@ -10,11 +10,16 @@ export class ChangeIssueStatusCommandHandler constructor(private readonly access: WriteAccess) {} async execute(command: ChangeIssueStatusCommand): Promise { - const { issue } = await this.access.issueForContributor( + const { issue, project } = await this.access.issueForContributor( command.issueId, command.actorId, ); - issue.changeStatus(command.actorId, command.status, command.reason); + issue.changeStatus( + command.actorId, + command.status, + command.reason, + project.workflow, + ); await issue.commitAndWait(); return ack(issue); } diff --git a/libs/api/tracker/application/src/lib/commands/change-issue-status.command.ts b/libs/api/tracker/application/src/lib/commands/change-issue-status.command.ts index d2dfe1f..2cd12c6 100644 --- a/libs/api/tracker/application/src/lib/commands/change-issue-status.command.ts +++ b/libs/api/tracker/application/src/lib/commands/change-issue-status.command.ts @@ -1,12 +1,12 @@ import { Command } from '@nestjs/cqrs'; -import { IssueStatus } from '@task-stream/api/tracker/domain'; import { CommandResult } from './command-result'; export class ChangeIssueStatusCommand extends Command { constructor( public readonly actorId: string, public readonly issueId: string, - public readonly status: IssueStatus, + /** Status id of the project's workflow. */ + public readonly status: string, /** Required for QA_FAILED and CANCELLED. */ public readonly reason?: string | null, ) { diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index 32bec73..fd9e25e 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -1,5 +1,6 @@ import { CreateProjectCommandHandler } from './create-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler'; +import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler'; import { AddProjectMemberCommandHandler } from './add-project-member.command-handler'; import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler'; import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler'; @@ -21,6 +22,7 @@ import { ArchiveReleaseCommandHandler } from './archive-release.command-handler' export const COMMAND_HANDLERS = [ CreateProjectCommandHandler, UpdateProjectCommandHandler, + UpdateProjectSettingsCommandHandler, AddProjectMemberCommandHandler, ChangeProjectMemberRoleCommandHandler, RemoveProjectMemberCommandHandler, diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts index 6a9ab44..3342781 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts @@ -19,21 +19,28 @@ export class CreateIssueCommandHandler async execute(command: CreateIssueCommand): Promise { const project = await this.access.project(command.projectId); project.assertCanContribute(command.actorId); + // Before taking a number: an invalid type or area must not burn one. + const workflow = project.workflow; + workflow.assertActiveType(command.type); + workflow.assertActiveArea(command.details.area); if (command.assigneeId) this.access.assertAssignable(project, command.assigneeId); if (command.releaseId) await this.access.openReleaseOf(project, command.releaseId); - const issue = this.issueFactory.create({ - ...command.details, - projectId: project.id, - projectKey: project.key, - number: await this.issueNumbers.next(project.id), - type: command.type, - title: command.title, - reporterId: command.actorId, - assigneeId: command.assigneeId, - }); + const issue = this.issueFactory.create( + { + ...command.details, + projectId: project.id, + projectKey: project.key, + number: await this.issueNumbers.next(project.id), + type: command.type, + title: command.title, + reporterId: command.actorId, + assigneeId: command.assigneeId, + }, + workflow, + ); if (command.releaseId) issue.setRelease(command.actorId, command.releaseId); this.eventPublisher.mergeObjectContext(issue); diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts index addd31b..b60ccbe 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts @@ -1,12 +1,13 @@ import { Command } from '@nestjs/cqrs'; -import { IssueDetails, IssueType } from '@task-stream/api/tracker/domain'; +import { IssueDetails } from '@task-stream/api/tracker/domain'; import { CommandResult } from './command-result'; export class CreateIssueCommand extends Command { constructor( public readonly actorId: string, public readonly projectId: string, - public readonly type: IssueType, + /** Ticket type id of the project's settings. */ + public readonly type: string, public readonly title: string, public readonly details: Omit, public readonly assigneeId?: string | null, diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 0e2e385..77b0d51 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -17,4 +17,5 @@ export * from './set-issue-release.command'; export * from './unlink-issues.command'; export * from './update-issue.command'; export * from './update-project.command'; +export * from './update-project-settings.command'; export * from './update-release.command'; diff --git a/libs/api/tracker/application/src/lib/commands/publish-release.command-handler.ts b/libs/api/tracker/application/src/lib/commands/publish-release.command-handler.ts index 3d1116f..3a855f5 100644 --- a/libs/api/tracker/application/src/lib/commands/publish-release.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/publish-release.command-handler.ts @@ -1,5 +1,4 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { IssueWorkflow } from '@task-stream/api/tracker/domain'; import { ReleaseIssuesRepository } from '../ports/release-issues.repository'; import { CommandResult, ack } from './command-result'; import { PublishReleaseCommand } from './publish-release.command'; @@ -16,9 +15,9 @@ export class PublishReleaseCommandHandler async execute(command: PublishReleaseCommand): Promise { const release = await this.access.release(command.releaseId); - (await this.access.project(release.projectId)).assertCanManage( - command.actorId, - ); + const project = await this.access.project(release.projectId); + project.assertCanManage(command.actorId); + const workflow = project.workflow; // Rebuilt from the event store (source of truth), not from the read model. const candidates = await this.releaseIssues.findIssueIds(release.id); @@ -27,8 +26,7 @@ export class PublishReleaseCommandHandler ); const unfinished = issues.filter( (issue) => - issue.releaseId === release.id && - !IssueWorkflow.isFinished(issue.status), + issue.releaseId === release.id && !workflow.isFinished(issue.status), ).length; release.ship(command.actorId, unfinished, command.force); diff --git a/libs/api/tracker/application/src/lib/commands/update-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/update-issue.command-handler.ts index 087767b..78a255a 100644 --- a/libs/api/tracker/application/src/lib/commands/update-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/update-issue.command-handler.ts @@ -10,11 +10,11 @@ export class UpdateIssueCommandHandler constructor(private readonly access: WriteAccess) {} async execute(command: UpdateIssueCommand): Promise { - const { issue } = await this.access.issueForContributor( + const { issue, project } = await this.access.issueForContributor( command.issueId, command.actorId, ); - issue.updateDetails(command.actorId, command.changes); + issue.updateDetails(command.actorId, command.changes, project.workflow); await issue.commitAndWait(); return ack(issue); } diff --git a/libs/api/tracker/application/src/lib/commands/update-project-settings.command-handler.ts b/libs/api/tracker/application/src/lib/commands/update-project-settings.command-handler.ts new file mode 100644 index 0000000..e81ece4 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/update-project-settings.command-handler.ts @@ -0,0 +1,18 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CommandResult, ack } from './command-result'; +import { UpdateProjectSettingsCommand } from './update-project-settings.command'; +import { WriteAccess } from './write-access'; + +@CommandHandler(UpdateProjectSettingsCommand) +export class UpdateProjectSettingsCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: UpdateProjectSettingsCommand): Promise { + const project = await this.access.project(command.projectId); + project.updateSettings(command.actorId, command.settings); + await project.commitAndWait(); + return ack(project); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/update-project-settings.command.ts b/libs/api/tracker/application/src/lib/commands/update-project-settings.command.ts new file mode 100644 index 0000000..077bb08 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/update-project-settings.command.ts @@ -0,0 +1,14 @@ +import { Command } from '@nestjs/cqrs'; +import { ProjectSettings } from '@task-stream/api/tracker/domain'; +import { CommandResult } from './command-result'; + +/** Replaces the statuses, transitions, ticket types and areas of a project. */ +export class UpdateProjectSettingsCommand extends Command { + constructor( + public readonly actorId: string, + public readonly projectId: string, + public readonly settings: ProjectSettings, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index f9834b0..2e08d63 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -7,9 +7,7 @@ import { IssueLinkedEvent, IssueLinkType, IssueReleaseChangedEvent, - IssueStatus, IssueStatusChangedEvent, - IssueType, IssueUnlinkedEvent, IssueUpdatedEvent, ProjectCreatedEvent, @@ -17,7 +15,9 @@ import { ProjectMemberRemovedEvent, ProjectMemberRoleChangedEvent, ProjectRole, + ProjectSettingsChangedEvent, ProjectUpdatedEvent, + ProjectWorkflow, ReleaseArchivedEvent, ReleaseCreatedEvent, ReleaseShippedEvent, @@ -31,28 +31,19 @@ export interface SummaryContext { issueKey: string; projectKey: string; releaseName: string; + /** The project's workflow: names of statuses and types. */ + workflow: ProjectWorkflow; } /* The activity feed is read by people: Spanish, with the same labels as the web app. */ -const STATUS: Record = { - [IssueStatus.BACKLOG]: 'Backlog', - [IssueStatus.TODO]: 'Por hacer', - [IssueStatus.IN_PROGRESS]: 'En curso', - [IssueStatus.IN_REVIEW]: 'En revisión', - [IssueStatus.READY_FOR_QA]: 'Listo para QA', - [IssueStatus.IN_QA]: 'En QA', - [IssueStatus.QA_FAILED]: 'QA fallido', - [IssueStatus.DONE]: 'Hecho', - [IssueStatus.CANCELLED]: 'Cancelado', -}; - -const TYPE: Record = { - [IssueType.BUG]: 'bug', - [IssueType.FEATURE]: 'feature', - [IssueType.UI_CHANGE]: 'cambio de UI', - [IssueType.TASK]: 'tarea', - [IssueType.QA]: 'QA', +/** Default types keep their lowercase wording in sentences ("creó WEB-3 (tarea)"). */ +const DEFAULT_TYPE_WORDING: Record = { + BUG: 'bug', + FEATURE: 'feature', + UI_CHANGE: 'cambio de UI', + TASK: 'tarea', + QA: 'QA', }; const LINK: Record = { @@ -98,6 +89,8 @@ export function describeEvent( return `${actor} creó el proyecto ${event.project.key}`; if (event instanceof ProjectUpdatedEvent) return `${actor} actualizó el proyecto ${ctx.projectKey}`; + if (event instanceof ProjectSettingsChangedEvent) + return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`; if (event instanceof ProjectMemberAddedEvent) { return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`; } @@ -111,13 +104,19 @@ export function describeEvent( } if (event instanceof IssueCreatedEvent) { - return `${actor} creó ${event.issue.key} (${TYPE[event.issue.type]}): ${event.issue.title}`; + const type = + DEFAULT_TYPE_WORDING[event.issue.type] ?? + ctx.workflow.typeName(event.issue.type); + return `${actor} creó ${event.issue.key} (${type}): ${event.issue.title}`; } if (event instanceof IssueUpdatedEvent) { return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`; } if (event instanceof IssueStatusChangedEvent) { - return `${actor} movió ${issueKey} de ${STATUS[event.from]} a ${STATUS[event.to]}${event.reason ? `: ${event.reason}` : ''}`; + // Names as they were then (old events: as configured now). + const from = event.meta?.fromName ?? ctx.workflow.statusName(event.from); + const to = event.meta?.toName ?? ctx.workflow.statusName(event.to); + return `${actor} movió ${issueKey} de ${from} a ${to}${event.reason ? `: ${event.reason}` : ''}`; } if (event instanceof IssueAssignedEvent) { return event.assigneeId diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts index 7fa026c..b5275f6 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts @@ -21,6 +21,9 @@ import { ReleaseShippedEvent, ReleaseUpdatedEvent, TrackerEvent, + DEFAULT_PROJECT_SETTINGS, + ProjectSettingsChangedEvent, + ProjectWorkflow, } from '@task-stream/api/tracker/domain'; import { ChangeFeed } from '../ports/change-feed'; import { FindIssuesRepository } from '../ports/find-issues.repository'; @@ -35,6 +38,7 @@ import { versionOf } from './read-model-notifier'; @EventsHandler( ProjectCreatedEvent, ProjectUpdatedEvent, + ProjectSettingsChangedEvent, ProjectMemberAddedEvent, ProjectMemberRemovedEvent, ProjectMemberRoleChangedEvent, @@ -96,6 +100,9 @@ export class ActivityEventHandler implements IEventHandler { issueKey: issueKey ?? '', projectKey: project?.key ?? '', releaseName: release?.name ?? '', + workflow: new ProjectWorkflow( + project?.settings ?? DEFAULT_PROJECT_SETTINGS, + ), }), occurredAt: event.occurredAt, }; diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index c98f779..ade25b0 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -19,6 +19,8 @@ import { ReleaseCreatedEventHandler } from './release-created.event-handler'; import { ReleaseShippedEventHandler } from './release-shipped.event-handler'; import { ReleaseUpdatedEventHandler } from './release-updated.event-handler'; import { ReadModelNotifier } from './read-model-notifier'; +import { IssueTimelineEventHandler } from './issue-timeline.event-handler'; +import { ProjectSettingsChangedEventHandler } from './project-settings-changed.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ export const EVENT_HANDLERS = [ @@ -31,6 +33,7 @@ export const EVENT_HANDLERS = [ IssueLinkedEventHandler, IssueReleaseChangedEventHandler, IssueStatusChangedEventHandler, + IssueTimelineEventHandler, IssueUnlinkedEventHandler, IssueUpdatedEventHandler, ProjectCreatedEventHandler, @@ -38,6 +41,7 @@ export const EVENT_HANDLERS = [ ProjectMemberRemovedEventHandler, ProjectMemberRoleChangedEventHandler, ProjectUpdatedEventHandler, + ProjectSettingsChangedEventHandler, ReleaseArchivedEventHandler, ReleaseCreatedEventHandler, ReleaseShippedEventHandler, diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts index 1c6333a..1be9475 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts @@ -1,5 +1,10 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; -import { IssueCreatedEvent } from '@task-stream/api/tracker/domain'; +import { + DEFAULT_PROJECT_SETTINGS, + IssueCreatedEvent, + ProjectWorkflow, +} from '@task-stream/api/tracker/domain'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindReleasesRepository } from '../ports/find-releases.repository'; import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; import { UserDirectory } from '../ports/user-directory'; @@ -12,6 +17,7 @@ export class IssueCreatedEventHandler constructor( private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, private readonly findReleases: FindReleasesRepository, + private readonly findProjects: FindProjectsRepository, private readonly users: UserDirectory, private readonly notifier: ReadModelNotifier, ) {} @@ -19,6 +25,10 @@ export class IssueCreatedEventHandler async handle(event: IssueCreatedEvent) { const { issue } = event; const users = await this.users.refs([issue.reporterId, issue.assigneeId]); + const project = await this.findProjects.findById(issue.projectId); + const workflow = new ProjectWorkflow( + project?.settings ?? DEFAULT_PROJECT_SETTINGS, + ); const release = issue.releaseId ? await this.findReleases.findById(issue.releaseId) : null; @@ -33,6 +43,7 @@ export class IssueCreatedEventHandler title: issue.title, description: issue.description, status: issue.status, + statusCategory: workflow.categoryOf(issue.status), priority: issue.priority, reporter: users.of(issue.reporterId), assignee: users.ofNullable(issue.assigneeId), diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-status-changed.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-status-changed.event-handler.ts index aaeb450..a059036 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-status-changed.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-status-changed.event-handler.ts @@ -1,5 +1,10 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; -import { IssueStatusChangedEvent } from '@task-stream/api/tracker/domain'; +import { + DEFAULT_PROJECT_SETTINGS, + IssueStatusChangedEvent, + ProjectWorkflow, +} from '@task-stream/api/tracker/domain'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; import { ReadModelNotifier, versionOf } from './read-model-notifier'; @@ -9,6 +14,7 @@ export class IssueStatusChangedEventHandler { constructor( private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly findProjects: FindProjectsRepository, private readonly notifier: ReadModelNotifier, ) {} @@ -16,9 +22,18 @@ export class IssueStatusChangedEventHandler await this.upsertMaterializedIssueRepository.upsert({ id: event.aggregateId, status: event.to, + statusCategory: event.meta?.toCategory ?? (await this.categoryOf(event)), updatedAt: event.occurredAt, version: versionOf(event), }); await this.notifier.issue(event.aggregateId, event); } + + /** Old events don't carry the category: ask the project's current settings. */ + private async categoryOf(event: IssueStatusChangedEvent) { + const project = await this.findProjects.findById(event.projectId); + return new ProjectWorkflow( + project?.settings ?? DEFAULT_PROJECT_SETTINGS, + ).categoryOf(event.to); + } } diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-timeline.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-timeline.event-handler.ts new file mode 100644 index 0000000..ebec13c --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-timeline.event-handler.ts @@ -0,0 +1,76 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + DEFAULT_PROJECT_SETTINGS, + IssueCreatedEvent, + IssueReleaseChangedEvent, + IssueStatusChangedEvent, + ProjectWorkflow, + TrackerEvent, +} from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { UpsertMaterializedTimelineRepository } from '../ports/upsert-materialized-timeline.repository'; +import { versionOf } from './read-model-notifier'; + +/** + * The life of every ticket, step by step: where it went (status + category) + * and which release it joined. Statistics are computed from it. + */ +@EventsHandler( + IssueCreatedEvent, + IssueStatusChangedEvent, + IssueReleaseChangedEvent, +) +export class IssueTimelineEventHandler implements IEventHandler { + constructor( + private readonly timeline: UpsertMaterializedTimelineRepository, + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + ) {} + + async handle(event: TrackerEvent) { + const base = { + id: `${event.aggregateId}:${versionOf(event)}`, + issueId: event.aggregateId, + projectId: event.projectId, + at: event.occurredAt, + }; + + if (event instanceof IssueCreatedEvent) { + const { issue } = event; + await this.timeline.record({ + ...base, + kind: 'status', + status: issue.status, + category: (await this.workflowOf(event)).categoryOf(issue.status), + assigneeId: issue.assigneeId, + }); + return; + } + if (event instanceof IssueStatusChangedEvent) { + const issue = await this.findIssues.findById(event.aggregateId); + await this.timeline.record({ + ...base, + kind: 'status', + status: event.to, + category: + event.meta?.toCategory ?? + (await this.workflowOf(event)).categoryOf(event.to), + assigneeId: issue?.assignee?.id ?? null, + }); + return; + } + if (event instanceof IssueReleaseChangedEvent) { + await this.timeline.record({ + ...base, + kind: 'release', + releaseId: event.releaseId, + }); + } + } + + private async workflowOf(event: TrackerEvent): Promise { + const project = await this.findProjects.findById(event.projectId); + return new ProjectWorkflow(project?.settings ?? DEFAULT_PROJECT_SETTINGS); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts index 35e1e4c..63e9aa8 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts @@ -1,5 +1,8 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; -import { ProjectCreatedEvent } from '@task-stream/api/tracker/domain'; +import { + defaultProjectSettings, + ProjectCreatedEvent, +} from '@task-stream/api/tracker/domain'; import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository'; import { UserDirectory } from '../ports/user-directory'; import { ReadModelNotifier, versionOf } from './read-model-notifier'; @@ -27,6 +30,8 @@ export class ProjectCreatedEventHandler user: users.of(m.userId), role: m.role, })), + // Projects created before settings existed get the defaults. + settings: project.settings ?? defaultProjectSettings(), createdAt: project.createdAt, updatedAt: project.updatedAt, version: versionOf(event), diff --git a/libs/api/tracker/application/src/lib/event-handlers/project-settings-changed.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/project-settings-changed.event-handler.ts new file mode 100644 index 0000000..484788c --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/project-settings-changed.event-handler.ts @@ -0,0 +1,37 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + ProjectSettingsChangedEvent, + StatusCategory, +} from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(ProjectSettingsChangedEvent) +export class ProjectSettingsChangedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository, + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: ProjectSettingsChangedEvent) { + await this.upsertMaterializedProjectRepository.upsert({ + id: event.projectId, + settings: event.settings, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + // A status may have changed category: open/finished counts depend on it. + const categories: Record = {}; + for (const status of event.settings.statuses) + categories[status.id] = status.category; + await this.upsertMaterializedIssueRepository.recategorize( + event.projectId, + categories, + ); + await this.notifier.project(event); + } +} diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index cd10a74..7eba6ec 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -5,8 +5,6 @@ import { IssueDetails, IssueLinkType, IssueReadModel, - IssueStatus, - IssueType, } from '@task-stream/api/tracker/domain'; import { AssignIssueCommand } from './commands/assign-issue.command'; import { ChangeIssueStatusCommand } from './commands/change-issue-status.command'; @@ -21,10 +19,15 @@ import { GetActivityQuery } from './queries/get-activity.query'; import { GetIssueQuery } from './queries/get-issue.query'; import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query'; import { SearchIssuesQuery } from './queries/search-issues.query'; +import { + MyIssuesFilter, + SearchMyIssuesQuery, +} from './queries/search-my-issues.query'; export interface CreateIssueInput extends Omit { projectId: string; - type: IssueType; + /** A type id of the project's settings. */ + type: string; title: string; assigneeId?: string | null; releaseId?: string | null; @@ -71,7 +74,7 @@ export class IssuesService { changeStatus( actorId: string, issueId: string, - status: IssueStatus, + status: string, reason?: string | null, ): Promise { return this.commandBus.execute( @@ -135,6 +138,14 @@ export class IssuesService { return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter)); } + /** Across all my projects: assigned to me, or any ticket (e.g. waiting for QA). */ + mine( + viewerId: string, + filter: MyIssuesFilter, + ): Promise> { + return this.queryBus.execute(new SearchMyIssuesQuery(viewerId, filter)); + } + activity( viewerId: string, params: { diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index 8f4d8b2..f059bd7 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -1,18 +1,18 @@ import { - IssueArea, IssuePriority, IssueReadModel, - IssueStatus, - IssueType, ProjectStatsReadModel, ReleaseProgressReadModel, + StatusCategory, } from '@task-stream/api/tracker/domain'; export interface IssueFilter { projectId: string; - statuses?: IssueStatus[]; - types?: IssueType[]; - areas?: IssueArea[]; + /** Status, type and area ids of the project's settings. */ + statuses?: string[]; + categories?: StatusCategory[]; + types?: string[]; + areas?: string[]; priorities?: IssuePriority[]; assigneeId?: string; unassigned?: boolean; @@ -23,6 +23,16 @@ export interface IssueFilter { offset: number; } +/** Tickets across several projects (the viewer's "my work"). */ +export interface CrossProjectIssueFilter { + projectIds: string[]; + assigneeId?: string; + statuses?: string[]; + categories?: StatusCategory[]; + limit: number; + offset: number; +} + export interface Page { items: T[]; total: number; @@ -33,7 +43,12 @@ export abstract class FindIssuesRepository { abstract findByKey(key: string): Promise; abstract findByIds(ids: string[]): Promise; abstract search(filter: IssueFilter): Promise>; + abstract searchAcross( + filter: CrossProjectIssueFilter, + ): Promise>; abstract stats(projectId: string): Promise; + /** Every ticket of a project (statistics). */ + abstract findByProject(projectId: string): Promise; abstract releaseProgress( releaseId: string, ): Promise; diff --git a/libs/api/tracker/application/src/lib/ports/find-releases.repository.ts b/libs/api/tracker/application/src/lib/ports/find-releases.repository.ts index 9f8f814..a8c3ba2 100644 --- a/libs/api/tracker/application/src/lib/ports/find-releases.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-releases.repository.ts @@ -3,4 +3,9 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain'; export abstract class FindReleasesRepository { abstract findById(id: string): Promise; abstract findByProject(projectId: string): Promise; + /** Planned releases of these projects, soonest target date first (undated last). */ + abstract findUpcoming( + projectIds: string[], + limit: number, + ): Promise; } diff --git a/libs/api/tracker/application/src/lib/ports/find-timeline.repository.ts b/libs/api/tracker/application/src/lib/ports/find-timeline.repository.ts new file mode 100644 index 0000000..43da38d --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/find-timeline.repository.ts @@ -0,0 +1,6 @@ +import { IssueTimelineEntry } from '@task-stream/api/tracker/domain'; + +export abstract class FindTimelineRepository { + /** Every step of every ticket of the project, oldest first. */ + abstract findByProject(projectId: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index aad3695..0ab91d1 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -14,3 +14,5 @@ export * from './upsert-materialized-activity.repository'; export * from './change-feed'; export * from './user-directory'; export * from './release-issues.repository'; +export * from './upsert-materialized-timeline.repository'; +export * from './find-timeline.repository'; diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts index b6c1af0..f60aeeb 100644 --- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts @@ -1,6 +1,7 @@ import { IssueLinkReadModel, IssueReadModel, + StatusCategory, } from '@task-stream/api/tracker/domain'; export abstract class UpsertMaterializedIssueRepository { @@ -19,6 +20,11 @@ export abstract class UpsertMaterializedIssueRepository { issueId: string, commentCount: number, ): Promise; + /** A project's statuses changed category: update its tickets' `statusCategory`. */ + abstract recategorize( + projectId: string, + categories: Record, + ): Promise; /** Keeps the denormalised release name of every issue in sync. */ abstract renameRelease(releaseId: string, name: string): Promise; } diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-timeline.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-timeline.repository.ts new file mode 100644 index 0000000..0acffe2 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-timeline.repository.ts @@ -0,0 +1,6 @@ +import { IssueTimelineEntry } from '@task-stream/api/tracker/domain'; + +export abstract class UpsertMaterializedTimelineRepository { + /** Stores a step of a ticket's life (idempotent by `entry.id`). */ + abstract record(entry: IssueTimelineEntry): Promise; +} diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index c37acf4..cdfceaf 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { + ProjectAnalyticsReadModel, ProjectReadModel, ProjectRole, + ProjectSettings, ProjectStatsReadModel, ReleaseReadModel, } from '@task-stream/api/tracker/domain'; @@ -12,9 +14,11 @@ import { CommandResult } from './commands/command-result'; import { CreateProjectCommand } from './commands/create-project.command'; import { RemoveProjectMemberCommand } from './commands/remove-project-member.command'; import { UpdateProjectCommand } from './commands/update-project.command'; +import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command'; import { IssueFilter } from './ports/find-issues.repository'; import { BoardColumn, GetBoardQuery } from './queries/get-board.query'; import { GetMyProjectsQuery } from './queries/get-my-projects.query'; +import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query'; import { GetProjectStatsQuery } from './queries/get-project-stats.query'; import { GetProjectQuery } from './queries/get-project.query'; import { GetReleasesQuery } from './queries/get-releases.query'; @@ -50,6 +54,17 @@ export class ProjectsService { ); } + /** Statuses, transitions, ticket types and areas (owners and admins). */ + updateSettings( + actorId: string, + projectId: string, + settings: ProjectSettings, + ): Promise { + return this.commandBus.execute( + new UpdateProjectSettingsCommand(actorId, projectId, settings), + ); + } + addMember( actorId: string, input: { projectId: string; userId: string; role: ProjectRole }, @@ -115,4 +130,15 @@ export class ProjectsService { releases(viewerId: string, projectId: string): Promise { return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId)); } + + /** Statistics over the last `days` days (created vs resolved, flow, times, QA). */ + analytics( + viewerId: string, + projectId: string, + days: number, + ): Promise { + return this.queryBus.execute( + new GetProjectAnalyticsQuery(viewerId, projectId, days), + ); + } } diff --git a/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts index 839fb4e..18aab66 100644 --- a/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts @@ -1,41 +1,49 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { IssueStatus } from '@task-stream/api/tracker/domain'; +import { + DEFAULT_PROJECT_SETTINGS, + StatusCategory, +} from '@task-stream/api/tracker/domain'; import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; import { BoardColumn, GetBoardQuery } from './get-board.query'; import { ReadAccess } from './read-access'; -const BOARD_COLUMNS: IssueStatus[] = [ - IssueStatus.BACKLOG, - IssueStatus.TODO, - IssueStatus.IN_PROGRESS, - IssueStatus.IN_REVIEW, - IssueStatus.READY_FOR_QA, - IssueStatus.IN_QA, - IssueStatus.QA_FAILED, - IssueStatus.DONE, -]; - @QueryHandler(GetBoardQuery) export class GetBoardQueryHandler implements IQueryHandler { constructor( private readonly findIssues: FindIssuesRepository, + private readonly findProjects: FindProjectsRepository, private readonly access: ReadAccess, ) {} + /** + * Columns follow the project's statuses in order. Cancelled ones stay off the + * board; archived ones only show while they still hold tickets. + */ async execute(query: GetBoardQuery): Promise { await this.access.assertCanView(query.filter.projectId, query.viewerId); - return Promise.all( - BOARD_COLUMNS.map(async (status) => { + const project = await this.findProjects.findById(query.filter.projectId); + const statuses = ( + project?.settings ?? DEFAULT_PROJECT_SETTINGS + ).statuses.filter((status) => status.category !== StatusCategory.CANCELLED); + const columns = await Promise.all( + statuses.map(async (status) => { const page = await this.findIssues.search({ ...query.filter, - statuses: [status], + statuses: [status.id], limit: Math.min(query.perColumn, 200), offset: 0, }); - return { status, issues: page.items, total: page.total }; + return { + column: { status: status.id, issues: page.items, total: page.total }, + archived: status.archived, + }; }), ); + return columns + .filter(({ column, archived }) => !archived || column.total > 0) + .map(({ column }) => column); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-board.query.ts b/libs/api/tracker/application/src/lib/queries/get-board.query.ts index a03e0ee..d9ce1c6 100644 --- a/libs/api/tracker/application/src/lib/queries/get-board.query.ts +++ b/libs/api/tracker/application/src/lib/queries/get-board.query.ts @@ -1,14 +1,15 @@ import { Query } from '@nestjs/cqrs'; -import { IssueReadModel, IssueStatus } from '@task-stream/api/tracker/domain'; +import { IssueReadModel } from '@task-stream/api/tracker/domain'; import { IssueFilter } from '../ports/find-issues.repository'; export interface BoardColumn { - status: IssueStatus; + /** Status id of the project's workflow. */ + status: string; issues: IssueReadModel[]; total: number; } -/** Kanban board: one column per status. */ +/** Kanban board: one column per status of the project (cancelled ones aside). */ export class GetBoardQuery extends Query { constructor( public readonly viewerId: string, diff --git a/libs/api/tracker/application/src/lib/queries/get-project-analytics.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query-handler.ts new file mode 100644 index 0000000..684bd88 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query-handler.ts @@ -0,0 +1,47 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { + DEFAULT_PROJECT_SETTINGS, + ProjectAnalyticsReadModel, +} from '@task-stream/api/tracker/domain'; +import { computeProjectAnalytics } from '../analytics/analytics'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { FindTimelineRepository } from '../ports/find-timeline.repository'; +import { GetProjectAnalyticsQuery } from './get-project-analytics.query'; +import { ReadAccess } from './read-access'; + +const DAY = 86_400_000; + +@QueryHandler(GetProjectAnalyticsQuery) +export class GetProjectAnalyticsQueryHandler + implements IQueryHandler +{ + constructor( + private readonly access: ReadAccess, + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + private readonly findTimeline: FindTimelineRepository, + ) {} + + async execute( + query: GetProjectAnalyticsQuery, + ): Promise { + await this.access.assertCanView(query.projectId, query.viewerId); + const [project, issues, timeline] = await Promise.all([ + this.findProjects.findById(query.projectId), + this.findIssues.findByProject(query.projectId), + this.findTimeline.findByProject(query.projectId), + ]); + const days = Math.min(Math.max(Math.round(query.days), 7), 365); + const to = new Date(); + return computeProjectAnalytics({ + issues, + timeline, + statusOrder: (project?.settings ?? DEFAULT_PROJECT_SETTINGS).statuses.map( + (status) => status.id, + ), + from: new Date(to.getTime() - (days - 1) * DAY), + to, + }); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-project-analytics.query.ts b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query.ts new file mode 100644 index 0000000..4872d2e --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query.ts @@ -0,0 +1,13 @@ +import { Query } from '@nestjs/cqrs'; +import { ProjectAnalyticsReadModel } from '@task-stream/api/tracker/domain'; + +/** Statistics of a project over the last `days` days. */ +export class GetProjectAnalyticsQuery extends Query { + constructor( + public readonly viewerId: string, + public readonly projectId: string, + public readonly days: number, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts new file mode 100644 index 0000000..ea797ca --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts @@ -0,0 +1,34 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain'; +import { computeReleaseBurnup } from '../analytics/analytics'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindReleasesRepository } from '../ports/find-releases.repository'; +import { FindTimelineRepository } from '../ports/find-timeline.repository'; +import { GetReleaseBurnupQuery } from './get-release-burnup.query'; +import { ReadAccess } from './read-access'; + +@QueryHandler(GetReleaseBurnupQuery) +export class GetReleaseBurnupQueryHandler + implements IQueryHandler +{ + constructor( + private readonly access: ReadAccess, + private readonly findReleases: FindReleasesRepository, + private readonly findIssues: FindIssuesRepository, + private readonly findTimeline: FindTimelineRepository, + ) {} + + async execute(query: GetReleaseBurnupQuery): Promise { + const release = this.access.assertFound( + await this.findReleases.findById(query.releaseId), + 'Release', + query.releaseId, + ); + await this.access.assertCanView(release.projectId, query.viewerId); + const [issues, timeline] = await Promise.all([ + this.findIssues.findByProject(release.projectId), + this.findTimeline.findByProject(release.projectId), + ]); + return computeReleaseBurnup({ release, issues, timeline, now: new Date() }); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query.ts b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query.ts new file mode 100644 index 0000000..7cc3afb --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query.ts @@ -0,0 +1,12 @@ +import { Query } from '@nestjs/cqrs'; +import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain'; + +/** Scope vs finished tickets of a release, day by day. */ +export class GetReleaseBurnupQuery extends Query { + constructor( + public readonly viewerId: string, + public readonly releaseId: string, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query-handler.ts new file mode 100644 index 0000000..a797d4c --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query-handler.ts @@ -0,0 +1,23 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { ReleaseReadModel } from '@task-stream/api/tracker/domain'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { FindReleasesRepository } from '../ports/find-releases.repository'; +import { GetUpcomingReleasesQuery } from './get-upcoming-releases.query'; + +@QueryHandler(GetUpcomingReleasesQuery) +export class GetUpcomingReleasesQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findProjects: FindProjectsRepository, + private readonly findReleases: FindReleasesRepository, + ) {} + + async execute(query: GetUpcomingReleasesQuery): Promise { + const projects = await this.findProjects.findByMember(query.viewerId); + return this.findReleases.findUpcoming( + projects.map((project) => project.id), + Math.min(query.limit, 50), + ); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query.ts b/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query.ts new file mode 100644 index 0000000..de5013b --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query.ts @@ -0,0 +1,12 @@ +import { Query } from '@nestjs/cqrs'; +import { ReleaseReadModel } from '@task-stream/api/tracker/domain'; + +/** Planned releases of the viewer's projects, soonest first. */ +export class GetUpcomingReleasesQuery extends Query { + constructor( + public readonly viewerId: string, + public readonly limit: number, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index ab2c946..618f960 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -5,9 +5,13 @@ export * from './get-comments.query'; export * from './get-issue.query'; export * from './get-issues-by-ids.query'; export * from './get-my-projects.query'; +export * from './get-project-analytics.query'; export * from './get-project-stats.query'; +export * from './get-release-burnup.query'; export * from './get-project.query'; export * from './get-release-progress.query'; export * from './get-release.query'; export * from './get-releases.query'; +export * from './get-upcoming-releases.query'; export * from './search-issues.query'; +export * from './search-my-issues.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index 5c7d1c6..4cde03e 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -6,11 +6,15 @@ import { GetIssueQueryHandler } from './get-issue.query-handler'; import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler'; import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler'; import { GetProjectQueryHandler } from './get-project.query-handler'; +import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler'; import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler'; +import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler'; import { GetReleaseQueryHandler } from './get-release.query-handler'; import { GetReleaseProgressQueryHandler } from './get-release-progress.query-handler'; import { GetReleasesQueryHandler } from './get-releases.query-handler'; +import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler'; import { SearchIssuesQueryHandler } from './search-issues.query-handler'; +import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; export const QUERY_HANDLERS = [ CanViewProjectQueryHandler, @@ -21,9 +25,13 @@ export const QUERY_HANDLERS = [ GetIssuesByIdsQueryHandler, GetMyProjectsQueryHandler, GetProjectQueryHandler, + GetProjectAnalyticsQueryHandler, GetProjectStatsQueryHandler, + GetReleaseBurnupQueryHandler, GetReleaseQueryHandler, GetReleaseProgressQueryHandler, GetReleasesQueryHandler, + GetUpcomingReleasesQueryHandler, SearchIssuesQueryHandler, + SearchMyIssuesQueryHandler, ]; diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts new file mode 100644 index 0000000..044a40d --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts @@ -0,0 +1,28 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository, Page } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { SearchMyIssuesQuery } from './search-my-issues.query'; + +@QueryHandler(SearchMyIssuesQuery) +export class SearchMyIssuesQueryHandler + implements IQueryHandler> +{ + constructor( + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + ) {} + + /** Membership is the access rule: only projects the viewer belongs to. */ + async execute(query: SearchMyIssuesQuery): Promise> { + const projects = await this.findProjects.findByMember(query.viewerId); + return this.findIssues.searchAcross({ + projectIds: projects.map((project) => project.id), + assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, + statuses: query.filter.statuses, + categories: query.filter.categories, + limit: Math.min(query.filter.limit, 200), + offset: query.filter.offset, + }); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts new file mode 100644 index 0000000..87b539c --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts @@ -0,0 +1,26 @@ +import { Query } from '@nestjs/cqrs'; +import { + IssueReadModel, + StatusCategory, +} from '@task-stream/api/tracker/domain'; +import { Page } from '../ports/find-issues.repository'; + +export interface MyIssuesFilter { + /** true: assigned to the viewer. false: any ticket of the viewer's projects. */ + assignedToMe: boolean; + statuses?: string[]; + /** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */ + categories?: StatusCategory[]; + limit: number; + offset: number; +} + +/** Tickets across every project the viewer is a member of ("my work"). */ +export class SearchMyIssuesQuery extends Query> { + constructor( + public readonly viewerId: string, + public readonly filter: MyIssuesFilter, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/releases.service.ts b/libs/api/tracker/application/src/lib/releases.service.ts index 918b0b4..2aef50e 100644 --- a/libs/api/tracker/application/src/lib/releases.service.ts +++ b/libs/api/tracker/application/src/lib/releases.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { + ReleaseBurnupReadModel, ReleaseProgressReadModel, ReleaseReadModel, } from '@task-stream/api/tracker/domain'; @@ -11,6 +12,8 @@ import { PublishReleaseCommand } from './commands/publish-release.command'; import { UpdateReleaseCommand } from './commands/update-release.command'; import { GetReleaseProgressQuery } from './queries/get-release-progress.query'; import { GetReleaseQuery } from './queries/get-release.query'; +import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query'; +import { GetReleaseBurnupQuery } from './queries/get-release-burnup.query'; interface ReleaseDetails { name?: string; @@ -71,6 +74,11 @@ export class ReleasesService { return this.queryBus.execute(new GetReleaseQuery(viewerId, releaseId)); } + /** Planned releases of all my projects, soonest target date first. */ + upcoming(viewerId: string, limit: number): Promise { + return this.queryBus.execute(new GetUpcomingReleasesQuery(viewerId, limit)); + } + progress( viewerId: string, releaseId: string, @@ -79,4 +87,11 @@ export class ReleasesService { new GetReleaseProgressQuery(viewerId, releaseId), ); } + + /** Scope vs finished tickets of the release, day by day. */ + burnup(viewerId: string, releaseId: string): Promise { + return this.queryBus.execute( + new GetReleaseBurnupQuery(viewerId, releaseId), + ); + } } diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 5e9a72e..1270696 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -14,6 +14,7 @@ import { ProjectsService } from './projects.service'; import { QUERY_HANDLERS } from './queries/query-handlers'; import { ReadAccess } from './queries/read-access'; import { ReleasesService } from './releases.service'; +import { WorkflowsService } from './workflows.service'; const SERVICES = [ ProjectsService, @@ -21,6 +22,7 @@ const SERVICES = [ CommentsService, ReleasesService, ChangesService, + WorkflowsService, ]; /** diff --git a/libs/api/tracker/application/src/lib/workflows.service.ts b/libs/api/tracker/application/src/lib/workflows.service.ts new file mode 100644 index 0000000..fe004bc --- /dev/null +++ b/libs/api/tracker/application/src/lib/workflows.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { + DEFAULT_PROJECT_SETTINGS, + IssueReadModel, + ProjectWorkflow, +} from '@task-stream/api/tracker/domain'; +import { FindProjectsRepository } from './ports/find-projects.repository'; + +/** How long a project's workflow is reused (a board asks once per ticket). */ +const TTL_MS = 2_000; + +/** + * The workflow of each project, for presenting tickets (e.g. which statuses a + * ticket can move to). Commands always load the aggregate instead. + */ +@Injectable() +export class WorkflowsService { + private readonly cache = new Map< + string, + { at: number; workflow: Promise } + >(); + + constructor(private readonly projects: FindProjectsRepository) {} + + of(projectId: string): Promise { + const now = Date.now(); + const hit = this.cache.get(projectId); + if (hit && now - hit.at < TTL_MS) return hit.workflow; + for (const [id, entry] of this.cache) + if (now - entry.at >= TTL_MS) this.cache.delete(id); + const workflow = this.projects + .findById(projectId) + .then( + (project) => + new ProjectWorkflow(project?.settings ?? DEFAULT_PROJECT_SETTINGS), + ); + this.cache.set(projectId, { at: now, workflow }); + return workflow; + } + + /** Active statuses the ticket can move to next. */ + async allowedTransitions(issue: IssueReadModel): Promise { + return (await this.of(issue.projectId)).allowedFrom(issue.status); + } +} diff --git a/libs/api/tracker/domain/README.md b/libs/api/tracker/domain/README.md index 5a704f3..732c858 100644 --- a/libs/api/tracker/domain/README.md +++ b/libs/api/tracker/domain/README.md @@ -10,8 +10,11 @@ El modelo del producto: proyectos, tickets, comentarios y releases. - Agregados event-sourced: `Project`, `Issue`, `Comment`, `Release`. Validan en sus métodos y cambian de estado en `on`. - `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`. - `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`). -- `issue/issue-workflow.ts`: transiciones de estado permitidas y estados que exigen motivo. -- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…). +- `project/project-settings.ts`: configuración por proyecto (estados con categoría, transiciones, tipos, áreas), sus + valores por defecto, `ProjectWorkflow` (lo que consultan tickets y queries) y `validateProjectSettings` (archivar, + nunca borrar). +- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…), incluida la línea temporal + de los tickets y las estadísticas (`analytics.read-model.ts`). Reglas y workflow: [API](../../../../docs/03-api.md#31-modelo-de-dominio-contexto-tracker). diff --git a/libs/api/tracker/domain/project.json b/libs/api/tracker/domain/project.json index 56b8567..0480eed 100644 --- a/libs/api/tracker/domain/project.json +++ b/libs/api/tracker/domain/project.json @@ -3,10 +3,7 @@ "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/api/tracker/domain/src", "projectType": "library", - "tags": [ - "scope:api-tracker", - "type:domain" - ], + "tags": ["scope:api-tracker", "type:domain"], "// targets": "to see all targets run: nx show project api-tracker-domain --web", "targets": {} } diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 3455ce0..27c1d1d 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -2,7 +2,7 @@ export * from './lib/project/project'; export * from './lib/project/project-role'; export * from './lib/issue/issue'; export * from './lib/issue/issue.enums'; -export * from './lib/issue/issue-workflow'; +export * from './lib/project/project-settings'; export * from './lib/comment/comment'; export * from './lib/release/release'; export * from './lib/release/release-status'; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 1fc7b73..05cbc90 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -1,6 +1,7 @@ export * from './tracker.event'; export * from './project-created.event'; export * from './project-updated.event'; +export * from './project-settings-changed.event'; export * from './project-member-added.event'; export * from './project-member-removed.event'; export * from './project-member-role-changed.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-status-changed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-status-changed.event.ts index be23e20..04b8895 100644 --- a/libs/api/tracker/domain/src/lib/events/issue-status-changed.event.ts +++ b/libs/api/tracker/domain/src/lib/events/issue-status-changed.event.ts @@ -1,17 +1,27 @@ import { AutowiredEvent } from '@task-stream/api/shared/domain'; -import { IssueStatus } from '../issue/issue.enums'; +import type { StatusCategory } from '../project/project-settings'; import { TrackerEvent } from './tracker.event'; -/** A ticket moved through the workflow (incl. QA). */ +/** Names and categories of both statuses when the move happened (absent in old events). */ +export interface StatusChangeMeta { + fromName: string; + toName: string; + fromCategory: StatusCategory; + toCategory: StatusCategory; +} + +/** A ticket moved through its project's workflow (incl. QA). */ @AutowiredEvent export class IssueStatusChangedEvent extends TrackerEvent { constructor( actorId: string, issueId: string, projectId: string, - readonly from: IssueStatus, - readonly to: IssueStatus, + /** Status ids of the project's workflow, e.g. IN_QA → QA_FAILED. */ + readonly from: string, + readonly to: string, readonly reason: string | null, + readonly meta?: StatusChangeMeta, ) { super(actorId, issueId, projectId); } diff --git a/libs/api/tracker/domain/src/lib/events/project-settings-changed.event.ts b/libs/api/tracker/domain/src/lib/events/project-settings-changed.event.ts new file mode 100644 index 0000000..bfef7ce --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/project-settings-changed.event.ts @@ -0,0 +1,15 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import type { ProjectSettings } from '../project/project-settings'; +import { TrackerEvent } from './tracker.event'; + +/** Statuses, transitions, ticket types or areas of a project changed (full snapshot). */ +@AutowiredEvent +export class ProjectSettingsChangedEvent extends TrackerEvent { + constructor( + actorId: string, + projectId: string, + readonly settings: ProjectSettings, + ) { + super(actorId, projectId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts index 32c46d0..4464f6e 100644 --- a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts +++ b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts @@ -7,23 +7,32 @@ import { IssueProps, normalizeIssueDetails, } from '../issue/issue'; -import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums'; +import { IssuePriority } from '../issue/issue.enums'; +import { ProjectWorkflow } from '../project/project-settings'; export interface CreateIssueProps extends IssueDetails { projectId: string; projectKey: string; number: number; title: string; - type: IssueType; + type: string; reporterId: string; assigneeId?: string | null; } @Injectable() export class IssueFactory { - /** New tickets start in BACKLOG with a project-scoped key (e.g. TS-7). */ - create(props: CreateIssueProps): Issue { + /** + * New tickets start in the project's first active TODO status (BACKLOG by + * default) with a project-scoped key (e.g. TS-7). + */ + create( + props: CreateIssueProps, + workflow: ProjectWorkflow = ProjectWorkflow.default(), + ): Issue { const details = normalizeIssueDetails(props); + workflow.assertActiveType(props.type); + workflow.assertActiveArea(props.area); const title = details.title ?? ''; assertThat(title.length > 0, 'Issue title is required'); @@ -37,7 +46,7 @@ export class IssueFactory { area: props.area ?? null, title, description: details.description ?? null, - status: IssueStatus.BACKLOG, + status: workflow.initialStatus(), priority: props.priority ?? IssuePriority.MEDIUM, reporterId: props.reporterId, assigneeId: props.assigneeId ?? null, diff --git a/libs/api/tracker/domain/src/lib/factories/project.factory.ts b/libs/api/tracker/domain/src/lib/factories/project.factory.ts index 0bafba7..892778f 100644 --- a/libs/api/tracker/domain/src/lib/factories/project.factory.ts +++ b/libs/api/tracker/domain/src/lib/factories/project.factory.ts @@ -3,6 +3,7 @@ import { assertThat, newId } from '@task-stream/api/shared/domain'; import { ProjectCreatedEvent } from '../events/project-created.event'; import { Project, ProjectProps } from '../project/project'; import { ProjectRole } from '../project/project-role'; +import { defaultProjectSettings } from '../project/project-settings'; const KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/; @@ -30,6 +31,7 @@ export class ProjectFactory { description: props.description?.trim() || null, ownerId: props.ownerId, members: [{ userId: props.ownerId, role: ProjectRole.OWNER }], + settings: defaultProjectSettings(), createdAt: now, updatedAt: now, }; diff --git a/libs/api/tracker/domain/src/lib/issue/issue-workflow.ts b/libs/api/tracker/domain/src/lib/issue/issue-workflow.ts deleted file mode 100644 index ee21b04..0000000 --- a/libs/api/tracker/domain/src/lib/issue/issue-workflow.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { IssueStatus as S } from './issue.enums'; - -/** - * Allowed status transitions. QA is a first-class step: work goes - * READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS. - */ -const TRANSITIONS: Record = { - [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], -}; - -/** Statuses that require an explanation when entered. */ -const REASON_REQUIRED: readonly S[] = [S.QA_FAILED, S.CANCELLED]; - -export const FINISHED_STATUSES: readonly S[] = [S.DONE, S.CANCELLED]; - -export const IssueWorkflow = { - canTransition(from: S, to: S): boolean { - return TRANSITIONS[from].includes(to); - }, - allowedFrom(from: S): readonly S[] { - return TRANSITIONS[from]; - }, - requiresReason(to: S): boolean { - return REASON_REQUIRED.includes(to); - }, - isFinished(status: S): boolean { - return FINISHED_STATUSES.includes(status); - }, -}; diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 1a26b2e..c8fd5e9 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -10,14 +10,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event' import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueUnlinkedEvent } from '../events/issue-unlinked.event'; import { IssueUpdatedEvent } from '../events/issue-updated.event'; -import { - IssueArea, - IssueLinkType, - IssuePriority, - IssueStatus, - IssueType, -} from './issue.enums'; -import { IssueWorkflow } from './issue-workflow'; +import { ProjectWorkflow } from '../project/project-settings'; +import { IssueLinkType, IssuePriority } from './issue.enums'; export interface IssueLink { type: IssueLinkType; @@ -29,8 +23,10 @@ export interface IssueLink { export interface IssueDetails { title?: string; description?: string | null; - type?: IssueType; - area?: IssueArea | null; + /** Id of one of the project's ticket types. */ + type?: string; + /** Id of one of the project's areas. */ + area?: string | null; priority?: IssuePriority; labels?: string[]; figmaUrl?: string | null; @@ -44,11 +40,12 @@ export interface IssueProps { projectId: string; number: number; key: string; - type: IssueType; - area: IssueArea | null; + type: string; + area: string | null; title: string; description: string | null; - status: IssueStatus; + /** Id of a status of the project's workflow. */ + status: string; priority: IssuePriority; reporterId: string; assigneeId: string | null; @@ -70,11 +67,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public projectId!: string; public number!: number; public key!: string; - public type!: IssueType; - public area: IssueArea | null = null; + public type!: string; + public area: string | null = null; public title!: string; public description: string | null = null; - public status: IssueStatus = IssueStatus.BACKLOG; + public status!: string; public priority: IssuePriority = IssuePriority.MEDIUM; public reporterId!: string; public assigneeId: string | null = null; @@ -92,7 +89,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.id = id; } - updateDetails(actorId: string, changes: IssueDetails): void { + /** `workflow` (the project's) validates a new type or area. */ + updateDetails( + actorId: string, + changes: IssueDetails, + workflow: ProjectWorkflow = ProjectWorkflow.default(), + ): void { const next = normalizeIssueDetails(changes); const patch: IssueDetails = {}; for (const field of Object.keys(next) as (keyof IssueDetails)[]) { @@ -103,22 +105,36 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { } if (patch.title !== undefined) assertThat(patch.title.length > 0, 'Issue title is required'); + if (patch.type !== undefined) workflow.assertActiveType(patch.type); + if (patch.area !== undefined) workflow.assertActiveArea(patch.area); if (Object.keys(patch).length === 0) return; this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch)); } - changeStatus(actorId: string, to: IssueStatus, reason?: string | null): void { + /** Moves the ticket along its project's workflow. */ + changeStatus( + actorId: string, + to: string, + reason?: string | null, + workflow: ProjectWorkflow = ProjectWorkflow.default(), + ): void { const from = this.status; if (from === to) return; - if (!IssueWorkflow.canTransition(from, to)) { + if (!workflow.canTransition(from, to)) { throw new BusinessRuleError( - `Cannot move ${this.key} from ${from} to ${to}. Allowed: ${IssueWorkflow.allowedFrom(from).join(', ')}`, + `Cannot move ${this.key} from ${workflow.statusName(from)} to ${workflow.statusName(to)}. ` + + `Allowed: ${ + workflow + .allowedFrom(from) + .map((s) => workflow.statusName(s)) + .join(', ') || 'none' + }`, ); } const trimmedReason = reason?.trim() || null; - if (IssueWorkflow.requiresReason(to) && !trimmedReason) { + if (workflow.requiresReason(to) && !trimmedReason) { throw new BusinessRuleError( - `A reason is required to move ${this.key} to ${to}`, + `A reason is required to move ${this.key} to ${workflow.statusName(to)}`, ); } this.apply( @@ -129,6 +145,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { from, to, trimmedReason, + { + fromName: workflow.statusName(from), + toName: workflow.statusName(to), + fromCategory: workflow.categoryOf(from), + toCategory: workflow.categoryOf(to), + }, ), ); } diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts new file mode 100644 index 0000000..3b9c9c4 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts @@ -0,0 +1,166 @@ +import { + BusinessRuleError, + ForbiddenError, + ValidationError, +} from '@task-stream/api/shared/domain'; +import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event'; +import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; +import { IssueFactory } from '../factories/issue.factory'; +import { ProjectFactory } from '../factories/project.factory'; +import { replay } from '../testing'; +import { Project } from './project'; +import { ProjectRole } from './project-role'; +import { + defaultProjectSettings, + ProjectSettings, + StatusCategory, +} from './project-settings'; + +const newProject = () => { + const project = new ProjectFactory().create({ + key: 'WEB', + name: 'Web', + ownerId: 'owner', + }); + project.addMember('owner', 'dev', ProjectRole.MEMBER); + return project; +}; + +/** Defaults plus a STAGING step between review and QA, and a SPIKE type. */ +const withStaging = (): ProjectSettings => { + const settings = defaultProjectSettings(); + settings.statuses.splice(4, 0, { + id: 'STAGING', + name: 'En staging', + category: StatusCategory.QA, + color: 'teal', + requiresReason: false, + archived: false, + }); + settings.transitions['IN_REVIEW'] = ['STAGING']; + settings.transitions['STAGING'] = ['IN_QA', 'IN_PROGRESS']; + settings.issueTypes.push({ + id: 'SPIKE', + name: 'Spike', + icon: 'light-bulb', + color: 'teal', + archived: false, + }); + return settings; +}; + +describe('Project settings', () => { + it('starts with the default workflow, also when rebuilt from old events', () => { + const project = newProject(); + expect(project.workflow.initialStatus()).toBe('BACKLOG'); + expect(project.workflow.canTransition('IN_QA', 'QA_FAILED')).toBe(true); + + // Projects created before settings existed have no `settings` in their event. + const { history } = replay(project, Project); + delete (history[0].data as unknown as { project: { settings?: unknown } }) + .project.settings; + const old = new Project(project.id); + old.loadFromHistory(history); + expect(old.settings).toEqual(defaultProjectSettings()); + }); + + it('lets owners and admins customise it, as an event that replays', () => { + const project = newProject(); + project.updateSettings('owner', withStaging()); + + const event = project.getUncommittedEvents().at(-1); + expect(event).toBeInstanceOf(ProjectSettingsChangedEvent); + expect(project.workflow.allowedFrom('IN_REVIEW')).toEqual(['STAGING']); + + const { rebuilt } = replay(project, Project); + expect(rebuilt.workflow.status('STAGING')?.category).toBe( + StatusCategory.QA, + ); + expect(() => project.updateSettings('dev', withStaging())).toThrow( + ForbiddenError, + ); + }); + + it('archives values instead of deleting them', () => { + const project = newProject(); + const settings = defaultProjectSettings(); + settings.areas = settings.areas.filter((a) => a.id !== 'MOBILE'); + expect(() => project.updateSettings('owner', settings)).toThrow( + /archívalo/, + ); + + const archived = defaultProjectSettings(); + archived.areas = archived.areas.map((a) => + a.id === 'MOBILE' ? { ...a, archived: true } : a, + ); + project.updateSettings('owner', archived); + expect(() => project.workflow.assertActiveArea('MOBILE')).toThrow( + BusinessRuleError, + ); + }); + + it('rejects settings that would break the board', () => { + const project = newProject(); + const noStart = defaultProjectSettings(); + noStart.statuses + .filter((s) => s.category === StatusCategory.TODO) + .forEach((s) => (s.archived = true)); + expect(() => project.updateSettings('owner', noStart)).toThrow( + ValidationError, + ); + + const badTransition = defaultProjectSettings(); + badTransition.transitions['TODO'] = ['NOPE']; + expect(() => project.updateSettings('owner', badTransition)).toThrow( + /no existe/, + ); + + const duplicate = defaultProjectSettings(); + duplicate.issueTypes.push({ ...duplicate.issueTypes[0] }); + expect(() => project.updateSettings('owner', duplicate)).toThrow(/id BUG/); + }); + + it('drives tickets: types, transitions and names in the history', () => { + const project = newProject(); + project.updateSettings('owner', withStaging()); + const workflow = project.workflow; + const issue = new IssueFactory().create( + { + projectId: project.id, + projectKey: 'WEB', + number: 1, + title: 'Investigar pagos', + type: 'SPIKE', + reporterId: 'dev', + }, + workflow, + ); + for (const to of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'STAGING']) + issue.changeStatus('dev', to, null, workflow); + + const last = issue.getUncommittedEvents().at(-1) as IssueStatusChangedEvent; + expect(last.meta).toEqual({ + fromName: 'En revisión', + toName: 'En staging', + fromCategory: StatusCategory.IN_PROGRESS, + toCategory: StatusCategory.QA, + }); + // The default IN_REVIEW → READY_FOR_QA was replaced by STAGING. + expect(() => + issue.changeStatus('dev', 'READY_FOR_QA', null, workflow), + ).toThrow(BusinessRuleError); + expect(() => + new IssueFactory().create( + { + projectId: project.id, + projectKey: 'WEB', + number: 2, + title: 'x', + type: 'EPIC', + reporterId: 'dev', + }, + workflow, + ), + ).toThrow(/Unknown ticket type/); + }); +}); diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.ts b/libs/api/tracker/domain/src/lib/project/project-settings.ts new file mode 100644 index 0000000..8510a10 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/project/project-settings.ts @@ -0,0 +1,395 @@ +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; +} + +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; + 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); + } + + /** 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, + })); + 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}`, + ); + } + 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 = {}; + 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; +} diff --git a/libs/api/tracker/domain/src/lib/project/project.ts b/libs/api/tracker/domain/src/lib/project/project.ts index 1bb53eb..8503641 100644 --- a/libs/api/tracker/domain/src/lib/project/project.ts +++ b/libs/api/tracker/domain/src/lib/project/project.ts @@ -8,8 +8,15 @@ import { ProjectCreatedEvent } from '../events/project-created.event'; import { ProjectMemberAddedEvent } from '../events/project-member-added.event'; import { ProjectMemberRemovedEvent } from '../events/project-member-removed.event'; import { ProjectMemberRoleChangedEvent } from '../events/project-member-role-changed.event'; +import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event'; import { ProjectUpdatedEvent } from '../events/project-updated.event'; import { ProjectRole } from './project-role'; +import { + defaultProjectSettings, + ProjectSettings, + ProjectWorkflow, + validateProjectSettings, +} from './project-settings'; export interface ProjectMember { userId: string; @@ -24,6 +31,8 @@ export interface ProjectProps { description: string | null; ownerId: string; members: ProjectMember[]; + /** Absent in projects created before settings existed: defaults apply. */ + settings?: ProjectSettings; createdAt: string; updatedAt: string; } @@ -38,6 +47,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { public description: string | null = null; public ownerId!: string; public members: ProjectMember[] = []; + public settings: ProjectSettings = defaultProjectSettings(); public createdAt!: string; public updatedAt!: string; @@ -46,6 +56,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { this.id = id; } + /** Statuses, transitions, types and areas of this project. */ + get workflow(): ProjectWorkflow { + return new ProjectWorkflow(this.settings); + } + roleOf(userId: string): ProjectRole | null { return this.members.find((m) => m.userId === userId)?.role ?? null; } @@ -98,6 +113,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { this.apply(new ProjectUpdatedEvent(actorId, this.id, patch)); } + /** Owners and admins customise statuses, transitions, types and areas. */ + updateSettings(actorId: string, next: ProjectSettings): void { + this.assertCanManage(actorId); + const settings = validateProjectSettings(next, this.settings); + if (JSON.stringify(settings) === JSON.stringify(this.settings)) return; + this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings)); + } + addMember(actorId: string, userId: string, role: ProjectRole): void { this.assertCanManage(actorId); assertThat(role !== ProjectRole.OWNER, 'A project can only have one owner'); @@ -154,6 +177,9 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { this.description = project.description; this.ownerId = project.ownerId; this.members = project.members.map((m) => ({ ...m })); + this.settings = structuredClone( + project.settings ?? defaultProjectSettings(), + ); this.createdAt = project.createdAt; this.updatedAt = project.updatedAt; } @@ -163,6 +189,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { this.updatedAt = event.occurredAt; } + onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void { + this.settings = structuredClone(event.settings); + this.updatedAt = event.occurredAt; + } + onProjectMemberAddedEvent(event: ProjectMemberAddedEvent): void { this.members = [ ...this.members, diff --git a/libs/api/tracker/domain/src/lib/read-models/analytics.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/analytics.read-model.ts new file mode 100644 index 0000000..b67cd5f --- /dev/null +++ b/libs/api/tracker/domain/src/lib/read-models/analytics.read-model.ts @@ -0,0 +1,58 @@ +import { StatusCategory } from '../project/project-settings'; +import { UserRefReadModel } from './user-ref.read-model'; + +/** + * One step in a ticket's life (projected from its events): the raw material + * of the statistics. `status` entries record where the ticket went (with the + * category it had then), `release` entries which release it joined or left. + */ +export class IssueTimelineEntry { + /** Id of the event that produced it (projection is idempotent). */ + id!: string; + issueId!: string; + projectId!: string; + at!: string; + kind!: 'status' | 'release'; + status?: string; + category?: StatusCategory; + /** Assignee when the status changed (throughput per person). */ + assigneeId?: string | null; + releaseId?: string | null; +} + +export class DurationStats { + count!: number; + averageDays!: number | null; + /** 85th percentile: "85% of tickets take at most this long". */ + p85Days!: number | null; +} + +/** Statistics of a project over a period. */ +export class ProjectAnalyticsReadModel { + from!: string; + to!: string; + /** Per week (Monday): tickets created and tickets finished. */ + createdVsResolved!: { week: string; created: number; resolved: number }[]; + /** Tickets in each status at the end of every day (cumulative flow). */ + cumulativeFlow!: { + dates: string[]; + series: { status: string; counts: number[] }[]; + }; + /** From starting work to done. */ + cycleTime!: DurationStats; + /** From creation to done. */ + leadTime!: DurationStats; + /** Tickets finished per person and week. */ + throughput!: { week: string; user: UserRefReadModel | null; count: number }[]; + /** Tickets leaving QA: to done (passed) or back to work (failed). */ + qa!: { passed: number; failed: number; failureRate: number | null }; +} + +/** Scope vs finished tickets of a release, day by day. */ +export class ReleaseBurnupReadModel { + releaseId!: string; + targetDate!: string | null; + dates!: string[]; + scope!: number[]; + done!: number[]; +} diff --git a/libs/api/tracker/domain/src/lib/read-models/index.ts b/libs/api/tracker/domain/src/lib/read-models/index.ts index d64dfac..23af381 100644 --- a/libs/api/tracker/domain/src/lib/read-models/index.ts +++ b/libs/api/tracker/domain/src/lib/read-models/index.ts @@ -5,3 +5,4 @@ export * from './comment.read-model'; export * from './release.read-model'; export * from './activity.read-model'; export * from './project-stats.read-model'; +export * from './analytics.read-model'; diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 48ae069..cc60d6d 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -1,10 +1,5 @@ -import { - IssueArea, - IssueLinkType, - IssuePriority, - IssueStatus, - IssueType, -} from '../issue/issue.enums'; +import { IssueLinkType, IssuePriority } from '../issue/issue.enums'; +import { StatusCategory } from '../project/project-settings'; import { UserRefReadModel } from './user-ref.read-model'; export type LinkDirection = 'OUTWARD' | 'INWARD'; @@ -23,11 +18,14 @@ export class IssueReadModel { projectKey!: string; number!: number; key!: string; - type!: IssueType; - area!: IssueArea | null; + /** Ids from the project's settings. */ + type!: string; + area!: string | null; title!: string; description!: string | null; - status!: IssueStatus; + status!: string; + /** Category of `status` (kept in sync when the project's settings change). */ + statusCategory!: StatusCategory; priority!: IssuePriority; reporter!: UserRefReadModel; assignee!: UserRefReadModel | null; diff --git a/libs/api/tracker/domain/src/lib/read-models/project-stats.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/project-stats.read-model.ts index aff69b0..b74e31b 100644 --- a/libs/api/tracker/domain/src/lib/read-models/project-stats.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/project-stats.read-model.ts @@ -1,4 +1,4 @@ -import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums'; +import { IssuePriority } from '../issue/issue.enums'; import { UserRefReadModel } from './user-ref.read-model'; export class CountBy { @@ -10,8 +10,9 @@ export class CountBy { export class ProjectStatsReadModel { total!: number; open!: number; - byStatus!: CountBy[]; - byType!: CountBy[]; + /** Keys are status / type ids of the project's settings. */ + byStatus!: CountBy[]; + byType!: CountBy[]; byPriority!: CountBy[]; byArea!: CountBy[]; /** Open tickets per person (user null = unassigned). */ diff --git a/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts index 7a31995..420da1f 100644 --- a/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts @@ -1,4 +1,5 @@ import { ProjectRole } from '../project/project-role'; +import { ProjectSettings } from '../project/project-settings'; import { UserRefReadModel } from './user-ref.read-model'; export class ProjectMemberReadModel { @@ -13,6 +14,8 @@ export class ProjectReadModel { description!: string | null; owner!: UserRefReadModel; members!: ProjectMemberReadModel[]; + /** Statuses, transitions, ticket types and areas. */ + settings!: ProjectSettings; createdAt!: string; updatedAt!: string; /** Position of the last projected event. */ diff --git a/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts index 9eeee52..b6c3bae 100644 --- a/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts @@ -1,4 +1,3 @@ -import { IssueStatus } from '../issue/issue.enums'; import { ReleaseStatus } from '../release/release-status'; export class ReleaseReadModel { @@ -17,5 +16,5 @@ export class ReleaseProgressReadModel { total!: number; finished!: number; percent!: number; - byStatus!: { key: IssueStatus; count: number }[]; + byStatus!: { key: string; count: number }[]; } diff --git a/libs/api/tracker/infrastructure/README.md b/libs/api/tracker/infrastructure/README.md index 6ca3d8a..72132a3 100644 --- a/libs/api/tracker/infrastructure/README.md +++ b/libs/api/tracker/infrastructure/README.md @@ -8,7 +8,9 @@ Adaptadores de salida del tracker. ## Contenido - `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`) y `ReleaseIssuesRepository` (consulta el event store). -- `mongo/`: esquemas del read model, repositorios `Find*` y `UpsertMaterialized*` (upsert idempotente por versión). +- `mongo/`: esquemas del read model (incluida la línea temporal `issue_timeline`), repositorios `Find*` y + `UpsertMaterialized*` (upsert idempotente por versión) y `StatusCategoryBackfill` (al arrancar, rellena la categoría + del estado en los tickets proyectados antes de existir). - `realtime/`: `RedisChangeFeed` (canales de Redis pub/sub por proyecto, ticket y usuario). - `identity/`: `IdentityUserDirectory`, capa anticorrupción hacia el contexto de identidad. diff --git a/libs/api/tracker/infrastructure/project.json b/libs/api/tracker/infrastructure/project.json index 4cd4746..44c6267 100644 --- a/libs/api/tracker/infrastructure/project.json +++ b/libs/api/tracker/infrastructure/project.json @@ -3,10 +3,7 @@ "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/api/tracker/infrastructure/src", "projectType": "library", - "tags": [ - "scope:api-tracker", - "type:infrastructure" - ], + "tags": ["scope:api-tracker", "type:infrastructure"], "// targets": "to see all targets run: nx show project api-tracker-infrastructure --web", "targets": {} } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 4ad5794..92993ca 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -1,11 +1,13 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { + CrossProjectIssueFilter, FindActivityRepository, FindCommentsRepository, FindIssuesRepository, FindProjectsRepository, FindReleasesRepository, + FindTimelineRepository, IssueFilter, Page, } from '@task-stream/api/tracker/application'; @@ -13,14 +15,16 @@ import { ActivityReadModel, CommentReadModel, CountBy, - FINISHED_STATUSES, + defaultProjectSettings, IssueReadModel, - IssueStatus, + IssueTimelineEntry, ProjectReadModel, ProjectRole, ProjectStatsReadModel, ReleaseProgressReadModel, ReleaseReadModel, + ReleaseStatus, + StatusCategory, UserRefReadModel, } from '@task-stream/api/tracker/domain'; import { FilterQuery, Model } from 'mongoose'; @@ -31,6 +35,7 @@ import { ProjectDoc, ReadModels, ReleaseDoc, + TimelineDoc, fromDoc, issueFromDoc, } from './read-model.schemas'; @@ -40,9 +45,15 @@ import { const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; + function toProject(doc: ProjectDoc): ProjectReadModel { const { memberIds: _ignored, ...rest } = doc; - return fromDoc(rest); + // Projects projected before settings existed use the defaults. + return { + ...fromDoc(rest), + settings: rest.settings ?? defaultProjectSettings(), + }; } @Injectable() @@ -123,6 +134,29 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { return { items: docs.map(issueFromDoc), total }; } + async searchAcross( + filter: CrossProjectIssueFilter, + ): Promise> { + if (filter.projectIds.length === 0) return { items: [], total: 0 }; + const query: Record = { + projectId: { $in: filter.projectIds }, + }; + if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; + if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; + if (filter.categories?.length) + query['statusCategory'] = { $in: filter.categories }; + const [docs, total] = await Promise.all([ + this.issues + .find(query) + .sort({ updatedAt: -1 }) + .skip(filter.offset) + .limit(filter.limit) + .lean(), + this.issues.countDocuments(query), + ]); + return { items: docs.map(issueFromDoc), total }; + } + async stats(projectId: string): Promise { const countBy = (field: string) => [ { $group: { _id: `$${field}`, count: { $sum: 1 } } }, @@ -131,7 +165,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { const [facets] = await this.issues.aggregate<{ total: { count: number }[]; open: { count: number }[]; - byStatus: { _id: IssueStatus; count: number }[]; + byStatus: { _id: string; count: number }[]; byType: { _id: string; count: number }[]; byPriority: { _id: string; count: number }[]; byArea: { _id: string | null; count: number }[]; @@ -142,7 +176,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { $facet: { total: [{ $count: 'count' }], open: [ - { $match: { status: { $nin: FINISHED_STATUSES } } }, + { $match: { statusCategory: { $nin: FINISHED } } }, { $count: 'count' }, ], byStatus: countBy('status'), @@ -150,7 +184,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { byPriority: countBy('priority'), byArea: countBy('area'), byAssignee: [ - { $match: { status: { $nin: FINISHED_STATUSES } } }, + { $match: { statusCategory: { $nin: FINISHED } } }, ...countBy('assignee'), ], }, @@ -173,23 +207,36 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { }; } + async findByProject(projectId: string): Promise { + const docs = await this.issues.find({ projectId }).lean(); + return docs.map(issueFromDoc); + } + async releaseProgress(releaseId: string): Promise { const rows = await this.issues.aggregate<{ - _id: IssueStatus; + _id: { status: string; category: StatusCategory }; count: number; }>([ { $match: { 'release.id': releaseId } }, - { $group: { _id: '$status', count: { $sum: 1 } } }, + { + $group: { + _id: { status: '$status', category: '$statusCategory' }, + count: { $sum: 1 }, + }, + }, ]); const total = rows.reduce((sum, r) => sum + r.count, 0); const finished = rows - .filter((r) => FINISHED_STATUSES.includes(r._id)) + .filter((r) => FINISHED.includes(r._id.category)) .reduce((sum, r) => sum + r.count, 0); + const byStatus = new Map(); + for (const r of rows) + byStatus.set(r._id.status, (byStatus.get(r._id.status) ?? 0) + r.count); return { total, finished, percent: total === 0 ? 0 : Math.round((finished / total) * 100), - byStatus: rows.map((r) => ({ key: r._id, count: r.count })), + byStatus: [...byStatus].map(([key, count]) => ({ key, count })), }; } } @@ -197,6 +244,8 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { function toMongoFilter(filter: IssueFilter): FilterQuery { const query: FilterQuery = { projectId: filter.projectId }; if (filter.statuses?.length) query.status = { $in: filter.statuses }; + if (filter.categories?.length) + query['statusCategory'] = { $in: filter.categories }; if (filter.types?.length) query['type'] = { $in: filter.types }; if (filter.areas?.length) query['area'] = { $in: filter.areas }; if (filter.priorities?.length) query['priority'] = { $in: filter.priorities }; @@ -273,6 +322,25 @@ export class MongoFindReleasesRepository extends FindReleasesRepository { .lean(); return docs.map(fromDoc); } + + async findUpcoming( + projectIds: string[], + limit: number, + ): Promise { + if (projectIds.length === 0) return []; + const docs = await this.releases + .find({ projectId: { $in: projectIds }, status: ReleaseStatus.PLANNED }) + .lean(); + // Ascending targetDate puts nulls first in Mongo: sort here instead. + return docs + .map(fromDoc) + .sort( + (a, b) => + (a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') || + a.name.localeCompare(b.name), + ) + .slice(0, limit); + } } @Injectable() @@ -301,3 +369,21 @@ export class MongoFindActivityRepository extends FindActivityRepository { return docs.map(fromDoc); } } + +@Injectable() +export class MongoFindTimelineRepository extends FindTimelineRepository { + constructor( + @InjectModel(ReadModels.Timeline) + private readonly timeline: Model, + ) { + super(); + } + + async findByProject(projectId: string): Promise { + const docs = await this.timeline + .find({ projectId }) + .sort({ at: 1 }) + .lean(); + return docs.map(fromDoc); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index cec4b13..2ecaf52 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -3,6 +3,7 @@ import { CommentReadModel, IssueLinkReadModel, IssueReadModel, + IssueTimelineEntry, ProjectReadModel, ReleaseReadModel, } from '@task-stream/api/tracker/domain'; @@ -21,6 +22,7 @@ export type IssueDoc = Doc> & { export type CommentDoc = Doc; export type ReleaseDoc = Doc; export type ActivityDoc = Doc; +export type TimelineDoc = Doc; export const ReadModels = { Project: 'ProjectReadModel', @@ -28,6 +30,7 @@ export const ReadModels = { Comment: 'CommentReadModel', Release: 'ReleaseReadModel', Activity: 'ActivityReadModel', + Timeline: 'IssueTimelineReadModel', } as const; const userRef = { _id: false, id: String, name: String }; @@ -59,6 +62,7 @@ export const IssueSchema = new Schema( projectId: { type: String, index: true }, key: { type: String, unique: true }, status: String, + statusCategory: String, reporter: userRef, assignee: userRef, release: { _id: false, id: String, name: String }, @@ -70,6 +74,7 @@ export const IssueSchema = new Schema( { ...loose, collection: 'issues_view' }, ); IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 }); +IssueSchema.index({ projectId: 1, statusCategory: 1 }); IssueSchema.index({ 'assignee.id': 1 }); IssueSchema.index({ 'release.id': 1 }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); @@ -102,12 +107,20 @@ export const ActivitySchema = new Schema( ActivitySchema.index({ projectId: 1, occurredAt: -1 }); ActivitySchema.index({ issueId: 1, occurredAt: -1 }); +/** Every status and release step of every ticket (statistics). */ +export const TimelineSchema = new Schema( + { _id: String, projectId: String, issueId: String, at: String }, + { ...loose, collection: 'issue_timeline' }, +); +TimelineSchema.index({ projectId: 1, at: 1 }); + export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Issue, schema: IssueSchema }, { name: ReadModels.Comment, schema: CommentSchema }, { name: ReadModels.Release, schema: ReleaseSchema }, { name: ReadModels.Activity, schema: ActivitySchema }, + { name: ReadModels.Timeline, schema: TimelineSchema }, ]; export function fromDoc( diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/status-category.backfill.ts b/libs/api/tracker/infrastructure/src/lib/mongo/status-category.backfill.ts new file mode 100644 index 0000000..23f10a1 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/status-category.backfill.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { ProjectWorkflow } from '@task-stream/api/tracker/domain'; +import { Model } from 'mongoose'; +import { IssueDoc, ProjectDoc, ReadModels } from './read-model.schemas'; + +/** + * Tickets projected before statuses had categories lack `statusCategory`; + * fill it in once at startup from each project's settings (or the defaults). + */ +@Injectable() +export class StatusCategoryBackfill implements OnApplicationBootstrap { + private readonly logger = new Logger(StatusCategoryBackfill.name); + + constructor( + @InjectModel(ReadModels.Issue) private readonly issues: Model, + @InjectModel(ReadModels.Project) + private readonly projects: Model, + ) {} + + async onApplicationBootstrap(): Promise { + const missing = { statusCategory: { $exists: false } }; + const groups = await this.issues.aggregate<{ + _id: { projectId: string; status: string }; + }>([ + { $match: missing }, + { $group: { _id: { projectId: '$projectId', status: '$status' } } }, + ]); + if (groups.length === 0) return; + + const projectIds = [...new Set(groups.map((g) => g._id.projectId))]; + const projects = await this.projects + .find({ _id: { $in: projectIds } }, { settings: 1 }) + .lean[]>(); + const workflows = new Map( + projects.map((p) => [ + p._id, + p.settings + ? new ProjectWorkflow(p.settings) + : ProjectWorkflow.default(), + ]), + ); + const result = await this.issues.bulkWrite( + groups.map(({ _id: { projectId, status } }) => ({ + updateMany: { + filter: { ...missing, projectId, status }, + update: { + $set: { + statusCategory: ( + workflows.get(projectId) ?? ProjectWorkflow.default() + ).categoryOf(status), + }, + }, + }, + })), + ); + this.logger.log( + `Status category filled in on ${result.modifiedCount} tickets`, + ); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 02482f2..a994ee5 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -6,14 +6,17 @@ import { UpsertMaterializedIssueRepository, UpsertMaterializedProjectRepository, UpsertMaterializedReleaseRepository, + UpsertMaterializedTimelineRepository, } from '@task-stream/api/tracker/application'; import { ActivityReadModel, CommentReadModel, IssueLinkReadModel, IssueReadModel, + IssueTimelineEntry, ProjectReadModel, ReleaseReadModel, + StatusCategory, } from '@task-stream/api/tracker/domain'; import { Model } from 'mongoose'; import { @@ -23,6 +26,7 @@ import { ProjectDoc, ReadModels, ReleaseDoc, + TimelineDoc, } from './read-model.schemas'; import { upsertIfNewer } from './upsert-if-newer'; @@ -101,6 +105,19 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs await this.issues.updateOne({ _id: issueId }, { $set: { commentCount } }); } + async recategorize( + projectId: string, + categories: Record, + ) { + const ops = Object.entries(categories).map(([status, category]) => ({ + updateMany: { + filter: { projectId, status, statusCategory: { $ne: category } }, + update: { $set: { statusCategory: category } }, + }, + })); + if (ops.length) await this.issues.bulkWrite(ops); + } + async renameRelease(releaseId: string, name: string) { await this.issues.updateMany( { 'release.id': releaseId }, @@ -167,3 +184,21 @@ export class MongoUpsertMaterializedActivityRepository extends UpsertMaterialize return result.upsertedCount > 0; } } + +@Injectable() +export class MongoUpsertMaterializedTimelineRepository extends UpsertMaterializedTimelineRepository { + constructor( + @InjectModel(ReadModels.Timeline) + private readonly timeline: Model, + ) { + super(); + } + + async record({ id, ...fields }: IssueTimelineEntry): Promise { + await this.timeline.updateOne( + { _id: id }, + { $setOnInsert: fields }, + { upsert: true }, + ); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index bfdba42..33e255b 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -9,6 +9,7 @@ import { FindIssuesRepository, FindProjectsRepository, FindReleasesRepository, + FindTimelineRepository, IssueNumbersRepository, ProjectKeysRepository, ReleaseIssuesRepository, @@ -18,6 +19,7 @@ import { UpsertMaterializedIssueRepository, UpsertMaterializedProjectRepository, UpsertMaterializedReleaseRepository, + UpsertMaterializedTimelineRepository, UserDirectory, } from '@task-stream/api/tracker/application'; import { IdentityUserDirectory } from './identity/identity-user-directory'; @@ -27,6 +29,7 @@ import { MongoFindIssuesRepository, MongoFindProjectsRepository, MongoFindReleasesRepository, + MongoFindTimelineRepository, } from './mongo/find.repositories'; import { READ_MODEL_SCHEMAS } from './mongo/read-model.schemas'; import { @@ -35,7 +38,9 @@ import { MongoUpsertMaterializedIssueRepository, MongoUpsertMaterializedProjectRepository, MongoUpsertMaterializedReleaseRepository, + MongoUpsertMaterializedTimelineRepository, } from './mongo/upsert-materialized.repositories'; +import { StatusCategoryBackfill } from './mongo/status-category.backfill'; import { LOOKUP_ENTITIES } from './postgres/lookup.entities'; import { PostgresIssueNumbersRepository, @@ -60,6 +65,7 @@ const PORTS = [ { provide: FindCommentsRepository, useClass: MongoFindCommentsRepository }, { provide: FindReleasesRepository, useClass: MongoFindReleasesRepository }, { provide: FindActivityRepository, useClass: MongoFindActivityRepository }, + { provide: FindTimelineRepository, useClass: MongoFindTimelineRepository }, { provide: UpsertMaterializedProjectRepository, useClass: MongoUpsertMaterializedProjectRepository, @@ -80,6 +86,10 @@ const PORTS = [ provide: UpsertMaterializedActivityRepository, useClass: MongoUpsertMaterializedActivityRepository, }, + { + provide: UpsertMaterializedTimelineRepository, + useClass: MongoUpsertMaterializedTimelineRepository, + }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, // Identity context (anti-corruption layer) @@ -93,7 +103,7 @@ const PORTS = [ TypeOrmModule.forFeature(LOOKUP_ENTITIES), MongooseModule.forFeature(READ_MODEL_SCHEMAS), ], - providers: PORTS, + providers: [...PORTS, StatusCategoryBackfill], exports: PORTS.map((port) => port.provide), }) export class TrackerInfrastructureModule {} diff --git a/libs/api/tracker/presenters/README.md b/libs/api/tracker/presenters/README.md index 271034b..978b9af 100644 --- a/libs/api/tracker/presenters/README.md +++ b/libs/api/tracker/presenters/README.md @@ -10,7 +10,9 @@ API GraphQL y REST (OpenAPI) del tracker. - Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services. - Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first). - `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController` - (mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave). + (mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave; añade a cada ticket sus + transiciones permitidas según el workflow de su proyecto). +- `common/settings.ts`: configuración del proyecto con las transiciones como lista (lo que viaja por GraphQL y REST). Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y [REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps). diff --git a/libs/api/tracker/presenters/project.json b/libs/api/tracker/presenters/project.json index a670064..36bd7d8 100644 --- a/libs/api/tracker/presenters/project.json +++ b/libs/api/tracker/presenters/project.json @@ -3,10 +3,7 @@ "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/api/tracker/presenters/src", "projectType": "library", - "tags": [ - "scope:api-tracker", - "type:presenters" - ], + "tags": ["scope:api-tracker", "type:presenters"], "// targets": "to see all targets run: nx show project api-tracker-presenters --web", "targets": {} } diff --git a/libs/api/tracker/presenters/src/lib/common/settings.ts b/libs/api/tracker/presenters/src/lib/common/settings.ts new file mode 100644 index 0000000..bb3d954 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/common/settings.ts @@ -0,0 +1,56 @@ +import { + ProjectSettings, + SettingColor, + StatusCategory, + TypeIcon, +} from '@task-stream/api/tracker/domain'; + +/** Settings as the APIs carry them: transitions as a list instead of a map. */ +export interface SettingsPayload { + statuses: { + id: string; + name: string; + category: StatusCategory; + color: string; + requiresReason: boolean; + archived: boolean; + }[]; + transitions: { from: string; to: string[] }[]; + issueTypes: { + id: string; + name: string; + icon: string; + color: string; + archived: boolean; + }[]; + areas: { id: string; name: string; archived: boolean }[]; +} + +export function toSettingsPayload(settings: ProjectSettings): SettingsPayload { + return { + ...settings, + transitions: settings.statuses.map((s) => ({ + from: s.id, + to: settings.transitions[s.id] ?? [], + })), + }; +} + +/** Colours and icons are checked by the domain (validateProjectSettings). */ +export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings { + return { + statuses: payload.statuses.map((s) => ({ + ...s, + color: s.color as SettingColor, + })), + transitions: Object.fromEntries( + payload.transitions.map((t) => [t.from, t.to]), + ), + issueTypes: payload.issueTypes.map((t) => ({ + ...t, + icon: t.icon as TypeIcon, + color: t.color as SettingColor, + })), + areas: payload.areas, + }; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index 37b0521..ff78256 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -1,12 +1,10 @@ import { registerEnumType } from '@nestjs/graphql'; import { - IssueArea, IssueLinkType, IssuePriority, - IssueStatus, - IssueType, ProjectRole, ReleaseStatus, + StatusCategory, } from '@task-stream/api/tracker/domain'; export enum LinkDirection { @@ -14,13 +12,12 @@ export enum LinkDirection { INWARD = 'INWARD', } -registerEnumType(IssueType, { name: 'IssueType' }); -registerEnumType(IssueArea, { - name: 'IssueArea', - description: 'Team/layer: links FE ↔ BE ↔ QA work', -}); registerEnumType(IssuePriority, { name: 'IssuePriority' }); -registerEnumType(IssueStatus, { name: 'IssueStatus' }); +registerEnumType(StatusCategory, { + name: 'StatusCategory', + description: + 'What a status means, whatever each project calls it. DONE and CANCELLED are finished.', +}); registerEnumType(IssueLinkType, { name: 'IssueLinkType' }); registerEnumType(ProjectRole, { name: 'ProjectRole' }); registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' }); diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index ee1843a..8c2371f 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -1,14 +1,14 @@ import { Field, ID, InputType, Int } from '@nestjs/graphql'; import { - IssueArea, IssueLinkType, IssuePriority, - IssueStatus, - IssueType, ProjectRole, + StatusCategory, } from '@task-stream/api/tracker/domain'; +import { Type } from 'class-transformer'; import { ArrayMaxSize, + IsArray, IsBoolean, IsEnum, IsInt, @@ -22,8 +22,12 @@ import { MaxLength, Min, MinLength, + ValidateNested, } from 'class-validator'; +/** Ids of statuses, types and areas (checked against the project's settings). */ +const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/; + @InputType() export class CreateProjectInput { @Field({ description: '2-10 chars, e.g. "TS" → tickets TS-1, TS-2...' }) @@ -74,10 +78,10 @@ abstract class IssueFieldsInput { @MaxLength(50000) description?: string | null; - @Field(() => IssueArea, { nullable: true }) + @Field(() => String, { nullable: true, description: 'An area id' }) @IsOptional() - @IsEnum(IssueArea) - area?: IssueArea | null; + @Matches(SETTING_ID) + area?: string | null; @Field(() => IssuePriority, { nullable: true }) @IsOptional() @@ -114,7 +118,9 @@ abstract class IssueFieldsInput { @InputType() export class CreateIssueInput extends IssueFieldsInput { @Field(() => ID) @IsUUID() projectId!: string; - @Field(() => IssueType) @IsEnum(IssueType) type!: IssueType; + @Field({ description: 'A ticket type id' }) + @Matches(SETTING_ID) + type!: string; @Field() @IsString() @MinLength(1) @MaxLength(255) title!: string; @Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?: | string @@ -127,10 +133,10 @@ export class CreateIssueInput extends IssueFieldsInput { @InputType() export class UpdateIssueInput extends IssueFieldsInput { @Field(() => ID) @IsUUID() issueId!: string; - @Field(() => IssueType, { nullable: true }) + @Field({ nullable: true }) @IsOptional() - @IsEnum(IssueType) - type?: IssueType; + @Matches(SETTING_ID) + type?: string; @Field({ nullable: true }) @IsOptional() @IsString() @@ -142,10 +148,12 @@ export class UpdateIssueInput extends IssueFieldsInput { @InputType() export class ChangeIssueStatusInput { @Field(() => ID) @IsUUID() issueId!: string; - @Field(() => IssueStatus) @IsEnum(IssueStatus) status!: IssueStatus; + @Field({ description: 'A status id' }) + @Matches(SETTING_ID) + status!: string; @Field(() => String, { nullable: true, - description: 'Required for QA_FAILED and CANCELLED', + description: 'Required by statuses with requiresReason', }) @IsOptional() @IsString() @@ -163,18 +171,22 @@ export class LinkIssuesInput { @InputType() export class IssueFilterInput { @Field(() => ID) @IsUUID() projectId!: string; - @Field(() => [IssueStatus], { nullable: true }) + @Field(() => [String], { nullable: true }) @IsOptional() - @IsEnum(IssueStatus, { each: true }) - statuses?: IssueStatus[]; - @Field(() => [IssueType], { nullable: true }) + @IsString({ each: true }) + statuses?: string[]; + @Field(() => [StatusCategory], { nullable: true }) @IsOptional() - @IsEnum(IssueType, { each: true }) - types?: IssueType[]; - @Field(() => [IssueArea], { nullable: true }) + @IsEnum(StatusCategory, { each: true }) + categories?: StatusCategory[]; + @Field(() => [String], { nullable: true }) @IsOptional() - @IsEnum(IssueArea, { each: true }) - areas?: IssueArea[]; + @IsString({ each: true }) + types?: string[]; + @Field(() => [String], { nullable: true }) + @IsOptional() + @IsString({ each: true }) + areas?: string[]; @Field(() => [IssuePriority], { nullable: true }) @IsOptional() @IsEnum(IssuePriority, { each: true }) @@ -237,3 +249,77 @@ export class UpdateReleaseInput { @IsISO8601() targetDate?: string | null; } + +/* Project settings (owners and admins). Values are archived, never deleted. */ + +@InputType() +export class WorkflowStatusInput { + @Field() @Matches(SETTING_ID) id!: string; + @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; + @Field(() => StatusCategory) + @IsEnum(StatusCategory) + category!: StatusCategory; + @Field() @IsString() color!: string; + @Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean; + @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; +} + +@InputType() +export class StatusTransitionsInput { + @Field() @Matches(SETTING_ID) from!: string; + @Field(() => [String]) + @IsArray() + @ArrayMaxSize(50) + @Matches(SETTING_ID, { each: true }) + to!: string[]; +} + +@InputType() +export class IssueTypeOptionInput { + @Field() @Matches(SETTING_ID) id!: string; + @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; + @Field() @IsString() icon!: string; + @Field() @IsString() color!: string; + @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; +} + +@InputType() +export class AreaOptionInput { + @Field() @Matches(SETTING_ID) id!: string; + @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; + @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; +} + +@InputType({ + description: + 'The whole configuration (replaces the previous one). Statuses go in board order.', +}) +export class ProjectSettingsInput { + @Field(() => [WorkflowStatusInput]) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => WorkflowStatusInput) + statuses!: WorkflowStatusInput[]; + + @Field(() => [StatusTransitionsInput]) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => StatusTransitionsInput) + transitions!: StatusTransitionsInput[]; + + @Field(() => [IssueTypeOptionInput]) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => IssueTypeOptionInput) + issueTypes!: IssueTypeOptionInput[]; + + @Field(() => [AreaOptionInput]) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => AreaOptionInput) + areas!: AreaOptionInput[]; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 80d10bb..44b40eb 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -13,11 +13,11 @@ import { CurrentUser } from '@task-stream/api/identity/presenters'; import { CommentsService, IssuesService, + WorkflowsService, } from '@task-stream/api/tracker/application'; import { IssueReadModel, - IssueStatus, - IssueWorkflow, + StatusCategory, } from '@task-stream/api/tracker/domain'; import { ChangeIssueStatusInput, @@ -40,6 +40,7 @@ export class IssueResolver { constructor( private readonly issuesService: IssuesService, private readonly commentsService: CommentsService, + private readonly workflows: WorkflowsService, ) {} @Query(() => IssueGql) @@ -61,6 +62,29 @@ export class IssueResolver { return this.issuesService.search(me.id, { ...filter, limit, offset }); } + @Query(() => IssuePageGql, { + description: + 'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])', + }) + myIssues( + @CurrentUser() me: User, + @Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean, + @Args('statuses', { type: () => [String], nullable: true }) + statuses: string[] | undefined, + @Args('categories', { type: () => [StatusCategory], nullable: true }) + categories: StatusCategory[] | undefined, + @Args('limit', { type: () => Int, defaultValue: 50 }) limit: number, + @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number, + ) { + return this.issuesService.mine(me.id, { + assignedToMe, + statuses: statuses ?? undefined, + categories: categories ?? undefined, + limit, + offset, + }); + } + @Query(() => [ActivityGql], { description: 'Project activity feed (newest first)', }) @@ -80,11 +104,11 @@ export class IssueResolver { }); } - @ResolveField(() => [IssueStatus], { - description: 'Statuses this ticket can move to next', + @ResolveField(() => [String], { + description: "Statuses this ticket can move to next (project's workflow)", }) - allowedTransitions(@Parent() issue: IssueReadModel): readonly IssueStatus[] { - return IssueWorkflow.allowedFrom(issue.status); + allowedTransitions(@Parent() issue: IssueReadModel): Promise { + return this.workflows.allowedTransitions(issue); } @ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index a700833..7be8887 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -11,16 +11,20 @@ import { import { User } from '@task-stream/api/identity/domain'; import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters'; import { ProjectsService } from '@task-stream/api/tracker/application'; -import { IssueArea, ProjectReadModel } from '@task-stream/api/tracker/domain'; +import { ProjectReadModel } from '@task-stream/api/tracker/domain'; +import { fromSettingsPayload, toSettingsPayload } from '../common/settings'; import { CreateProjectInput, ProjectMemberInput, + ProjectSettingsInput, UpdateProjectInput, } from './inputs'; import { BoardColumnGql, CommandResultGql, + ProjectAnalyticsGql, ProjectGql, + ProjectSettingsGql, ProjectStatsGql, ReleaseGql, } from './types'; @@ -43,6 +47,23 @@ export class ProjectResolver { return this.projects.findOne(me.id, idOrKey); } + @ResolveField(() => ProjectSettingsGql) + settings(@Parent() project: ProjectReadModel) { + return toSettingsPayload(project.settings); + } + + @ResolveField(() => ProjectAnalyticsGql, { + description: + 'Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA', + }) + analytics( + @CurrentUser() me: User, + @Parent() project: ProjectReadModel, + @Args('days', { type: () => Int, defaultValue: 90 }) days: number, + ) { + return this.projects.analytics(me.id, project.id, days); + } + @ResolveField(() => [ReleaseGql]) releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) { return this.projects.releases(me.id, project.id); @@ -63,7 +84,7 @@ export class ProjectResolver { @Parent() project: ProjectReadModel, @Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string, @Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string, - @Args('area', { type: () => IssueArea, nullable: true }) area?: IssueArea, + @Args('area', { type: () => String, nullable: true }) area?: string, @Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50, ) { return this.projects.board( @@ -95,6 +116,22 @@ export class ProjectResolver { return this.projects.update(me.id, projectId, changes); } + @Mutation(() => CommandResultGql, { + description: + 'Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.', + }) + updateProjectSettings( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + @Args('settings') settings: ProjectSettingsInput, + ) { + return this.projects.updateSettings( + me.id, + projectId, + fromSettingsPayload(settings), + ); + } + @Mutation(() => CommandResultGql, { description: 'Add a Pireon user to the project', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts index fc3117e..71fc8e6 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts @@ -19,6 +19,7 @@ import { CreateReleaseInput, UpdateReleaseInput } from './inputs'; import { CommandResultGql, IssuePageGql, + ReleaseBurnupGql, ReleaseGql, ReleaseProgressGql, } from './types'; @@ -35,11 +36,29 @@ export class ReleaseResolver { return this.releases.findOne(me.id, id); } + @Query(() => [ReleaseGql], { + description: + 'Planned releases of all my projects, soonest target date first', + }) + upcomingReleases( + @CurrentUser() me: User, + @Args('limit', { type: () => Int, defaultValue: 10 }) limit: number, + ) { + return this.releases.upcoming(me.id, limit); + } + @ResolveField(() => ReleaseProgressGql) progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) { return this.releases.progress(me.id, release.id); } + @ResolveField(() => ReleaseBurnupGql, { + description: 'Scope vs finished tickets, day by day', + }) + burnup(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) { + return this.releases.burnup(me.id, release.id); + } + @ResolveField(() => IssuePageGql, { description: 'Tickets included in this release', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index afa4d72..e69492a 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -1,12 +1,10 @@ -import { Field, ID, Int, ObjectType } from '@nestjs/graphql'; +import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql'; import { - IssueArea, IssueLinkType, IssuePriority, - IssueStatus, - IssueType, ProjectRole, ReleaseStatus, + StatusCategory, } from '@task-stream/api/tracker/domain'; import { LinkDirection } from './enums'; @@ -22,6 +20,51 @@ export class ProjectMemberGql { @Field(() => ProjectRole) role!: ProjectRole; } +@ObjectType('WorkflowStatus') +export class WorkflowStatusGql { + @Field({ description: 'Stable id stored on tickets, e.g. IN_REVIEW' }) + id!: string; + @Field() name!: string; + @Field(() => StatusCategory) category!: StatusCategory; + @Field({ description: 'Colour token: slate, violet, green…' }) color!: string; + @Field({ description: 'Entering it needs a reason' }) + requiresReason!: boolean; + @Field({ description: 'No new tickets or transitions into it' }) + archived!: boolean; +} + +@ObjectType('StatusTransitions') +export class StatusTransitionsGql { + @Field() from!: string; + @Field(() => [String]) to!: string[]; +} + +@ObjectType('IssueTypeOption') +export class IssueTypeOptionGql { + @Field() id!: string; + @Field() name!: string; + @Field() icon!: string; + @Field() color!: string; + @Field() archived!: boolean; +} + +@ObjectType('AreaOption') +export class AreaOptionGql { + @Field() id!: string; + @Field() name!: string; + @Field() archived!: boolean; +} + +@ObjectType('ProjectSettings', { + description: 'Statuses (board order), transitions, ticket types and areas', +}) +export class ProjectSettingsGql { + @Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[]; + @Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[]; + @Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[]; + @Field(() => [AreaOptionGql]) areas!: AreaOptionGql[]; +} + @ObjectType('Project') export class ProjectGql { @Field(() => ID) id!: string; @@ -59,11 +102,15 @@ export class IssueGql { @Field() projectKey!: string; @Field(() => Int) number!: number; @Field({ description: 'Human key, e.g. TS-42' }) key!: string; - @Field(() => IssueType) type!: IssueType; - @Field(() => IssueArea, { nullable: true }) area!: IssueArea | null; + @Field({ description: "A ticket type id of the project's settings" }) + type!: string; + @Field(() => String, { nullable: true, description: 'An area id' }) + area!: string | null; @Field() title!: string; @Field(() => String, { nullable: true }) description!: string | null; - @Field(() => IssueStatus) status!: IssueStatus; + @Field({ description: "A status id of the project's settings" }) + status!: string; + @Field(() => StatusCategory) statusCategory!: StatusCategory; @Field(() => IssuePriority) priority!: IssuePriority; @Field(() => UserRefGql) reporter!: UserRefGql; @Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null; @@ -88,7 +135,7 @@ export class IssuePageGql { @ObjectType('BoardColumn') export class BoardColumnGql { - @Field(() => IssueStatus) status!: IssueStatus; + @Field() status!: string; @Field(() => [IssueGql]) issues!: IssueGql[]; @Field(() => Int) total!: number; } @@ -125,7 +172,7 @@ export class ReleaseGql { @ObjectType('CountByStatus') export class CountByStatusGql { - @Field(() => IssueStatus) key!: IssueStatus; + @Field({ description: 'Status id' }) key!: string; @Field(() => Int) count!: number; } @@ -221,3 +268,77 @@ export class ProjectChangeGql { @Field(() => ID) actorId!: string; @Field(() => ProjectGql) project!: ProjectGql; } + +/* Statistics */ + +@ObjectType('CreatedResolved') +export class CreatedResolvedGql { + @Field({ description: 'Monday of the week (YYYY-MM-DD)' }) week!: string; + @Field(() => Int) created!: number; + @Field(() => Int) resolved!: number; +} + +@ObjectType('FlowSeries') +export class FlowSeriesGql { + @Field() status!: string; + @Field(() => [Int]) counts!: number[]; +} + +@ObjectType('CumulativeFlow') +export class CumulativeFlowGql { + @Field(() => [String]) dates!: string[]; + @Field(() => [FlowSeriesGql]) series!: FlowSeriesGql[]; +} + +@ObjectType('DurationStats') +export class DurationStatsGql { + @Field(() => Int) count!: number; + @Field(() => Float, { nullable: true }) averageDays!: number | null; + @Field(() => Float, { + nullable: true, + description: '85% of tickets take at most this long', + }) + p85Days!: number | null; +} + +@ObjectType('Throughput') +export class ThroughputGql { + @Field() week!: string; + @Field(() => UserRefGql, { nullable: true }) user!: UserRefGql | null; + @Field(() => Int) count!: number; +} + +@ObjectType('QaStats') +export class QaStatsGql { + @Field(() => Int) passed!: number; + @Field(() => Int) failed!: number; + @Field(() => Float, { nullable: true }) failureRate!: number | null; +} + +@ObjectType('ProjectAnalytics') +export class ProjectAnalyticsGql { + @Field() from!: string; + @Field() to!: string; + @Field(() => [CreatedResolvedGql]) createdVsResolved!: CreatedResolvedGql[]; + @Field(() => CumulativeFlowGql, { + description: 'Tickets in each status at the end of every day', + }) + cumulativeFlow!: CumulativeFlowGql; + @Field(() => DurationStatsGql, { description: 'Start of work → done' }) + cycleTime!: DurationStatsGql; + @Field(() => DurationStatsGql, { description: 'Creation → done' }) + leadTime!: DurationStatsGql; + @Field(() => [ThroughputGql], { description: 'Finished per person and week' }) + throughput!: ThroughputGql[]; + @Field(() => QaStatsGql) qa!: QaStatsGql; +} + +@ObjectType('ReleaseBurnup') +export class ReleaseBurnupGql { + @Field(() => ID) releaseId!: string; + @Field(() => String, { nullable: true }) targetDate!: string | null; + @Field(() => [String]) dates!: string[]; + @Field(() => [Int], { description: 'Tickets in the release (not cancelled)' }) + scope!: number[]; + @Field(() => [Int]) done!: number[]; +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index c2ba9c6..5697d11 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -5,16 +5,17 @@ import { PartialType, } from '@nestjs/swagger'; import { - IssueArea, IssueLinkType, IssuePriority, - IssueStatus, - IssueType, ProjectRole, + SETTING_COLORS, + StatusCategory, + TYPE_ICONS, } from '@task-stream/api/tracker/domain'; import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, + IsArray, IsBoolean, IsEnum, IsIn, @@ -29,6 +30,7 @@ import { Min, MinLength, ValidateIf, + ValidateNested, } from 'class-validator'; /* REST request bodies and query strings, validated like the GraphQL inputs. */ @@ -46,6 +48,9 @@ const toList = ({ value }: { value: unknown }) => const toBoolean = ({ value }: { value: unknown }) => value === undefined ? undefined : value === true || value === 'true'; +/** Ids of statuses, types and areas (checked against the project's settings). */ +const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/; + // ---- Projects --------------------------------------------------------------- export class CreateProjectDto { @@ -89,6 +94,121 @@ export class AddMemberDto { role: ProjectRole = ProjectRole.MEMBER; } +// ---- Project settings ------------------------------------------------------- + +export class WorkflowStatusBodyDto { + @ApiProperty({ + example: 'IN_REVIEW', + description: 'Never change an existing id', + }) + @Matches(SETTING_ID) + id!: string; + @ApiProperty({ example: 'En revisión', maxLength: 40 }) + @IsString() + @MinLength(1) + @MaxLength(40) + name!: string; + @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) + @IsEnum(StatusCategory) + category!: StatusCategory; + @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) + @IsIn(SETTING_COLORS) + color!: string; + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + requiresReason = false; + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + archived = false; +} + +export class StatusTransitionsBodyDto { + @ApiProperty({ example: 'IN_QA' }) @Matches(SETTING_ID) from!: string; + @ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] }) + @IsArray() + @ArrayMaxSize(50) + @Matches(SETTING_ID, { each: true }) + to!: string[]; +} + +export class IssueTypeOptionBodyDto { + @ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string; + @ApiProperty({ example: 'Spike', maxLength: 40 }) + @IsString() + @MinLength(1) + @MaxLength(40) + name!: string; + @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) + @IsIn(TYPE_ICONS) + icon!: string; + @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) + @IsIn(SETTING_COLORS) + color!: string; + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + archived = false; +} + +export class AreaOptionBodyDto { + @ApiProperty({ example: 'DATA' }) @Matches(SETTING_ID) id!: string; + @ApiProperty({ example: 'Datos', maxLength: 40 }) + @IsString() + @MinLength(1) + @MaxLength(40) + name!: string; + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + archived = false; +} + +export class ProjectSettingsBodyDto { + @ApiProperty({ + type: [WorkflowStatusBodyDto], + description: + 'In board order. Values cannot be removed: archive them instead.', + }) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => WorkflowStatusBodyDto) + statuses!: WorkflowStatusBodyDto[]; + + @ApiProperty({ type: [StatusTransitionsBodyDto] }) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => StatusTransitionsBodyDto) + transitions!: StatusTransitionsBodyDto[]; + + @ApiProperty({ type: [IssueTypeOptionBodyDto] }) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => IssueTypeOptionBodyDto) + issueTypes!: IssueTypeOptionBodyDto[]; + + @ApiProperty({ type: [AreaOptionBodyDto] }) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => AreaOptionBodyDto) + areas!: AreaOptionBodyDto[]; +} + +export class AnalyticsQueryDto { + @ApiPropertyOptional({ default: 90, minimum: 7, maximum: 365 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(7) + @Max(365) + days = 90; +} + export class ChangeMemberRoleDto { @ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' }) @IsEnum(ProjectRole) @@ -106,10 +226,10 @@ export class BoardQueryDto { @IsString() assigneeId?: string; - @ApiPropertyOptional({ enum: IssueArea, enumName: 'IssueArea' }) + @ApiPropertyOptional({ example: 'FRONTEND', description: 'An area id' }) @IsOptional() - @IsEnum(IssueArea) - area?: IssueArea; + @Matches(SETTING_ID) + area?: string; @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) @IsOptional() @@ -162,37 +282,79 @@ export class PageQueryDto { // ---- Issues ----------------------------------------------------------------- +export class MyIssuesQueryDto extends PageQueryDto { + @ApiPropertyOptional({ + default: true, + description: + 'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)', + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + assignedToMe = true; + + @ApiPropertyOptional({ + type: [String], + description: 'Status ids. Repeat the parameter or separate with commas', + }) + @IsOptional() + @Transform(toList) + @Matches(SETTING_ID, { each: true }) + status?: string[]; + + @ApiPropertyOptional({ + enum: StatusCategory, + enumName: 'StatusCategory', + isArray: true, + description: 'Whatever each project calls its statuses', + }) + @IsOptional() + @Transform(toList) + @IsEnum(StatusCategory, { each: true }) + category?: StatusCategory[]; +} + +export class UpcomingReleasesQueryDto { + @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit = 10; +} + export class IssueSearchQueryDto extends PageQueryDto { @ApiPropertyOptional({ - enum: IssueStatus, - enumName: 'IssueStatus', - isArray: true, - description: 'Repeat the parameter or separate with commas', + type: [String], + description: 'Status ids. Repeat the parameter or separate with commas', }) @IsOptional() @Transform(toList) - @IsEnum(IssueStatus, { each: true }) - status?: IssueStatus[]; + @Matches(SETTING_ID, { each: true }) + status?: string[]; @ApiPropertyOptional({ - enum: IssueType, - enumName: 'IssueType', + enum: StatusCategory, + enumName: 'StatusCategory', isArray: true, }) @IsOptional() @Transform(toList) - @IsEnum(IssueType, { each: true }) - type?: IssueType[]; + @IsEnum(StatusCategory, { each: true }) + category?: StatusCategory[]; - @ApiPropertyOptional({ - enum: IssueArea, - enumName: 'IssueArea', - isArray: true, - }) + @ApiPropertyOptional({ type: [String], description: 'Ticket type ids' }) @IsOptional() @Transform(toList) - @IsEnum(IssueArea, { each: true }) - area?: IssueArea[]; + @Matches(SETTING_ID, { each: true }) + type?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Area ids' }) + @IsOptional() + @Transform(toList) + @Matches(SETTING_ID, { each: true }) + area?: string[]; @ApiPropertyOptional({ enum: IssuePriority, @@ -241,13 +403,14 @@ class IssueFieldsDto { description?: string | null; @ApiPropertyOptional({ - enum: IssueArea, type: String, nullable: true, + example: 'FRONTEND', + description: "An area id of the project's settings", }) @IsOptional() - @IsEnum(IssueArea) - area?: IssueArea | null; + @Matches(SETTING_ID) + area?: string | null; @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' }) @IsOptional() @@ -289,9 +452,12 @@ class IssueFieldsDto { } export class CreateIssueDto extends IssueFieldsDto { - @ApiProperty({ enum: IssueType, enumName: 'IssueType' }) - @IsEnum(IssueType) - type!: IssueType; + @ApiProperty({ + example: 'BUG', + description: "A ticket type id of the project's settings", + }) + @Matches(SETTING_ID) + type!: string; @ApiProperty({ example: 'El botón de pagar no responde en Safari', @@ -318,10 +484,10 @@ export class CreateIssueDto extends IssueFieldsDto { } export class UpdateIssueDto extends IssueFieldsDto { - @ApiPropertyOptional({ enum: IssueType, enumName: 'IssueType' }) + @ApiPropertyOptional({ example: 'FEATURE', description: 'A ticket type id' }) @IsOptional() - @IsEnum(IssueType) - type?: IssueType; + @Matches(SETTING_ID) + type?: string; @ApiPropertyOptional({ maxLength: 255 }) @IsOptional() @@ -332,14 +498,18 @@ export class UpdateIssueDto extends IssueFieldsDto { } export class ChangeStatusDto { - @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) - @IsEnum(IssueStatus) - status!: IssueStatus; + @ApiProperty({ + example: 'IN_REVIEW', + description: + "A status id of the project's settings, among the ticket's allowedTransitions", + }) + @Matches(SETTING_ID) + status!: string; @ApiPropertyOptional({ type: String, nullable: true, - description: 'Required for QA_FAILED and CANCELLED', + description: 'Required by statuses with requiresReason (e.g. QA_FAILED)', }) @IsOptional() @IsString() diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 36d5a62..cf810b4 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -1,12 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { - IssueArea, IssueLinkType, IssuePriority, - IssueStatus, - IssueType, ProjectRole, ReleaseStatus, + SETTING_COLORS, + StatusCategory, + TYPE_ICONS, } from '@task-stream/api/tracker/domain'; /* @@ -25,6 +25,51 @@ export class ProjectMemberDto { role!: ProjectRole; } +export class WorkflowStatusDto { + @ApiProperty({ example: 'IN_REVIEW', description: 'Stable id on tickets' }) + id!: string; + @ApiProperty({ example: 'En revisión' }) name!: string; + @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) + category!: StatusCategory; + @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) + color!: string; + @ApiProperty({ description: 'Entering it needs a reason' }) + requiresReason!: boolean; + @ApiProperty({ description: 'No new tickets or transitions into it' }) + archived!: boolean; +} + +export class StatusTransitionsDto { + @ApiProperty({ example: 'IN_QA' }) from!: string; + @ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] }) + to!: string[]; +} + +export class IssueTypeOptionDto { + @ApiProperty({ example: 'BUG' }) id!: string; + @ApiProperty({ example: 'Bug' }) name!: string; + @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string; + @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) + color!: string; + @ApiProperty() archived!: boolean; +} + +export class AreaOptionDto { + @ApiProperty({ example: 'FRONTEND' }) id!: string; + @ApiProperty({ example: 'Frontend' }) name!: string; + @ApiProperty() archived!: boolean; +} + +export class ProjectSettingsDto { + @ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' }) + statuses!: WorkflowStatusDto[]; + @ApiProperty({ type: [StatusTransitionsDto] }) + transitions!: StatusTransitionsDto[]; + @ApiProperty({ type: [IssueTypeOptionDto] }) + issueTypes!: IssueTypeOptionDto[]; + @ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[]; +} + export class ProjectDto { @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ example: 'WEB', description: 'Prefix of the ticket keys' }) @@ -33,6 +78,8 @@ export class ProjectDto { @ApiProperty({ type: String, nullable: true }) description!: string | null; @ApiProperty({ type: UserRefDto }) owner!: UserRefDto; @ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[]; + @ApiProperty({ type: ProjectSettingsDto }) + settings!: ProjectSettingsDto; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) updatedAt!: string; @ApiProperty({ description: 'Version of the aggregate (event count)' }) @@ -63,20 +110,21 @@ export class IssueDto { @ApiProperty({ example: 'WEB' }) projectKey!: string; @ApiProperty({ example: 12 }) number!: number; @ApiProperty({ example: 'WEB-12', description: 'Human key' }) key!: string; - @ApiProperty({ enum: IssueType, enumName: 'IssueType' }) type!: IssueType; - @ApiProperty({ enum: IssueArea, type: String, nullable: true }) - area!: IssueArea | null; + @ApiProperty({ example: 'BUG', description: 'A ticket type id' }) + type!: string; + @ApiProperty({ type: String, nullable: true, example: 'FRONTEND' }) + area!: string | null; @ApiProperty() title!: string; @ApiProperty({ type: String, nullable: true }) description!: string | null; - @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) - status!: IssueStatus; + @ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' }) + status!: string; + @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) + statusCategory!: StatusCategory; @ApiProperty({ - enum: IssueStatus, - enumName: 'IssueStatus', - isArray: true, - description: 'Statuses this ticket can move to next', + type: [String], + description: "Statuses this ticket can move to next (project's workflow)", }) - allowedTransitions!: IssueStatus[]; + allowedTransitions!: string[]; @ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' }) priority!: IssuePriority; @ApiProperty({ type: UserRefDto }) reporter!: UserRefDto; @@ -104,8 +152,7 @@ export class IssuePageDto { } export class BoardColumnDto { - @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) - status!: IssueStatus; + @ApiProperty({ example: 'IN_PROGRESS' }) status!: string; @ApiProperty({ type: [IssueDto] }) issues!: IssueDto[]; @ApiProperty({ description: 'Tickets in the column (may exceed `issues`)' }) total!: number; @@ -143,8 +190,8 @@ export class ReleaseDto { } export class CountByStatusDto { - @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) - key!: IssueStatus; + @ApiProperty({ example: 'IN_PROGRESS', description: 'Status id' }) + key!: string; @ApiProperty() count!: number; } @@ -213,3 +260,78 @@ export class CommandResultDto { }) version!: number; } + +// ---- Statistics --------------------------------------------------------------- + +export class CreatedResolvedDto { + @ApiProperty({ format: 'date', description: 'Monday of the week' }) + week!: string; + @ApiProperty() created!: number; + @ApiProperty() resolved!: number; +} + +export class FlowSeriesDto { + @ApiProperty({ example: 'IN_PROGRESS' }) status!: string; + @ApiProperty({ type: [Number], description: 'One value per date' }) + counts!: number[]; +} + +export class CumulativeFlowDto { + @ApiProperty({ type: [String], format: 'date' }) dates!: string[]; + @ApiProperty({ type: [FlowSeriesDto] }) series!: FlowSeriesDto[]; +} + +export class DurationStatsDto { + @ApiProperty() count!: number; + @ApiProperty({ type: Number, nullable: true }) averageDays!: number | null; + @ApiProperty({ + type: Number, + nullable: true, + description: '85% of tickets take at most this long', + }) + p85Days!: number | null; +} + +export class ThroughputDto { + @ApiProperty({ format: 'date' }) week!: string; + @ApiProperty({ type: UserRefDto, nullable: true }) user!: UserRefDto | null; + @ApiProperty() count!: number; +} + +export class QaStatsDto { + @ApiProperty({ description: 'Left QA to a done status' }) passed!: number; + @ApiProperty({ description: 'Left QA back to work' }) failed!: number; + @ApiProperty({ type: Number, nullable: true, minimum: 0, maximum: 1 }) + failureRate!: number | null; +} + +export class ProjectAnalyticsDto { + @ApiProperty({ format: 'date-time' }) from!: string; + @ApiProperty({ format: 'date-time' }) to!: string; + @ApiProperty({ type: [CreatedResolvedDto] }) + createdVsResolved!: CreatedResolvedDto[]; + @ApiProperty({ + type: CumulativeFlowDto, + description: 'Tickets in each status at the end of every day', + }) + cumulativeFlow!: CumulativeFlowDto; + @ApiProperty({ type: DurationStatsDto, description: 'Start of work → done' }) + cycleTime!: DurationStatsDto; + @ApiProperty({ type: DurationStatsDto, description: 'Creation → done' }) + leadTime!: DurationStatsDto; + @ApiProperty({ type: [ThroughputDto] }) throughput!: ThroughputDto[]; + @ApiProperty({ type: QaStatsDto }) qa!: QaStatsDto; +} + +export class ReleaseBurnupDto { + @ApiProperty({ format: 'uuid' }) releaseId!: string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + targetDate!: string | null; + @ApiProperty({ type: [String], format: 'date' }) dates!: string[]; + @ApiProperty({ + type: [Number], + description: 'Tickets in the release (not cancelled)', + }) + scope!: number[]; + @ApiProperty({ type: [Number] }) done!: number[]; +} diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index ec71e94..747cb62 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -39,6 +39,7 @@ import { IssueSearchQueryDto, LinkIssueDto, LinkTypeParamDto, + MyIssuesQueryDto, PageQueryDto, SetIssueReleaseDto, UpdateIssueDto, @@ -50,7 +51,7 @@ import { IssueDto, IssuePageDto, } from './dto/responses.dto'; -import { Refs, toIssueDto } from './refs'; +import { Refs } from './refs'; const PROJECT = ApiParam({ name: 'project', @@ -74,6 +75,25 @@ export class IssuesController { private readonly refs: Refs, ) {} + @Get('me/issues') + @ApiOperation({ + summary: 'My work: tickets across all my projects', + description: + 'Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).', + }) + @ApiOkResponse({ type: IssuePageDto }) + @ApiReadErrors() + async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) { + const page = await this.issues.mine(me.id, { + assignedToMe: query.assignedToMe, + statuses: query.status, + categories: query.category, + limit: query.limit, + offset: query.offset, + }); + return { ...page, items: await this.refs.issueDtos(page.items) }; + } + @Get('projects/:project/issues') @PROJECT @ApiOperation({ summary: 'Search the tickets of a project' }) @@ -87,6 +107,7 @@ export class IssuesController { const page = await this.issues.search(me.id, { projectId: await this.refs.projectId(me.id, project), statuses: query.status, + categories: query.category, types: query.type, areas: query.area, priorities: query.priority, @@ -98,7 +119,7 @@ export class IssuesController { limit: query.limit, offset: query.offset, }); - return { ...page, items: page.items.map(toIssueDto) }; + return { ...page, items: await this.refs.issueDtos(page.items) }; } @Post('projects/:project/issues') @@ -106,7 +127,7 @@ export class IssuesController { @ApiOperation({ summary: 'Create a ticket', description: - 'Starts in BACKLOG. The response carries its key (e.g. WEB-13).', + "Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13).", }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() @@ -127,7 +148,7 @@ export class IssuesController { @ApiOkResponse({ type: IssueDto }) @ApiReadErrors() async findOne(@CurrentUser() me: User, @Param('issue') issue: string) { - return toIssueDto(await this.issues.findOne(me.id, issue)); + return this.refs.issueDto(await this.issues.findOne(me.id, issue)); } @Patch('issues/:issue') diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index 165cc7c..cc5010f 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -6,6 +6,7 @@ import { Param, Patch, Post, + Put, Query, } from '@nestjs/common'; import { @@ -32,19 +33,23 @@ import { import { ActivityQueryDto, AddMemberDto, + AnalyticsQueryDto, BoardQueryDto, ChangeMemberRoleDto, CreateProjectDto, + ProjectSettingsBodyDto, UpdateProjectDto, } from './dto/requests.dto'; import { ActivityDto, BoardColumnDto, CommandResultDto, + ProjectAnalyticsDto, ProjectDto, ProjectStatsDto, } from './dto/responses.dto'; -import { Refs, toIssueDto } from './refs'; +import { fromSettingsPayload } from '../common/settings'; +import { Refs, toProjectDto } from './refs'; const PROJECT = ApiParam({ name: 'project', @@ -67,8 +72,8 @@ export class ProjectsController { @ApiOperation({ summary: 'Projects where I am a member' }) @ApiOkResponse({ type: [ProjectDto] }) @ApiReadErrors() - mine(@CurrentUser() me: User) { - return this.projects.findMine(me.id); + async mine(@CurrentUser() me: User) { + return (await this.projects.findMine(me.id)).map(toProjectDto); } @Post() @@ -81,11 +86,11 @@ export class ProjectsController { @Get(':project') @PROJECT - @ApiOperation({ summary: 'A project with its members' }) + @ApiOperation({ summary: 'A project with its members and settings' }) @ApiOkResponse({ type: ProjectDto }) @ApiReadErrors() - findOne(@CurrentUser() me: User, @Param('project') project: string) { - return this.projects.findOne(me.id, project); + async findOne(@CurrentUser() me: User, @Param('project') project: string) { + return toProjectDto(await this.projects.findOne(me.id, project)); } @Patch(':project') @@ -105,6 +110,28 @@ export class ProjectsController { ); } + @Put(':project/settings') + @PROJECT + @ApiOperation({ + summary: 'Statuses, transitions, ticket types and areas (owner/admin)', + description: + 'Replaces the whole configuration (send what GET /projects/{project} returns, changed). ' + + 'Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async updateSettings( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: ProjectSettingsBodyDto, + ) { + return this.projects.updateSettings( + me.id, + await this.refs.projectId(me.id, project), + fromSettingsPayload(body), + ); + } + @Get(':project/board') @PROJECT @ApiOperation({ summary: 'Kanban board: one column per status' }) @@ -125,10 +152,12 @@ export class ProjectsController { }, query.perColumn, ); - return columns.map((column) => ({ - ...column, - issues: column.issues.map(toIssueDto), - })); + return Promise.all( + columns.map(async (column) => ({ + ...column, + issues: await this.refs.issueDtos(column.issues), + })), + ); } @Get(':project/stats') @@ -145,6 +174,27 @@ export class ProjectsController { ); } + @Get(':project/analytics') + @PROJECT + @ApiOperation({ + summary: 'Statistics of the last `days` days', + description: + 'Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail.', + }) + @ApiOkResponse({ type: ProjectAnalyticsDto }) + @ApiReadErrors() + async analytics( + @CurrentUser() me: User, + @Param('project') project: string, + @Query() query: AnalyticsQueryDto, + ) { + return this.projects.analytics( + me.id, + await this.refs.projectId(me.id, project), + query.days, + ); + } + @Get(':project/activity') @PROJECT @ApiOperation({ summary: 'Activity feed (newest first)' }) diff --git a/libs/api/tracker/presenters/src/lib/http/refs.ts b/libs/api/tracker/presenters/src/lib/http/refs.ts index 2a8664c..69d63a6 100644 --- a/libs/api/tracker/presenters/src/lib/http/refs.ts +++ b/libs/api/tracker/presenters/src/lib/http/refs.ts @@ -2,8 +2,13 @@ import { Injectable } from '@nestjs/common'; import { IssuesService, ProjectsService, + WorkflowsService, } from '@task-stream/api/tracker/application'; -import { IssueReadModel, IssueWorkflow } from '@task-stream/api/tracker/domain'; +import { + IssueReadModel, + ProjectReadModel, +} from '@task-stream/api/tracker/domain'; +import { toSettingsPayload } from '../common/settings'; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -17,6 +22,7 @@ export class Refs { constructor( private readonly projects: ProjectsService, private readonly issues: IssuesService, + private readonly workflows: WorkflowsService, ) {} async projectId(viewerId: string, ref: string): Promise { @@ -28,12 +34,21 @@ export class Refs { if (UUID.test(ref)) return ref; return (await this.issues.findOne(viewerId, ref)).id; } + + /** The ticket as the REST API returns it: read model + allowed transitions. */ + async issueDto(issue: IssueReadModel) { + return { + ...issue, + allowedTransitions: await this.workflows.allowedTransitions(issue), + }; + } + + issueDtos(issues: IssueReadModel[]) { + return Promise.all(issues.map((issue) => this.issueDto(issue))); + } } -/** The ticket as the REST API returns it: read model + allowed transitions. */ -export function toIssueDto(issue: IssueReadModel) { - return { - ...issue, - allowedTransitions: IssueWorkflow.allowedFrom(issue.status), - }; +/** The project as the REST API returns it: transitions as a list. */ +export function toProjectDto(project: ProjectReadModel) { + return { ...project, settings: toSettingsPayload(project.settings) }; } diff --git a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts index 1825fdd..e8aa333 100644 --- a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts @@ -33,15 +33,17 @@ import { CreateReleaseDto, PageQueryDto, PublishReleaseDto, + UpcomingReleasesQueryDto, UpdateReleaseDto, } from './dto/requests.dto'; import { CommandResultDto, IssuePageDto, ReleaseDto, + ReleaseBurnupDto, ReleaseProgressDto, } from './dto/responses.dto'; -import { Refs, toIssueDto } from './refs'; +import { Refs } from './refs'; const PROJECT = ApiParam({ name: 'project', @@ -61,6 +63,16 @@ export class ReleasesController { private readonly refs: Refs, ) {} + @Get('me/releases') + @ApiOperation({ + summary: 'Planned releases of all my projects, soonest target date first', + }) + @ApiOkResponse({ type: [ReleaseDto] }) + @ApiReadErrors() + upcoming(@CurrentUser() me: User, @Query() query: UpcomingReleasesQueryDto) { + return this.releases.upcoming(me.id, query.limit); + } + @Get('projects/:project/releases') @PROJECT @ApiOperation({ summary: 'Releases of a project' }) @@ -117,6 +129,16 @@ export class ReleasesController { return this.releases.progress(me.id, releaseId); } + @Get('releases/:releaseId/burnup') + @ApiOperation({ + summary: 'Burnup: tickets in the release and finished ones, day by day', + }) + @ApiOkResponse({ type: ReleaseBurnupDto }) + @ApiReadErrors() + burnup(@CurrentUser() me: User, @Param('releaseId') releaseId: string) { + return this.releases.burnup(me.id, releaseId); + } + @Get('releases/:releaseId/issues') @ApiOperation({ summary: 'Tickets included in the release' }) @ApiOkResponse({ type: IssuePageDto }) @@ -133,7 +155,7 @@ export class ReleasesController { limit: page.limit, offset: page.offset, }); - return { ...found, items: found.items.map(toIssueDto) }; + return { ...found, items: await this.refs.issueDtos(found.items) }; } @Post('releases/:releaseId/publish') diff --git a/libs/web/tracker/application/README.md b/libs/web/tracker/application/README.md index a99f429..0fb0838 100644 --- a/libs/web/tracker/application/README.md +++ b/libs/web/tracker/application/README.md @@ -7,9 +7,11 @@ Estado del tracker (NgRx Signals con eventos) detrás de facades. ## Contenido -- Facades (lo que se exporta): `ProjectsFacade` (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con +- Facades (lo que se exporta): `ProjectsFacade` y `HomeFacade` («Mi trabajo») (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con `provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`. -- Interno: stores `ProjectsStore`, `ProjectStore`, `BoardStore`, `IssueStore` y eventos por origen +- `ProjectFacade` también da la configuración del proyecto (`workflow`, `saveSettings`) y sus estadísticas + (`loadAnalytics`, `loadBurnup`). +- Interno: stores `ProjectsStore`, `ProjectStore`, `HomeStore`, `BoardStore`, `IssueStore` y eventos por origen (`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`). - `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión). - Puerto `Notifications`. diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index 5efe64f..ec298d5 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -3,3 +3,4 @@ export * from './lib/facades/projects.facade'; export * from './lib/facades/project.facade'; export * from './lib/facades/board.facade'; export * from './lib/facades/issue.facade'; +export * from './lib/facades/home.facade'; diff --git a/libs/web/tracker/application/src/lib/events/board.events.ts b/libs/web/tracker/application/src/lib/events/board.events.ts index 0af8442..22b29ad 100644 --- a/libs/web/tracker/application/src/lib/events/board.events.ts +++ b/libs/web/tracker/application/src/lib/events/board.events.ts @@ -6,7 +6,6 @@ import { CommandResult, CreateIssueInput, IssueChanged, - IssueStatus, } from '@task-stream/web/tracker/domain'; export const boardPageEvents = eventGroup({ @@ -17,7 +16,7 @@ export const boardPageEvents = eventGroup({ /** Optimistic: the card moves immediately and comes back if the API refuses. */ issueMoved: type<{ issueId: string; - to: IssueStatus; + to: string; reason?: string | null; }>(), issueCreateRequested: type(), diff --git a/libs/web/tracker/application/src/lib/events/home.events.ts b/libs/web/tracker/application/src/lib/events/home.events.ts new file mode 100644 index 0000000..9822caa --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/home.events.ts @@ -0,0 +1,23 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import { IssuePage, Release } from '@task-stream/web/tracker/domain'; + +export const homePageEvents = eventGroup({ + source: 'Home Page', + events: { + /** Loads (or reloads) "my work": also used to refresh when coming back. */ + opened: type(), + }, +}); + +export const homeApiEvents = eventGroup({ + source: 'Home API', + events: { + loaded: type<{ + mine: IssuePage; + awaitingQa: IssuePage; + releases: Release[]; + }>(), + loadFailed: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/events/project.events.ts b/libs/web/tracker/application/src/lib/events/project.events.ts index 64736c6..5d399a6 100644 --- a/libs/web/tracker/application/src/lib/events/project.events.ts +++ b/libs/web/tracker/application/src/lib/events/project.events.ts @@ -5,8 +5,11 @@ import { CreateReleaseInput, MemberInput, Project, + ProjectAnalytics, + ProjectSettings, ProjectStats, Release, + ReleaseBurnup, UserDirectoryEntry, } from '@task-stream/web/tracker/domain'; @@ -21,6 +24,12 @@ export const projectPageEvents = eventGroup({ releasePublishRequested: type<{ releaseId: string; force: boolean }>(), releaseArchiveRequested: type<{ releaseId: string }>(), userSearchRequested: type(), + settingsSaveRequested: type<{ + projectId: string; + settings: ProjectSettings; + }>(), + analyticsRequested: type<{ projectId: string; days: number }>(), + burnupRequested: type<{ releaseId: string }>(), }, }); @@ -35,6 +44,10 @@ export const projectApiEvents = eventGroup({ commandSucceeded: type(), commandFailed: type(), usersFound: type(), + analyticsLoaded: type(), + analyticsFailed: type(), + burnupLoaded: type(), + settingsSaved: type(), }, }); diff --git a/libs/web/tracker/application/src/lib/facades/board.facade.ts b/libs/web/tracker/application/src/lib/facades/board.facade.ts index e77d121..7cdd109 100644 --- a/libs/web/tracker/application/src/lib/facades/board.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/board.facade.ts @@ -1,10 +1,6 @@ import { inject, Injectable, Provider } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; -import { - BoardFilter, - CreateIssueInput, - IssueStatus, -} from '@task-stream/web/tracker/domain'; +import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain'; import { boardPageEvents } from '../events/board.events'; import { BoardStore } from '../stores/board.store'; @@ -30,7 +26,7 @@ export class BoardFacade { } /** Optimistic: the card moves at once and comes back if the API refuses. */ - moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void { + moveIssue(issueId: string, to: string, reason?: string | null): void { this.dispatch.issueMoved({ issueId, to, reason }); } diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index 2a875bf..6054b1a 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -6,7 +6,6 @@ import { ActivityGateway, CommentsGateway, IssuesGateway, - IssueStatus, Project, ProjectRole, ProjectsGateway, @@ -19,12 +18,14 @@ import { boardPageEvents } from '../events/board.events'; import { projectPageEvents } from '../events/project.events'; import { Notifications } from '../notifications'; import { BoardFacade, provideBoardFacade } from './board.facade'; +import { HomeFacade } from './home.facade'; import { IssueFacade, provideIssueFacade } from './issue.facade'; import { ProjectFacade, provideProjectFacade } from './project.facade'; const ok = { id: 'x', key: null, aggregateVersion: 1 }; const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project; const issue = { id: 'i1', key: 'WEB-1', links: [] }; +const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } }; /** Every command succeeds, every query answers once, nothing arrives in real time. */ function gateways() { @@ -33,6 +34,8 @@ function gateways() { project: vi.fn(() => of(project)), stats: vi.fn(() => NEVER), addMember: vi.fn(() => of(ok)), + updateSettings: vi.fn(() => of(ok)), + analytics: vi.fn(() => of(analytics)), }, issues: { board: vi.fn(() => of([])), @@ -127,6 +130,28 @@ describe('ProjectFacade', () => { }); }); + it('saves settings and loads statistics of the open project', () => { + const { project, fakes } = setup(); + project.open('WEB'); + const settings = { + statuses: [], + transitions: [], + issueTypes: [], + areas: [], + }; + + project.saveSettings(settings); + expect(fakes.projects.updateSettings).toHaveBeenCalledWith('p1', settings); + expect(project.savingSettings()).toBe(false); + + project.loadAnalytics(30); + expect(fakes.projects.analytics).toHaveBeenCalledWith('p1', 30); + expect(project.analyticsDays()).toBe(30); + expect(project.analytics()).toBe(analytics); + // Unknown ids still get a readable name. + expect(project.workflow().status('IN_REVIEW').name).toBe('In review'); + }); + it('searches the directory from two characters', () => { const { project } = setup(); const seen: string[] = []; @@ -145,15 +170,71 @@ describe('IssueFacade', () => { it('acts on the open ticket', () => { const { issue, fakes } = setup(); - issue.changeStatus(IssueStatus.IN_PROGRESS); + issue.changeStatus('IN_PROGRESS'); expect(fakes.issues.changeStatus).not.toHaveBeenCalled(); issue.open('WEB-1'); - issue.changeStatus(IssueStatus.QA_FAILED, 'Falla en Safari'); + issue.changeStatus('QA_FAILED', 'Falla en Safari'); expect(fakes.issues.changeStatus).toHaveBeenCalledWith({ issueId: 'i1', - status: IssueStatus.QA_FAILED, + status: 'QA_FAILED', reason: 'Falla en Safari', }); }); }); + +describe('HomeFacade', () => { + const issue = (key: string, status: string, statusCategory: string) => + ({ id: key, key, status, statusCategory, title: key }) as never; + + it('loads my work and groups it by category, QA failures first', () => { + const mine = vi.fn((filter: { assignedToMe: boolean }) => + of( + filter.assignedToMe + ? { + items: [ + issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'), + issue('WEB-4', 'BACKLOG', 'TODO'), + issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'), + ], + total: 3, + } + : { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 }, + ), + ); + TestBed.configureTestingModule({ + providers: [ + { provide: IssuesGateway, useValue: { mine } }, + { + provide: ReleasesGateway, + useValue: { upcoming: () => of([{ id: 'r1', name: 'v1.4.0' }]) }, + }, + ], + }); + const home = TestBed.inject(HomeFacade); + + home.load(); + + expect(mine).toHaveBeenCalledWith( + expect.objectContaining({ assignedToMe: true }), + ); + expect(mine).toHaveBeenCalledWith( + expect.objectContaining({ + assignedToMe: false, + categories: ['QA'], + }), + ); + expect(home.loaded()).toBe(true); + expect( + home + .mineByCategory() + .map((g) => [g.category, g.issues.map((i) => i.key)]), + ).toEqual([ + ['IN_PROGRESS', ['WEB-2', 'WEB-1']], + ['TODO', ['WEB-4']], + ]); + expect(home.qaFailed()).toBe(1); + expect(home.awaitingQaTotal()).toBe(1); + expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']); + }); +}); diff --git a/libs/web/tracker/application/src/lib/facades/home.facade.ts b/libs/web/tracker/application/src/lib/facades/home.facade.ts new file mode 100644 index 0000000..bfafa45 --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/home.facade.ts @@ -0,0 +1,32 @@ +import { inject, Injectable } from '@angular/core'; +import { injectDispatch } from '@ngrx/signals/events'; +import { homePageEvents } from '../events/home.events'; +import { HomeStore } from '../stores/home.store'; + +/** "My work" on the home page: my tickets, waiting for QA, upcoming releases. */ +@Injectable({ providedIn: 'root' }) +export class HomeFacade { + private readonly store = inject(HomeStore); + private readonly dispatch = injectDispatch(homePageEvents); + + /** My open tickets (most recently updated first) and how many there are. */ + readonly mine = this.store.mine; + readonly mineTotal = this.store.mineTotal; + /** The same, grouped by category (in progress, QA, pending), QA failures first. */ + readonly mineByCategory = this.store.mineByCategory; + readonly qaFailed = this.store.qaFailed; + /** Tickets of my projects in a QA-category status (anyone's). */ + readonly awaitingQa = this.store.awaitingQa; + readonly awaitingQaTotal = this.store.awaitingQaTotal; + /** Planned releases of my projects, soonest first. */ + readonly releases = this.store.releases; + /** False until the first load: show skeletons, not empty states. */ + readonly loaded = this.store.loaded; + readonly loading = this.store.loading; + readonly error = this.store.error; + + /** Loads my work; call again to refresh (e.g. when the tab regains focus). */ + load(): void { + this.dispatch.opened(); + } +} diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index 2112838..af6e32c 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -3,7 +3,6 @@ import { injectDispatch } from '@ngrx/signals/events'; import { IssueLink, IssueLinkType, - IssueStatus, UpdateIssueInput, } from '@task-stream/web/tracker/domain'; import { issuePageEvents } from '../events/issue.events'; @@ -34,7 +33,7 @@ export class IssueFacade { } /** QA_FAILED and CANCELLED need a reason. */ - changeStatus(status: IssueStatus, reason?: string | null): void { + changeStatus(status: string, reason?: string | null): void { this.withIssue((issueId) => this.dispatch.statusChangeRequested({ issueId, status, reason }), ); diff --git a/libs/web/tracker/application/src/lib/facades/project.facade.ts b/libs/web/tracker/application/src/lib/facades/project.facade.ts index 1c41913..8ef05fd 100644 --- a/libs/web/tracker/application/src/lib/facades/project.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts @@ -3,6 +3,7 @@ import { injectDispatch } from '@ngrx/signals/events'; import { CreateReleaseInput, ProjectRole, + ProjectSettings, } from '@task-stream/web/tracker/domain'; import { projectPageEvents } from '../events/project.events'; import { ProjectStore } from '../stores/project.store'; @@ -30,6 +31,16 @@ export class ProjectFacade { readonly canManage = this.store.canManage; /** Any role but viewer. */ readonly canContribute = this.store.canContribute; + /** Statuses, ticket types and areas of this project (names, colours, lookups). */ + readonly workflow = this.store.workflow; + readonly savingSettings = this.store.savingSettings; + /** Statistics of the last `analyticsDays()` days (see `loadAnalytics`). */ + readonly analytics = this.store.analytics; + readonly analyticsDays = this.store.analyticsDays; + readonly analyticsLoading = this.store.analyticsLoading; + readonly analyticsError = this.store.analyticsError; + /** Burnup of the release asked for with `loadBurnup`. */ + readonly burnup = this.store.burnup; /** Loads the project (by id or key) and follows its changes. */ open(idOrKey: string): void { @@ -74,6 +85,21 @@ export class ProjectFacade { this.dispatch.releaseArchiveRequested({ releaseId }); } + /** Replaces statuses, transitions, types and areas (owners and admins). */ + saveSettings(settings: ProjectSettings): void { + const projectId = this.projectId(); + if (projectId) this.dispatch.settingsSaveRequested({ projectId, settings }); + } + + loadAnalytics(days = this.store.analyticsDays()): void { + const projectId = this.projectId(); + if (projectId) this.dispatch.analyticsRequested({ projectId, days }); + } + + loadBurnup(releaseId: string): void { + this.dispatch.burnupRequested({ releaseId }); + } + private projectId(): string | null { return this.store.project()?.id ?? null; } diff --git a/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts b/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts index e441ae0..19f015e 100644 --- a/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts +++ b/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts @@ -1,13 +1,9 @@ -import { - BoardColumn, - IssueStatus, - IssueSummary, -} from '@task-stream/web/tracker/domain'; +import { BoardColumn, IssueSummary } from '@task-stream/web/tracker/domain'; import { applyIssueChange, moveIssue } from './board.reducers'; const issue = ( id: string, - status: IssueStatus, + status: string, version = 1, assignee: string | null = null, ): IssueSummary => @@ -27,55 +23,42 @@ const issue = ( const board = (): BoardColumn[] => [ { - status: IssueStatus.TODO, + status: 'TODO', total: 1, - issues: [issue('a', IssueStatus.TODO)], + issues: [issue('a', 'TODO')], }, - { status: IssueStatus.IN_PROGRESS, total: 0, issues: [] }, + { status: 'IN_PROGRESS', total: 0, issues: [] }, ]; describe('board reducers', () => { it('moves a card optimistically', () => { - const next = moveIssue(board(), 'a', IssueStatus.IN_PROGRESS); + const next = moveIssue(board(), 'a', 'IN_PROGRESS'); expect(next[0]).toMatchObject({ total: 0, issues: [] }); expect(next[1].issues[0]).toMatchObject({ id: 'a', - status: IssueStatus.IN_PROGRESS, + status: 'IN_PROGRESS', }); expect(next[1].total).toBe(1); }); it('applies newer realtime versions and ignores stale ones', () => { - const moved = applyIssueChange( - board(), - issue('a', IssueStatus.IN_PROGRESS, 2), - null, - ); + const moved = applyIssueChange(board(), issue('a', 'IN_PROGRESS', 2), null); expect(moved[1].issues.map((i) => i.id)).toEqual(['a']); - const stale = applyIssueChange( - moved, - issue('a', IssueStatus.TODO, 1), - null, - ); + const stale = applyIssueChange(moved, issue('a', 'TODO', 1), null); expect(stale).toBe(moved); }); it('drops issues that no longer match the filter', () => { - const next = applyIssueChange( - board(), - issue('a', IssueStatus.TODO, 2, 'bob'), - { projectId: 'p', assigneeId: 'ana' }, - ); + const next = applyIssueChange(board(), issue('a', 'TODO', 2, 'bob'), { + projectId: 'p', + assigneeId: 'ana', + }); expect(next[0].issues).toEqual([]); }); it('adds new issues from other users', () => { - const next = applyIssueChange( - board(), - issue('b', IssueStatus.TODO, 1), - null, - ); + const next = applyIssueChange(board(), issue('b', 'TODO', 1), null); expect(next[0].issues.map((i) => i.id)).toEqual(['b', 'a']); }); }); diff --git a/libs/web/tracker/application/src/lib/stores/board.reducers.ts b/libs/web/tracker/application/src/lib/stores/board.reducers.ts index 6eb5c9e..6b87fbf 100644 --- a/libs/web/tracker/application/src/lib/stores/board.reducers.ts +++ b/libs/web/tracker/application/src/lib/stores/board.reducers.ts @@ -1,7 +1,6 @@ import { BoardColumn, BoardFilter, - IssueStatus, IssueSummary, } from '@task-stream/web/tracker/domain'; @@ -56,7 +55,7 @@ export function placeIssue( export function moveIssue( columns: BoardColumn[], issueId: string, - to: IssueStatus, + to: string, ): BoardColumn[] { const issue = findIssue(columns, issueId); return issue diff --git a/libs/web/tracker/application/src/lib/stores/home.store.ts b/libs/web/tracker/application/src/lib/stores/home.store.ts new file mode 100644 index 0000000..8a93a87 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/home.store.ts @@ -0,0 +1,125 @@ +import { computed, inject } from '@angular/core'; +import { mapResponse } from '@ngrx/operators'; +import { signalStore, withComputed, withState } from '@ngrx/signals'; +import { + Events, + on, + withEventHandlers, + withReducer, +} from '@ngrx/signals/events'; +import { + IssuesGateway, + IssueSummary, + OPEN_CATEGORIES, + QA_FAILED, + Release, + ReleasesGateway, + StatusCategory, +} from '@task-stream/web/tracker/domain'; +import { forkJoin, switchMap } from 'rxjs'; +import { homeApiEvents, homePageEvents } from '../events/home.events'; +import { describeError } from '../notifications'; + +/** Groups of "my work": what is moving first, then QA, then what is pending. */ +const GROUP_ORDER = [ + StatusCategory.IN_PROGRESS, + StatusCategory.QA, + StatusCategory.TODO, +]; + +interface HomeState { + mine: IssueSummary[]; + mineTotal: number; + awaitingQa: IssueSummary[]; + awaitingQaTotal: number; + releases: Release[]; + loaded: boolean; + loading: boolean; + error: string | null; +} + +/** + * "My work" across every project I belong to: my open tickets, tickets + * waiting for QA and upcoming releases. Root-scoped, so coming back to the + * home page shows the last data at once while it refreshes. + */ +export const HomeStore = signalStore( + { providedIn: 'root' }, + withState({ + mine: [], + mineTotal: 0, + awaitingQa: [], + awaitingQaTotal: 0, + releases: [], + loaded: false, + loading: false, + error: null, + }), + withComputed(({ mine }) => ({ + /** + * My open tickets grouped by category (statuses are per project), QA + * failures first within their group. + */ + mineByCategory: computed(() => + GROUP_ORDER.map((category) => ({ + category, + issues: mine() + .filter((issue) => issue.statusCategory === category) + .sort( + (a, b) => + Number(b.status === QA_FAILED) - Number(a.status === QA_FAILED), + ), + })).filter((group) => group.issues.length > 0), + ), + qaFailed: computed( + () => mine().filter((issue) => issue.status === QA_FAILED).length, + ), + })), + withReducer( + on(homePageEvents.opened, () => ({ loading: true, error: null })), + on(homeApiEvents.loaded, ({ payload }) => ({ + mine: payload.mine.items, + mineTotal: payload.mine.total, + awaitingQa: payload.awaitingQa.items, + awaitingQaTotal: payload.awaitingQa.total, + releases: payload.releases, + loaded: true, + loading: false, + })), + on(homeApiEvents.loadFailed, ({ payload }) => ({ + loading: false, + error: payload, + })), + ), + withEventHandlers( + ( + _store, + events = inject(Events), + issues = inject(IssuesGateway), + releases = inject(ReleasesGateway), + ) => ({ + load$: events.on(homePageEvents.opened).pipe( + switchMap(() => + forkJoin({ + mine: issues.mine({ + assignedToMe: true, + categories: [...OPEN_CATEGORIES], + limit: 50, + }), + awaitingQa: issues.mine({ + assignedToMe: false, + categories: [StatusCategory.QA], + limit: 20, + }), + releases: releases.upcoming(5), + }).pipe( + mapResponse({ + next: (data) => homeApiEvents.loaded(data), + error: (error) => homeApiEvents.loadFailed(describeError(error)), + }), + ), + ), + ), + }), + ), +); diff --git a/libs/web/tracker/application/src/lib/stores/project.store.ts b/libs/web/tracker/application/src/lib/stores/project.store.ts index f20ba63..11f5944 100644 --- a/libs/web/tracker/application/src/lib/stores/project.store.ts +++ b/libs/web/tracker/application/src/lib/stores/project.store.ts @@ -12,14 +12,17 @@ import { Activity, ActivityGateway, Project, + ProjectAnalytics, ProjectRole, ProjectsGateway, ProjectStats, Release, + ReleaseBurnup, ReleasesGateway, TrackerRealtimeGateway, UserDirectoryEntry, UsersGateway, + Workflow, } from '@task-stream/web/tracker/domain'; import { debounceTime, @@ -46,6 +49,13 @@ interface ProjectState { releases: Release[]; activity: Activity[]; userResults: UserDirectoryEntry[]; + analytics: ProjectAnalytics | null; + analyticsDays: number; + analyticsLoading: boolean; + analyticsError: string | null; + burnup: ReleaseBurnup | null; + /** True while settings are being saved (until the project comes back changed). */ + savingSettings: boolean; loading: boolean; error: string | null; } @@ -58,6 +68,12 @@ export const ProjectStore = signalStore( releases: [], activity: [], userResults: [], + analytics: null, + analyticsDays: 90, + analyticsLoading: false, + analyticsError: null, + burnup: null, + savingSettings: false, loading: false, error: null, }), @@ -76,6 +92,8 @@ export const ProjectStore = signalStore( () => !!myRole() && myRole() !== ProjectRole.VIEWER, ), members: computed(() => project()?.members ?? []), + /** Statuses, types and areas of the project, with lookups. */ + workflow: computed(() => new Workflow(project()?.settings)), }; }), withReducer( @@ -86,12 +104,42 @@ export const ProjectStore = signalStore( stats: null, releases: [], activity: [], + analytics: null, + analyticsError: null, + burnup: null, + })), + on(projectApiEvents.loaded, ({ payload }) => ({ + project: payload, + loading: false, + })), + on(projectRealtimeEvents.projectChanged, ({ payload }) => ({ + project: payload, + loading: false, + savingSettings: false, + })), + on(projectPageEvents.settingsSaveRequested, () => ({ + savingSettings: true, })), on( - projectApiEvents.loaded, - projectRealtimeEvents.projectChanged, - ({ payload }) => ({ project: payload, loading: false }), + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + () => ({ savingSettings: false }), ), + on(projectPageEvents.analyticsRequested, ({ payload }) => ({ + analyticsDays: payload.days, + analyticsLoading: true, + analyticsError: null, + })), + on(projectApiEvents.analyticsLoaded, ({ payload }) => ({ + analytics: payload, + analyticsLoading: false, + })), + on(projectApiEvents.analyticsFailed, ({ payload }) => ({ + analyticsLoading: false, + analyticsError: payload, + })), + on(projectPageEvents.burnupRequested, () => ({ burnup: null })), + on(projectApiEvents.burnupLoaded, ({ payload }) => ({ burnup: payload })), on(projectApiEvents.loadFailed, ({ payload }) => ({ loading: false, error: payload, @@ -204,6 +252,28 @@ export const ProjectStore = signalStore( return id ? reloadReleases(id) : []; }), ), + analytics$: events.on(projectPageEvents.analyticsRequested).pipe( + switchMap(({ payload }) => + projects.analytics(payload.projectId, payload.days).pipe( + mapResponse({ + next: (stats) => projectApiEvents.analyticsLoaded(stats), + error: (error) => + projectApiEvents.analyticsFailed(describeError(error)), + }), + ), + ), + ), + burnup$: events.on(projectPageEvents.burnupRequested).pipe( + switchMap(({ payload }) => + releases.burnup(payload.releaseId).pipe( + mapResponse({ + next: (burnup) => projectApiEvents.burnupLoaded(burnup), + error: (error) => + projectApiEvents.commandFailed(describeError(error)), + }), + ), + ), + ), /** Pireon directory search (to add members). */ searchUsers$: events.on(projectPageEvents.userSearchRequested).pipe( debounceTime(250), @@ -253,6 +323,18 @@ export const ProjectStore = signalStore( ), ), ), + events + .on(projectPageEvents.settingsSaveRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + projects.updateSettings(payload.projectId, payload.settings), + 'Configuración guardada', + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), events .on(projectPageEvents.releaseCreateRequested) .pipe( diff --git a/libs/web/tracker/domain/README.md b/libs/web/tracker/domain/README.md index fc83737..2fc292f 100644 --- a/libs/web/tracker/domain/README.md +++ b/libs/web/tracker/domain/README.md @@ -8,7 +8,9 @@ Modelo del tracker en el cliente. ## Contenido - Modelos (`Project`, `IssueSummary`, `Issue`, `Release`…), enums e inputs. -- Workflow (`REASON_REQUIRED`, `BOARD_COLUMNS`). +- Configuración por proyecto (`ProjectSettings`, `StatusCategory`) y `Workflow`: nombres, colores, iconos, categorías, + transiciones permitidas y estados que piden motivo, con un nombre legible para ids que ya no están configurados. +- Estadísticas (`ProjectAnalytics`, `ReleaseBurnup`). - Puertos: `ProjectsGateway`, `IssuesGateway`, `CommentsGateway`, `ReleasesGateway`, `ActivityGateway`, `UsersGateway`, `TrackerRealtimeGateway`. ## Comandos diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index 1ae3110..807c3e2 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -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', diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index f953f70..9cc9c7c 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -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; } diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index cb0c8b3..91c67fb 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -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 { export interface ProjectStats { total: number; open: number; - byStatus: CountBy[]; + 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; diff --git a/libs/web/tracker/domain/src/lib/models/workflow.ts b/libs/web/tracker/domain/src/lib/models/workflow.ts index 9ba6d53..b4fb7d4 100644 --- a/libs/web/tracker/domain/src/lib/models/workflow.ts +++ b/libs/web/tracker/domain/src/lib/models/workflow.ts @@ -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.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.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); } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 43fd793..1adc1ab 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -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; abstract project(idOrKey: string): Observable; abstract stats(projectId: string): Observable; + /** Statistics of the last `days` days. */ + abstract analytics( + projectId: string, + days: number, + ): Observable; + /** Replaces statuses, transitions, types and areas (owners and admins). */ + abstract updateSettings( + projectId: string, + settings: ProjectSettings, + ): Observable; abstract create(input: CreateProjectInput): Observable; abstract addMember(input: MemberInput): Observable; abstract changeMemberRole(input: MemberInput): Observable; @@ -44,6 +59,8 @@ export abstract class IssuesGateway { abstract board(filter: BoardFilter): Observable; abstract issue(idOrKey: string): Observable; abstract search(projectId: string, text: string): Observable; + /** Tickets across all my projects (most recently updated first). */ + abstract mine(filter: MyIssuesFilter): Observable; abstract create(input: CreateIssueInput): Observable; abstract update(input: UpdateIssueInput): Observable; abstract changeStatus(input: ChangeStatusInput): Observable; @@ -68,12 +85,15 @@ export abstract class CommentsGateway { export abstract class ReleasesGateway { abstract list(projectId: string): Observable; + /** Planned releases of all my projects, soonest target date first. */ + abstract upcoming(limit: number): Observable; abstract create(input: CreateReleaseInput): Observable; abstract publish( releaseId: string, force: boolean, ): Observable; abstract archive(releaseId: string): Observable; + abstract burnup(releaseId: string): Observable; } export abstract class ActivityGateway { diff --git a/libs/web/tracker/infrastructure/README.md b/libs/web/tracker/infrastructure/README.md index 73e8496..51f6fe5 100644 --- a/libs/web/tracker/infrastructure/README.md +++ b/libs/web/tracker/infrastructure/README.md @@ -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 diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts index e8ff698..96d3923 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts @@ -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 { + return this.gql + .query(UpcomingReleasesDocument, { limit }) + .pipe(map((d) => d.upcomingReleases.map(toRelease))); + } + create(input: CreateReleaseInput): Observable { 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 { + return this.gql + .query(ReleaseBurnupDocument, { id: releaseId }) + .pipe(map((d) => d.release.burnup)); + } } @Injectable() diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index 54eb6d6..6e99126 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -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 { + 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 { return this.gql .query(SearchIssuesDocument, { filter: { projectId, search: text } }) diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts index 35c3f7f..9abf01d 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts @@ -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 { + return this.gql + .query(ProjectAnalyticsDocument, { idOrKey: projectId, days }) + .pipe(map((d) => d.project.analytics)); + } + + updateSettings( + projectId: string, + settings: ProjectSettings, + ): Observable { + return this.gql + .mutate(UpdateProjectSettingsDocument, { + projectId, + settings: toSettingsInput(settings), + }) + .pipe(map((d) => toCommandResult(d.updateProjectSettings))); + } + create(input: CreateProjectInput): Observable { return this.gql .mutate(CreateProjectDocument, { input }) diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts index db9b452..c02e1bd 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts @@ -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 = (value: unknown): T => value as T; -export const toProject = (p: ProjectFieldsFragment): Project => as(p); +export const toProject = (p: ProjectFieldsFragment): Project => ({ + ...as(p), + // Plain values (no __typename): pages compare and send them back as input. + settings: as( + toSettingsInput(as(p.settings)), + ), +}); export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary => as(i); export const toIssue = (i: IssueFieldsFragment): Issue => as(i); @@ -53,3 +61,35 @@ export function toStats( ): ProjectStats { return as(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, + })), + }; +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index bfe7d1f..5f46787 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -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 diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index b104a2e..a1921b9 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -7,15 +7,23 @@ export type Incremental = [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; }; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type AreaOptionInput = { + archived?: boolean; + id: string; + name: string; +}; + export type ChangeIssueStatusInput = { issueId: string; - /** Required for QA_FAILED and CANCELLED */ + /** Required by statuses with requiresReason */ reason?: string | null | undefined; - status: IssueStatus; + /** A status id */ + status: string; }; export type CreateIssueInput = { - area?: IssueArea | null | undefined; + /** An area id */ + area?: string | null | undefined; assigneeId?: string | null | undefined; description?: string | null | undefined; dueDate?: string | null | undefined; @@ -27,7 +35,8 @@ export type CreateIssueInput = { releaseId?: string | null | undefined; storyPoints?: number | null | undefined; title: string; - type: IssueType; + /** A ticket type id */ + type: string; }; export type CreateProjectInput = { @@ -45,26 +54,18 @@ export type CreateReleaseInput = { targetDate?: string | null | undefined; }; -/** Team/layer: links FE ↔ BE ↔ QA work */ -export type IssueArea = - | 'BACKEND' - | 'DESIGN' - | 'DEVOPS' - | 'FRONTEND' - | 'MOBILE' - | 'QA'; - export type IssueFilterInput = { - areas?: Array | null | undefined; + areas?: Array | null | undefined; assigneeId?: string | null | undefined; + categories?: Array | null | undefined; labels?: Array | null | undefined; priorities?: Array | null | undefined; projectId: string; releaseId?: string | null | undefined; /** Matches key, title or description */ search?: string | null | undefined; - statuses?: Array | null | undefined; - types?: Array | null | undefined; + statuses?: Array | null | undefined; + types?: Array | null | undefined; unassigned?: boolean | null | undefined; }; @@ -77,18 +78,13 @@ export type IssueLinkType = export type IssuePriority = 'CRITICAL' | 'HIGH' | 'LOW' | 'LOWEST' | 'MEDIUM'; -export type IssueStatus = - | 'BACKLOG' - | 'CANCELLED' - | 'DONE' - | 'IN_PROGRESS' - | 'IN_QA' - | 'IN_REVIEW' - | 'QA_FAILED' - | 'READY_FOR_QA' - | 'TODO'; - -export type IssueType = 'BUG' | 'FEATURE' | 'QA' | 'TASK' | 'UI_CHANGE'; +export type IssueTypeOptionInput = { + archived?: boolean; + color: string; + icon: string; + id: string; + name: string; +}; export type LinkDirection = 'INWARD' | 'OUTWARD'; @@ -107,10 +103,32 @@ export type ProjectMemberInput = { export type ProjectRole = 'ADMIN' | 'MEMBER' | 'OWNER' | 'VIEWER'; +/** The whole configuration (replaces the previous one). Statuses go in board order. */ +export type ProjectSettingsInput = { + areas: Array; + issueTypes: Array; + statuses: Array; + transitions: Array; +}; + export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED'; +/** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */ +export type StatusCategory = + | 'CANCELLED' + | 'DONE' + | 'IN_PROGRESS' + | 'QA' + | 'TODO'; + +export type StatusTransitionsInput = { + from: string; + to: Array; +}; + export type UpdateIssueInput = { - area?: IssueArea | null | undefined; + /** An area id */ + area?: string | null | undefined; description?: string | null | undefined; dueDate?: string | null | undefined; /** Figma frame for UI changes */ @@ -120,7 +138,16 @@ export type UpdateIssueInput = { priority?: IssuePriority | null | undefined; storyPoints?: number | null | undefined; title?: string | null | undefined; - type?: IssueType | null | undefined; + type?: string | null | undefined; +}; + +export type WorkflowStatusInput = { + archived?: boolean; + category: StatusCategory; + color: string; + id: string; + name: string; + requiresReason?: boolean; }; export type UserRefFieldsFragment = { id: string; name: string }; @@ -134,16 +161,36 @@ export type ProjectFieldsFragment = { version: number; owner: { id: string; name: string }; members: Array<{ role: ProjectRole; user: { id: string; name: string } }>; + settings: { + statuses: Array<{ + id: string; + name: string; + category: StatusCategory; + color: string; + requiresReason: boolean; + archived: boolean; + }>; + transitions: Array<{ from: string; to: Array }>; + issueTypes: Array<{ + id: string; + name: string; + icon: string; + color: string; + archived: boolean; + }>; + areas: Array<{ id: string; name: string; archived: boolean }>; + }; }; export type IssueSummaryFieldsFragment = { id: string; projectId: string; key: string; - type: IssueType; - area: IssueArea | null; + type: string; + area: string | null; title: string; - status: IssueStatus; + status: string; + statusCategory: StatusCategory; priority: IssuePriority; labels: Array; commentCount: number; @@ -159,14 +206,15 @@ export type IssueFieldsFragment = { storyPoints: number | null; dueDate: string | null; createdAt: string; - allowedTransitions: Array; + allowedTransitions: Array; id: string; projectId: string; key: string; - type: IssueType; - area: IssueArea | null; + type: string; + area: string | null; title: string; - status: IssueStatus; + status: string; + statusCategory: StatusCategory; priority: IssuePriority; labels: Array; commentCount: number; @@ -222,23 +270,24 @@ export type BoardQueryVariables = Exact<{ idOrKey: string; releaseId?: string | null | undefined; assigneeId?: string | null | undefined; - area?: IssueArea | null | undefined; + area?: string | null | undefined; }>; export type BoardQuery = { project: { id: string; board: Array<{ - status: IssueStatus; + status: string; total: number; issues: Array<{ id: string; projectId: string; key: string; - type: IssueType; - area: IssueArea | null; + type: string; + area: string | null; title: string; - status: IssueStatus; + status: string; + statusCategory: StatusCategory; priority: IssuePriority; labels: Array; commentCount: number; @@ -262,14 +311,15 @@ export type IssueQuery = { storyPoints: number | null; dueDate: string | null; createdAt: string; - allowedTransitions: Array; + allowedTransitions: Array; id: string; projectId: string; key: string; - type: IssueType; - area: IssueArea | null; + type: string; + area: string | null; title: string; - status: IssueStatus; + status: string; + statusCategory: StatusCategory; priority: IssuePriority; labels: Array; commentCount: number; @@ -297,10 +347,11 @@ export type SearchIssuesQuery = { id: string; projectId: string; key: string; - type: IssueType; - area: IssueArea | null; + type: string; + area: string | null; title: string; - status: IssueStatus; + status: string; + statusCategory: StatusCategory; priority: IssuePriority; labels: Array; commentCount: number; @@ -416,6 +467,35 @@ export type DeleteCommentMutation = { deleteComment: { id: string; key: string | null; version: number }; }; +export type MyIssuesQueryVariables = Exact<{ + assignedToMe: boolean; + categories?: Array | StatusCategory | null | undefined; + limit: number; +}>; + +export type MyIssuesQuery = { + myIssues: { + total: number; + items: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + }>; + }; +}; + export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>; export type MyProjectsQuery = { @@ -428,6 +508,25 @@ export type MyProjectsQuery = { version: number; owner: { id: string; name: string }; members: Array<{ role: ProjectRole; user: { id: string; name: string } }>; + settings: { + statuses: Array<{ + id: string; + name: string; + category: StatusCategory; + color: string; + requiresReason: boolean; + archived: boolean; + }>; + transitions: Array<{ from: string; to: Array }>; + issueTypes: Array<{ + id: string; + name: string; + icon: string; + color: string; + archived: boolean; + }>; + areas: Array<{ id: string; name: string; archived: boolean }>; + }; }>; }; @@ -445,6 +544,25 @@ export type ProjectQuery = { version: number; owner: { id: string; name: string }; members: Array<{ role: ProjectRole; user: { id: string; name: string } }>; + settings: { + statuses: Array<{ + id: string; + name: string; + category: StatusCategory; + color: string; + requiresReason: boolean; + archived: boolean; + }>; + transitions: Array<{ from: string; to: Array }>; + issueTypes: Array<{ + id: string; + name: string; + icon: string; + color: string; + archived: boolean; + }>; + areas: Array<{ id: string; name: string; archived: boolean }>; + }; }; }; @@ -458,7 +576,7 @@ export type ProjectStatsQuery = { stats: { total: number; open: number; - byStatus: Array<{ key: IssueStatus; count: number }>; + byStatus: Array<{ key: string; count: number }>; byType: Array<{ key: string; count: number }>; byPriority: Array<{ key: string; count: number }>; byArea: Array<{ key: string; count: number }>; @@ -511,6 +629,55 @@ export type SearchUsersQuery = { users: Array<{ id: string; name: string; email: string }>; }; +export type UpdateProjectSettingsMutationVariables = Exact<{ + projectId: string; + settings: ProjectSettingsInput; +}>; + +export type UpdateProjectSettingsMutation = { + updateProjectSettings: { id: string; key: string | null; version: number }; +}; + +export type ProjectAnalyticsQueryVariables = Exact<{ + idOrKey: string; + days: number; +}>; + +export type ProjectAnalyticsQuery = { + project: { + id: string; + analytics: { + from: string; + to: string; + createdVsResolved: Array<{ + week: string; + created: number; + resolved: number; + }>; + cumulativeFlow: { + dates: Array; + series: Array<{ status: string; counts: Array }>; + }; + cycleTime: { + count: number; + averageDays: number | null; + p85Days: number | null; + }; + leadTime: { + count: number; + averageDays: number | null; + p85Days: number | null; + }; + throughput: Array<{ + week: string; + count: number; + user: { id: string; name: string } | null; + }>; + qa: { passed: number; failed: number; failureRate: number | null }; + }; + }; +}; + export type ReleasesQueryVariables = Exact<{ idOrKey: string; }>; @@ -573,6 +740,40 @@ export type ActivityQuery = { }>; }; +export type UpcomingReleasesQueryVariables = Exact<{ + limit: number; +}>; + +export type UpcomingReleasesQuery = { + upcomingReleases: Array<{ + id: string; + projectId: string; + name: string; + description: string | null; + targetDate: string | null; + status: ReleaseStatus; + releasedAt: string | null; + progress: { total: number; finished: number; percent: number }; + }>; +}; + +export type ReleaseBurnupQueryVariables = Exact<{ + id: string; +}>; + +export type ReleaseBurnupQuery = { + release: { + id: string; + burnup: { + releaseId: string; + targetDate: string | null; + dates: Array; + scope: Array; + done: Array; + }; + }; +}; + export type IssueChangedSubscriptionVariables = Exact<{ projectId: string; }>; @@ -586,10 +787,11 @@ export type IssueChangedSubscription = { id: string; projectId: string; key: string; - type: IssueType; - area: IssueArea | null; + type: string; + area: string | null; title: string; - status: IssueStatus; + status: string; + statusCategory: StatusCategory; priority: IssuePriority; labels: Array; commentCount: number; @@ -669,6 +871,25 @@ export type ProjectChangedSubscription = { version: number; owner: { id: string; name: string }; members: Array<{ role: ProjectRole; user: { id: string; name: string } }>; + settings: { + statuses: Array<{ + id: string; + name: string; + category: StatusCategory; + color: string; + requiresReason: boolean; + archived: boolean; + }>; + transitions: Array<{ from: string; to: Array }>; + issueTypes: Array<{ + id: string; + name: string; + icon: string; + color: string; + archived: boolean; + }>; + areas: Array<{ id: string; name: string; archived: boolean }>; + }; }; }; }; @@ -688,6 +909,25 @@ export type MyProjectsChangedSubscription = { version: number; owner: { id: string; name: string }; members: Array<{ role: ProjectRole; user: { id: string; name: string } }>; + settings: { + statuses: Array<{ + id: string; + name: string; + category: StatusCategory; + color: string; + requiresReason: boolean; + archived: boolean; + }>; + transitions: Array<{ from: string; to: Array }>; + issueTypes: Array<{ + id: string; + name: string; + icon: string; + color: string; + archived: boolean; + }>; + areas: Array<{ id: string; name: string; archived: boolean }>; + }; }; }; }; @@ -767,6 +1007,82 @@ export const ProjectFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'settings' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'statuses' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'category' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'requiresReason' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'transitions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'from' } }, + { kind: 'Field', name: { kind: 'Name', value: 'to' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueTypes' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'icon' } }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'areas' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + ], + }, + }, ], }, }, @@ -807,6 +1123,7 @@ export const IssueSummaryFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, @@ -943,6 +1260,7 @@ export const IssueFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, @@ -1167,10 +1485,7 @@ export const BoardDocument = { { kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'area' } }, - type: { - kind: 'NamedType', - name: { kind: 'Name', value: 'IssueArea' }, - }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, }, ], selectionSet: { @@ -1292,6 +1607,7 @@ export const BoardDocument = { { kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, @@ -1410,6 +1726,7 @@ export const IssueDocument = { { kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, @@ -1594,6 +1911,7 @@ export const SearchIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, @@ -2521,6 +2839,179 @@ export const DeleteCommentDocument = { DeleteCommentMutation, DeleteCommentMutationVariables >; +export const MyIssuesDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'MyIssues' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'assignedToMe' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Boolean' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'categories' }, + }, + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'StatusCategory' }, + }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'myIssues' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'assignedToMe' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'assignedToMe' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'categories' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'categories' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'total' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'items' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const MyProjectsDocument = { kind: 'Document', definitions: [ @@ -2614,6 +3105,82 @@ export const MyProjectsDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'settings' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'statuses' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'category' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'requiresReason' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'transitions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'from' } }, + { kind: 'Field', name: { kind: 'Name', value: 'to' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueTypes' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'icon' } }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'areas' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + ], + }, + }, ], }, }, @@ -2738,6 +3305,82 @@ export const ProjectDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'settings' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'statuses' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'category' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'requiresReason' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'transitions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'from' } }, + { kind: 'Field', name: { kind: 'Name', value: 'to' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueTypes' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'icon' } }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'areas' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + ], + }, + }, ], }, }, @@ -3283,6 +3926,343 @@ export const SearchUsersDocument = { }, ], } as unknown as DocumentNode; +export const UpdateProjectSettingsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UpdateProjectSettings' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'settings' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'ProjectSettingsInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'updateProjectSettings' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'settings' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'settings' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UpdateProjectSettingsMutation, + UpdateProjectSettingsMutationVariables +>; +export const ProjectAnalyticsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'ProjectAnalytics' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'idOrKey' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'days' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'project' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'idOrKey' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'idOrKey' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'analytics' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'days' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'days' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'from' } }, + { kind: 'Field', name: { kind: 'Name', value: 'to' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdVsResolved' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'week' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'created' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'resolved' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'cumulativeFlow' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'dates' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'series' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'status' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'counts' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'cycleTime' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'count' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'averageDays' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'p85Days' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'leadTime' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'count' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'averageDays' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'p85Days' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'throughput' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'week' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'count' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { + kind: 'Name', + value: 'UserRefFields', + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'qa' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'passed' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'failed' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'failureRate' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + ProjectAnalyticsQuery, + ProjectAnalyticsQueryVariables +>; export const ReleasesDocument = { kind: 'Document', definitions: [ @@ -3742,6 +4722,163 @@ export const ActivityDocument = { }, ], } as unknown as DocumentNode; +export const UpcomingReleasesDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'UpcomingReleases' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'upcomingReleases' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'ReleaseFields' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'progress' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'total' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'finished' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'percent' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'ReleaseFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Release' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'description' } }, + { kind: 'Field', name: { kind: 'Name', value: 'targetDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UpcomingReleasesQuery, + UpcomingReleasesQueryVariables +>; +export const ReleaseBurnupDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'ReleaseBurnup' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'burnup' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'releaseId' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'targetDate' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'dates' } }, + { kind: 'Field', name: { kind: 'Name', value: 'scope' } }, + { kind: 'Field', name: { kind: 'Name', value: 'done' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const IssueChangedDocument = { kind: 'Document', definitions: [ @@ -3835,6 +4972,7 @@ export const IssueChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, @@ -4292,6 +5430,82 @@ export const ProjectChangedDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'settings' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'statuses' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'category' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'requiresReason' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'transitions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'from' } }, + { kind: 'Field', name: { kind: 'Name', value: 'to' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueTypes' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'icon' } }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'areas' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + ], + }, + }, ], }, }, @@ -4402,6 +5616,82 @@ export const MyProjectsChangedDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'settings' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'statuses' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'category' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'requiresReason' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'transitions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'from' } }, + { kind: 'Field', name: { kind: 'Name', value: 'to' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueTypes' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'icon' } }, + { kind: 'Field', name: { kind: 'Name', value: 'color' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'areas' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 001f303..a3fbf1d 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -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 + } + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql index a58b9ab..1827f6e 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql @@ -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 + } + } + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql index 8530f82..f20131f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql @@ -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 + } + } +} diff --git a/libs/web/tracker/presenters/README.md b/libs/web/tracker/presenters/README.md index 2cfd1b5..380485e 100644 --- a/libs/web/tracker/presenters/README.md +++ b/libs/web/tracker/presenters/README.md @@ -7,9 +7,10 @@ Pantallas del tracker con `@pi/ui`. ## Contenido -- Rutas `trackerRoutes`: proyectos, tablero kanban (drag & drop), ticket, releases, seguimiento, actividad y miembros. -- `ui/`: `IssueCardComponent`, `AvatarsComponent` y `copy.ts` (textos, iconos y tonos de color por estado, tipo, - prioridad y proyecto). +- Rutas `trackerRoutes`: inicio (proyectos + «Mi trabajo», `pages/my-work.ts`), tablero kanban (drag & drop), ticket, + releases, seguimiento, estadísticas, actividad, miembros y configuración. +- `ui/`: `IssueCardComponent`, `AvatarsComponent`, `GoToComponent` («Ir a… WEB-12»), `SeriesChartComponent` (gráficas + SVG de líneas y áreas apiladas) y `copy.ts` (textos y tonos; estados, tipos y áreas salen del `Workflow` del proyecto). ## Comandos diff --git a/libs/web/tracker/presenters/src/lib/pages/analytics.page.ts b/libs/web/tracker/presenters/src/lib/pages/analytics.page.ts new file mode 100644 index 0000000..3ee0ea6 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/analytics.page.ts @@ -0,0 +1,600 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { + PiIconComponent, + PiNoteComponent, + PiSegmentedControlComponent, + PiSelectFieldComponent, + PiSkeletonComponent, + type PiSelectFieldOption, + type SegmentedControlOption, +} from '@pi/ui'; +import { ProjectFacade } from '@task-stream/web/tracker/application'; +import { + DurationStats, + ReleaseStatus, + StatusCategory, +} from '@task-stream/web/tracker/domain'; +import { CATEGORY } from '../ui/copy'; +import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart'; + +const PERIODS: SegmentedControlOption[] = [ + { value: 30, label: '30 días' }, + { value: 90, label: '90 días' }, + { value: 180, label: '6 meses' }, + { value: 365, label: '1 año' }, +]; + +/** Flow stages, bottom band first; the ordinal ramp darkens towards done. */ +const FLOW: { category: StatusCategory; color: string }[] = [ + { category: StatusCategory.DONE, color: 'var(--ts-viz-ramp-4)' }, + { category: StatusCategory.QA, color: 'var(--ts-viz-ramp-3)' }, + { category: StatusCategory.IN_PROGRESS, color: 'var(--ts-viz-ramp-2)' }, + { category: StatusCategory.TODO, color: 'var(--ts-viz-ramp-1)' }, +]; + +/** "2026-10-06" → "6 oct" (UTC: the API counts days and weeks in UTC). */ +const day = (iso: string, month: 'short' | 'long' = 'short') => + new Date(`${iso}T00:00:00Z`).toLocaleDateString('es', { + day: 'numeric', + month, + timeZone: 'UTC', + }); + +const decimal = new Intl.NumberFormat('es', { maximumFractionDigits: 1 }); + +/** "Estadísticas": how the project flows over time (owners and team alike). */ +@Component({ + selector: 'ts-analytics-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + PiIconComponent, + PiNoteComponent, + PiSegmentedControlComponent, + PiSelectFieldComponent, + PiSkeletonComponent, + SeriesChartComponent, + ], + template: ` +
+
+

Estadísticas

+

+ Cómo avanza el trabajo, según las categorías de los estados del + proyecto. +

+
+ +
+ + @if (facade.analyticsError(); as error) { + + } @else if (facade.analytics(); as stats) { +
+
+
+

Resueltos

+

{{ resolved() }}

+

de {{ created() }} creados

+
+
+

Tiempo de ciclo

+

{{ days(stats.cycleTime) }}

+

{{ p85(stats.cycleTime) }}

+
+
+

Tiempo de entrega

+

{{ days(stats.leadTime) }}

+

{{ p85(stats.leadTime) }}

+
+
+

Fallo en QA

+

{{ failureRate() }}

+ @if (stats.qa.passed + stats.qa.failed; as total) { + +

+ + {{ stats.qa.passed }} superados + + {{ stats.qa.failed }} fallidos +

+ } @else { +

Nada ha salido de QA aún

+ } +
+
+ +
+
+
+

Creados y resueltos

+

Tickets por semana (lunes a domingo)

+
+ +
+ +
+
+

Flujo acumulado

+

+ Tickets en cada categoría al final de cada día (sin cancelados). + Una banda que engorda es trabajo que se atasca. +

+
+ +
+ +
+
+
+

Burnup de la release

+

{{ burnupNote() }}

+
+ @if (releaseOptions().length) { + + Release + + } +
+ @if (!releaseOptions().length) { +

+ Crea una release y mete tickets en ella para ver su avance. +

+ } @else if (facade.burnup(); as burnup) { + + } @else { + + } +
+ +
+
+

Tickets resueltos por persona

+

+ Quién tenía el ticket asignado al pasarlo a hecho. +

+
+ @if (throughput().length) { +
    + @for (row of throughput(); track row.name) { +
  • + {{ row.name }} + + {{ row.count }} +
  • + } +
+ } @else { +

Nadie ha terminado tickets en el periodo.

+ } +
+
+
+ } @else { +
+
+ @for (i of [1, 2, 3, 4]; track i) { + + } +
+
+ @for (i of [1, 2, 3, 4]; track i) { + + } +
+
+ } + `, + styles: ` + .ts-section-header pi-segmented-control { + max-width: 100%; + } + .ts-stats { + display: grid; + gap: 1rem; + transition: opacity 0.2s ease; + &.is-refreshing { + opacity: 0.55; + } + } + .ts-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 9.5rem), 1fr)); + gap: 1rem; + } + .ts-tile { + display: grid; + align-content: start; + gap: 0.25rem; + padding: 1rem 1.125rem; + p { + margin: 0; + } + } + .ts-tile__label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--ts-muted); + } + .ts-tile__value { + font-size: 2rem; + font-weight: 700; + line-height: 1.1; + color: var(--ts-ink); + } + .ts-tile__sub { + font-size: 0.75rem; + color: var(--ts-muted); + } + .ts-tile__qa { + display: flex; + gap: 0.75rem; + span { + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + } + .ts-meter { + display: flex; + gap: 2px; + height: 0.5rem; + margin: 0.375rem 0 0.125rem; + span { + min-width: 2px; + border-radius: 2px; + } + } + .ts-meter__good { + background: var(--ts-viz-good); + } + .ts-meter__bad { + background: var(--ts-viz-critical); + } + .ts-charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); + gap: 1rem; + } + .ts-chart-card { + display: grid; + align-content: start; + gap: 1rem; + min-width: 0; + padding: 1.25rem; + h3 { + margin: 0; + font-size: 1rem; + font-weight: 700; + } + header p { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + } + } + .ts-chart-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + } + .ts-chart-card__select { + width: 12rem; + max-width: 100%; + } + .ts-empty { + margin: 0; + padding: 2rem 0; + text-align: center; + color: var(--ts-muted); + font-size: 0.875rem; + } + .ts-hbars { + display: grid; + gap: 0.625rem; + margin: 0; + padding: 0; + list-style: none; + li { + display: grid; + grid-template-columns: minmax(6rem, 10rem) 1fr 2rem; + align-items: center; + gap: 0.75rem; + font-size: 0.875rem; + } + } + .ts-hbars__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .ts-hbars__track { + height: 0.875rem; + } + .ts-hbars__bar { + display: block; + height: 100%; + min-width: 2px; + border-radius: 0 4px 4px 0; + background: var(--ts-viz-1); + } + .ts-hbars__value { + font-family: var(--ts-font-mono); + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--ts-ink-soft); + } + `, +}) +export class AnalyticsPage { + protected readonly facade = inject(ProjectFacade); + protected readonly periods = PERIODS; + + /** Release shown in the burnup ('' until the releases arrive). */ + protected readonly releaseId = signal(''); + + constructor() { + // (Re)load whenever the open project changes. + effect(() => { + const projectId = this.facade.project()?.id; + if (projectId) untracked(() => this.facade.loadAnalytics()); + }); + // Default burnup: the next planned release, else the last shipped one. + effect(() => { + const options = this.releaseOptions(); + const current = untracked(this.releaseId); + if (!options.length || options.some((o) => o.value === current)) return; + untracked(() => this.pickRelease(options[0].value)); + }); + } + + private readonly stats = this.facade.analytics; + + protected readonly created = computed(() => + (this.stats()?.createdVsResolved ?? []).reduce((s, w) => s + w.created, 0), + ); + protected readonly resolved = computed(() => + (this.stats()?.createdVsResolved ?? []).reduce((s, w) => s + w.resolved, 0), + ); + + protected readonly weekLabels = computed(() => + (this.stats()?.createdVsResolved ?? []).map((w) => day(w.week)), + ); + protected readonly weekLongLabels = computed(() => + (this.stats()?.createdVsResolved ?? []).map( + (w) => `Semana del ${day(w.week, 'long')}`, + ), + ); + protected readonly createdVsResolved = computed(() => { + const weeks = this.stats()?.createdVsResolved ?? []; + return [ + { + label: 'Creados', + values: weeks.map((w) => w.created), + color: 'var(--ts-viz-1)', + }, + { + label: 'Resueltos', + values: weeks.map((w) => w.resolved), + color: 'var(--ts-viz-2)', + }, + ]; + }); + + protected readonly dayLabels = computed(() => + (this.stats()?.cumulativeFlow.dates ?? []).map((d) => day(d)), + ); + protected readonly dayLongLabels = computed(() => + (this.stats()?.cumulativeFlow.dates ?? []).map((d) => day(d, 'long')), + ); + /** Statuses summed by category: custom statuses keep the chart readable. */ + protected readonly flow = computed(() => { + const stats = this.stats(); + if (!stats) return []; + const workflow = this.facade.workflow(); + const length = stats.cumulativeFlow.dates.length; + return FLOW.map(({ category, color }) => { + const values = new Array(length).fill(0) as number[]; + for (const series of stats.cumulativeFlow.series) { + if (workflow.status(series.status).category !== category) continue; + series.counts.forEach((count, i) => (values[i] += count)); + } + return { label: CATEGORY[category].label, values, color }; + }); + }); + + protected readonly throughput = computed(() => { + const totals = new Map(); + for (const row of this.stats()?.throughput ?? []) { + const id = row.user?.id ?? ''; + const entry = totals.get(id) ?? { + name: row.user?.name ?? 'Sin asignar', + count: 0, + }; + entry.count += row.count; + totals.set(id, entry); + } + return [...totals.values()].sort( + (a, b) => b.count - a.count || a.name.localeCompare(b.name, 'es'), + ); + }); + protected readonly maxThroughput = computed(() => + Math.max(1, ...this.throughput().map((r) => r.count)), + ); + + protected readonly failureRate = computed(() => { + const rate = this.stats()?.qa.failureRate; + return rate === null || rate === undefined + ? '—' + : `${decimal.format(rate * 100)} %`; + }); + + /* Burnup */ + + protected readonly releaseOptions = computed[]>( + () => { + const releases = this.facade + .releases() + .filter((r) => r.status !== ReleaseStatus.ARCHIVED); + // Planned: soonest target first (no date last). + const planned = releases + .filter((r) => r.status === ReleaseStatus.PLANNED) + .sort((a, b) => + (a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999'), + ); + const shipped = releases + .filter((r) => r.status === ReleaseStatus.RELEASED) + .sort((a, b) => (b.releasedAt ?? '').localeCompare(a.releasedAt ?? '')); + return [...planned, ...shipped].map((r) => ({ + value: r.id, + label: r.status === ReleaseStatus.RELEASED ? `${r.name} ✓` : r.name, + })); + }, + ); + private readonly burnup = computed(() => { + const burnup = this.facade.burnup(); + return burnup?.releaseId === this.releaseId() ? burnup : null; + }); + protected readonly burnupLabels = computed(() => + (this.burnup()?.dates ?? []).map((d) => day(d)), + ); + protected readonly burnupLongLabels = computed(() => + (this.burnup()?.dates ?? []).map((d) => day(d, 'long')), + ); + protected readonly burnupSeries = computed(() => { + const burnup = this.burnup(); + if (!burnup) return []; + return [ + { label: 'Alcance', values: burnup.scope, color: 'var(--ts-muted)' }, + { + label: 'Hechos', + values: burnup.done, + color: 'var(--ts-viz-1)', + area: true, + }, + ]; + }); + protected readonly targetMarker = computed(() => { + const burnup = this.burnup(); + if (!burnup?.targetDate) return null; + const index = burnup.dates.indexOf(burnup.targetDate.slice(0, 10)); + return index === -1 ? null : { index, label: 'Objetivo' }; + }); + protected readonly burnupNote = computed(() => { + const burnup = this.burnup(); + if (!burnup) return 'Tickets en la release frente a los ya hechos.'; + const scope = last(burnup.scope) ?? 0; + const done = last(burnup.done) ?? 0; + const target = burnup.targetDate + ? ` · objetivo ${day(burnup.targetDate.slice(0, 10), 'long')}` + : ''; + return `${done} de ${scope} tickets hechos${target}`; + }); + + protected readonly last = last; + + protected pickRelease(releaseId: string): void { + this.releaseId.set(releaseId); + if (releaseId) this.facade.loadBurnup(releaseId); + } + + protected days(stats: DurationStats): string { + return stats.averageDays === null + ? '—' + : `${decimal.format(stats.averageDays)} d`; + } + + protected p85(stats: DurationStats): string { + if (!stats.count) return 'Sin tickets terminados'; + return `85 % en ≤ ${decimal.format(stats.p85Days ?? 0)} d · ${stats.count} tickets`; + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index faca69a..ccdcb3a 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -36,16 +36,12 @@ import { } from '@task-stream/web/tracker/application'; import { BoardColumn, - IssueArea, IssuePriority, - IssueStatus, IssueSummary, - IssueType, - REASON_REQUIRED, ReleaseStatus, } from '@task-stream/web/tracker/domain'; import { IssueCardComponent } from '../ui/issue-card'; -import { AREA, options, PRIORITY, STATUS, TYPE } from '../ui/copy'; +import { options, PRIORITY, statusLook } from '../ui/copy'; const ALL = ''; @@ -85,7 +81,7 @@ const ALL = ''; @for (issue of column.issues; track issue.id) { - + } @empty {

Nada por aquí

} @@ -146,11 +148,17 @@ const ALL = ''; (submit)="$event.preventDefault(); create()" novalidate > - + @if (typeOptions().length <= 5) { + + } @else { + + Tipo + + } Título @@ -160,7 +168,7 @@ const ALL = ''; > Descripción - @if (newType() === 'UI_CHANGE') { + @if (project.workflow().isDesignType(newType())) { Prioridad
- + Área @@ -262,16 +270,35 @@ export class BoardPage { .map((r) => ({ value: r.id, label: r.name })), ], ); - protected readonly areaOptions: PiSelectFieldOption[] = [ + /** Archived areas still filter (old tickets keep them). */ + protected readonly areaFilterOptions = computed< + PiSelectFieldOption[] + >(() => [ { value: ALL, label: 'Todas las áreas' }, - ...options(AREA), - ]; + ...this.project + .workflow() + .settings.areas.map((a) => ({ value: a.id, label: a.name })), + ]); + protected readonly areaOptions = computed[]>( + () => [ + { value: ALL, label: 'Sin área' }, + ...this.project + .workflow() + .activeAreas() + .map((a) => ({ value: a.id, label: a.name })), + ], + ); protected readonly priorityOptions = options(PRIORITY); - protected readonly typeOptions: SegmentedControlOption[] = - options(TYPE); + protected readonly typeOptions = computed[]>( + () => + this.project + .workflow() + .activeTypes() + .map((t) => ({ value: t.id, label: t.name })), + ); /* New ticket */ - protected readonly newType = signal(IssueType.BUG); + protected readonly newType = signal(''); private readonly model = signal({ title: '', description: '', @@ -286,13 +313,13 @@ export class BoardPage { maxLength(p.title, 255); }); protected readonly titlePlaceholder = computed(() => - this.newType() === IssueType.BUG + this.project.workflow().type(this.newType()).icon === 'bug' ? 'El botón de pagar no responde en Safari' : 'Qué hay que hacer', ); /* Move needing a reason */ - private pendingMove: { issueId: string; to: IssueStatus } | null = null; + private pendingMove: { issueId: string; to: string } | null = null; private readonly reasonModel = signal({ text: '' }); protected readonly reason = form(this.reasonModel, (p) => required(p.text)); @@ -311,15 +338,27 @@ export class BoardPage { const filter = { assigneeId: this.assigneeFilter() || undefined, releaseId: this.releaseFilter() || undefined, - area: (this.area() || undefined) as IssueArea | undefined, + area: this.area() || undefined, }; if (!projectId) return; untracked(() => this.board.show({ projectId, ...filter })); }); } - protected statusLabel = (status: IssueStatus) => STATUS[status].label; - protected statusTone = (status: IssueStatus) => STATUS[status].tone; + protected statusLabel = (status: string) => + statusLook(this.project.workflow(), status).label; + protected statusTone = (status: string) => + statusLook(this.project.workflow(), status).tone; + + /** Columns a dragged ticket can enter: its allowed transitions. */ + protected readonly canEnter = ( + drag: CdkDrag, + drop: CdkDropList, + ) => + this.project + .workflow() + .allowedFrom(drag.data.status) + .includes(drop.data.status); protected drop( event: CdkDragDrop, @@ -327,10 +366,10 @@ export class BoardPage { if (event.previousContainer === event.container) return; const to = event.container.data.status; const issueId = event.item.data.id; - if (REASON_REQUIRED.includes(to)) { + if (this.project.workflow().requiresReason(to)) { this.pendingMove = { issueId, to }; this.reasonModel.set({ text: '' }); - this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm()); + this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm()); return; } this.board.moveIssue(issueId, to); @@ -345,6 +384,9 @@ export class BoardPage { } protected openCreate(): void { + const types = this.typeOptions(); + if (!types.some((t) => t.value === this.newType())) + this.newType.set(types[0]?.value ?? ''); this.model.set({ title: '', description: '', @@ -367,11 +409,11 @@ export class BoardPage { title: v.title, description: v.description || null, figmaUrl: - this.newType() === IssueType.UI_CHANGE && v.figmaUrl + this.project.workflow().isDesignType(this.newType()) && v.figmaUrl ? v.figmaUrl : null, priority: v.priority as IssuePriority, - area: (v.area || null) as IssueArea | null, + area: v.area || null, assigneeId: v.assigneeId || null, releaseId: v.releaseId || null, }); diff --git a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts index 48f466d..0298a4e 100644 --- a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts @@ -8,12 +8,11 @@ import { RouterLink } from '@angular/router'; import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { - BOARD_COLUMNS, - IssueStatus, - IssueType, ProjectRole, + QA_FAILED, + StatusCategory, } from '@task-stream/web/tracker/domain'; -import { initials, ROLE, ROLE_TONE, STATUS, TYPE } from '../ui/copy'; +import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy'; /** "Seguimiento": the state of the whole project at a glance (live). */ @Component({ @@ -366,12 +365,20 @@ export class DashboardPage { protected readonly facade = inject(ProjectFacade); protected readonly initialsOf = initials; - private count(status: IssueStatus): number { + private count(status: string): number { return ( this.facade.stats()?.byStatus.find((s) => s.key === status)?.count ?? 0 ); } + /** Tickets in statuses of a category (whatever the project calls them). */ + private countCategory(category: StatusCategory): number { + const workflow = this.facade.workflow(); + return (this.facade.stats()?.byStatus ?? []) + .filter((row) => workflow.status(row.key).category === category) + .reduce((sum, row) => sum + row.count, 0); + } + protected readonly kpis = computed< { label: string; @@ -382,7 +389,11 @@ export class DashboardPage { >(() => { const stats = this.facade.stats(); if (!stats) return []; - const bugs = stats.byType.find((t) => t.key === IssueType.BUG)?.count ?? 0; + // Bugs: every type drawn with the bug icon. + const workflow = this.facade.workflow(); + const bugs = stats.byType + .filter((t) => workflow.type(t.key).icon === 'bug') + .reduce((sum, t) => sum + t.count, 0); return [ { label: 'Tickets', value: stats.total, icon: 'list', color: 'primary' }, { @@ -393,36 +404,50 @@ export class DashboardPage { }, { label: 'Bugs (total)', value: bugs, icon: 'bug', color: 'warn' }, { - label: 'Pendientes de QA', - value: - this.count(IssueStatus.READY_FOR_QA) + this.count(IssueStatus.IN_QA), + label: 'En QA', + value: this.countCategory(StatusCategory.QA), icon: 'qa', color: 'highlighted', }, { label: 'QA fallidos', - value: this.count(IssueStatus.QA_FAILED), + value: this.count(QA_FAILED), icon: 'exclamation', color: 'warn', }, { label: 'Hechos', - value: this.count(IssueStatus.DONE), + value: this.countCategory(StatusCategory.DONE), icon: 'check-rounded', color: 'success', }, ]; }); - /** Statuses in workflow order (cancelled at the end). */ - protected readonly flow = computed(() => - [...BOARD_COLUMNS, IssueStatus.CANCELLED].map((status) => ({ - status, - label: STATUS[status].label, - tone: STATUS[status].tone, - count: this.count(status), - })), - ); + /** + * The project's statuses in board order, cancelled ones last. Archived (or + * no longer configured) statuses only while tickets still sit in them. + */ + protected readonly flow = computed(() => { + const workflow = this.facade.workflow(); + const configured = workflow.settings.statuses.map((s) => s.id); + const ids = [ + ...configured, + ...(this.facade.stats()?.byStatus ?? []) + .map((row) => row.key) + .filter((key) => !configured.includes(key)), + ]; + const cancelled = (id: string) => + workflow.status(id).category === StatusCategory.CANCELLED; + return [...ids.filter((id) => !cancelled(id)), ...ids.filter(cancelled)] + .map((status) => ({ + status, + ...statusLook(workflow, status), + archived: workflow.status(status).archived, + count: this.count(status), + })) + .filter((row) => !row.archived || row.count > 0); + }); protected readonly flowLabel = computed(() => this.flow() .filter((s) => s.count) @@ -471,5 +496,5 @@ export class DashboardPage { protected readonly roleLabel = (role: ProjectRole) => ROLE[role]; protected readonly roleTone = (role: ProjectRole) => ROLE_TONE[role]; protected readonly typeOf = (key: string) => - TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' }; + typeLook(this.facade.workflow(), key); } diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index dfbfabd..22c7c32 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -31,24 +31,20 @@ import { } from '@task-stream/web/tracker/application'; import { Comment, - IssueArea, IssueLink, IssueLinkType, IssuePriority, - IssueStatus, - REASON_REQUIRED, ReleaseStatus, } from '@task-stream/web/tracker/domain'; import { - AREA, clock, initials, LINK, options, PRIORITY, - STATUS, - TYPE, + statusLook, timeAgo, + typeLook, } from '../ui/copy'; const NONE = ''; @@ -303,6 +299,14 @@ const NONE = ''; > Release + + Tipo + Prioridad >('commentEditForm'); - protected readonly type = computed( - () => TYPE[this.facade.issue()?.type ?? 'TASK'], + protected readonly type = computed(() => + typeLook(this.project.workflow(), this.facade.issue()?.type ?? ''), ); - protected readonly status = computed( - () => STATUS[this.facade.issue()?.status ?? IssueStatus.BACKLOG], + protected readonly status = computed(() => + statusLook(this.project.workflow(), this.facade.issue()?.status ?? ''), ); protected readonly ago = timeAgo; @@ -469,10 +473,28 @@ export class IssuePage { ], ); protected readonly priorityOptions = options(PRIORITY); - protected readonly areaOptions: PiSelectFieldOption[] = [ - { value: NONE, label: 'Sin área' }, - ...options(AREA), - ]; + /** Active values, plus the ticket's own even if archived since. */ + protected readonly typeOptions = computed[]>( + () => { + const workflow = this.project.workflow(); + const current = this.facade.issue()?.type; + return workflow.settings.issueTypes + .filter((t) => !t.archived || t.id === current) + .map((t) => ({ value: t.id, label: t.name })); + }, + ); + protected readonly areaOptions = computed[]>( + () => { + const workflow = this.project.workflow(); + const current = this.facade.issue()?.area; + return [ + { value: NONE, label: 'Sin área' }, + ...workflow.settings.areas + .filter((a) => !a.archived || a.id === current) + .map((a) => ({ value: a.id, label: a.name })), + ]; + }, + ); protected readonly linkTypeOptions = ( Object.keys(LINK) as IssueLinkType[] ).map((value) => ({ @@ -485,7 +507,7 @@ export class IssuePage { protected readonly linkTarget = signal(''); protected readonly commentBody = signal(''); - private pendingStatus: IssueStatus | null = null; + private pendingStatus: string | null = null; private readonly reasonModel = signal({ text: '' }); protected readonly reason = form(this.reasonModel, (p) => required(p.text)); @@ -507,8 +529,10 @@ export class IssuePage { effect(() => this.facade.open(this.issueKey())); } - protected statusLabel = (status: IssueStatus) => STATUS[status].label; - protected statusTone = (status: IssueStatus) => STATUS[status].tone; + protected statusLabel = (status: string) => + statusLook(this.project.workflow(), status).label; + protected statusTone = (status: string) => + statusLook(this.project.workflow(), status).tone; protected readonly initialsOf = initials; protected readonly clock = clock; protected linkLabel = (link: IssueLink) => @@ -516,11 +540,11 @@ export class IssuePage { ? LINK[link.type].outward : LINK[link.type].inward; - protected move(to: IssueStatus): void { - if (REASON_REQUIRED.includes(to)) { + protected move(to: string): void { + if (this.project.workflow().requiresReason(to)) { this.pendingStatus = to; this.reasonModel.set({ text: '' }); - this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm()); + this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm()); return; } this.facade.changeStatus(to); @@ -542,8 +566,9 @@ export class IssuePage { } protected update(changes: { + type?: string; priority?: IssuePriority; - area?: IssueArea | null; + area?: string | null; }): void { this.facade.update(changes); } diff --git a/libs/web/tracker/presenters/src/lib/pages/my-work.ts b/libs/web/tracker/presenters/src/lib/pages/my-work.ts new file mode 100644 index 0000000..52e1954 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/my-work.ts @@ -0,0 +1,452 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + DOCUMENT, + inject, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { PiIconComponent, PiSkeletonComponent } from '@pi/ui'; +import { + HomeFacade, + ProjectsFacade, +} from '@task-stream/web/tracker/application'; +import { + IssueSummary, + Release, + StatusCategory, + Workflow, +} from '@task-stream/web/tracker/domain'; +import { + CATEGORY, + initials, + projectKeyOf, + projectTone, + releaseWhen, + statusLook, + typeLook, +} from '../ui/copy'; + +const VISIBLE = 12; + +/** + * "Mi trabajo" on the home page, across every project I belong to: my open + * tickets (QA failures first), tickets waiting for QA and upcoming releases. + * Reloads when the tab becomes visible again. + */ +@Component({ + selector: 'ts-my-work', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, PiIconComponent, PiSkeletonComponent], + template: ` + @if (!home.loaded()) { +
+ +
+ + +
+
+ } @else { +
+
+
+

Mis tickets

+ {{ home.mineTotal() }} abiertos +
+ @if (home.qaFailed()) { +

+

+ } + @for (group of visibleGroups(); track group.category) { +

+ {{ categoryLabel(group.category) }} + {{ group.issues.length }} +

+ + } @empty { +

+ No tienes tickets abiertos asignados. Buen momento para revisar lo + que espera QA. +

+ } + @if (home.mineTotal() > shown()) { +

+ y {{ home.mineTotal() - shown() }} más, en los tableros de cada + proyecto +

+ } +
+ +
+
+
+

Esperando QA

+ {{ home.awaitingQaTotal() }} +
+ +
+ +
+
+

Próximas releases

+
+ +
+
+
+ } + `, + styles: ` + :host { + display: block; + margin-bottom: 2.5rem; + } + .ts-work { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: 1.25rem; + align-items: start; + @media (max-width: 960px) { + grid-template-columns: minmax(0, 1fr); + } + } + .ts-work__side { + display: grid; + gap: 1.25rem; + } + .ts-work__panel { + padding: 1.375rem 1.5rem 1.25rem; + } + .ts-work__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.875rem; + h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 700; + } + .ts-muted { + font-size: 0.75rem; + } + } + .ts-work__alert { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.875rem; + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + background: color-mix(in srgb, var(--ts-s-failed) 10%, var(--ts-surface)); + color: color-mix(in srgb, var(--ts-s-failed) 85%, var(--ts-ink)); + font-size: 0.875rem; + font-weight: 600; + } + .ts-work__group { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.875rem 0 0.375rem; + font-size: 0.75rem; + color: var(--ts-muted); + &:first-of-type { + margin-top: 0; + } + } + .ts-work__list { + display: grid; + margin: 0 -0.5rem; + padding: 0; + list-style: none; + li { + margin: 0; + } + } + .ts-work__row { + --type: var(--ts-muted); + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + min-height: 2.5rem; + padding: 0.25rem 0.5rem 0.25rem 0.75rem; + border-radius: 0.625rem; + color: inherit; + text-decoration: none; + box-shadow: inset 3px 0 0 var(--type); + transition: background-color 0.15s ease; + &:hover { + background: var(--pi-color-primary-25); + } + &:focus-visible { + outline: 2px solid var(--ts-brand); + outline-offset: 1px; + } + } + .ts-work__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + } + .ts-work__status { + margin-right: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--tone); + } + .ts-work__project { + font-family: var(--ts-font-mono); + font-size: 0.6875rem; + font-weight: 600; + color: var(--tone); + } + .ts-work__who { + display: inline-grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface)); + color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink)); + font-family: var(--ts-font-mono); + font-size: 0.5625rem; + font-weight: 600; + } + .ts-work__release { + display: grid; + grid-template-columns: auto auto 1fr; + grid-template-areas: + 'version project when' + 'meter meter percent'; + align-items: center; + gap: 0.375rem 0.625rem; + padding: 0.5rem; + border-radius: 0.625rem; + color: inherit; + text-decoration: none; + &:hover { + background: var(--pi-color-primary-25); + } + &:focus-visible { + outline: 2px solid var(--ts-brand); + outline-offset: 1px; + } + } + .ts-work__version { + grid-area: version; + font-family: var(--ts-font-mono); + font-weight: 600; + letter-spacing: -0.02em; + } + .ts-work__release .ts-work__project { + grid-area: project; + } + .ts-work__when { + grid-area: when; + justify-self: end; + font-size: 0.6875rem; + color: var(--ts-muted); + } + .ts-work__meter { + grid-area: meter; + height: 0.375rem; + border-radius: 0.25rem; + background: var(--ts-sunken); + overflow: hidden; + span { + display: block; + height: 100%; + background: var(--ts-s-done); + } + } + .ts-work__percent { + grid-area: percent; + justify-self: end; + font-size: 0.6875rem; + color: var(--ts-muted); + } + .ts-work__empty { + margin: 0; + padding: 0.25rem 0.5rem; + color: var(--ts-muted); + font-size: 0.875rem; + } + .ts-work__more { + margin: 0.75rem 0 0; + font-size: 0.75rem; + } + `, +}) +export class MyWorkComponent implements OnInit { + protected readonly home = inject(HomeFacade); + private readonly projects = inject(ProjectsFacade); + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + + /** Groups trimmed to the first VISIBLE tickets overall. */ + protected readonly visibleGroups = computed(() => { + let left = VISIBLE; + return this.home + .mineByCategory() + .map((group) => { + const issues = group.issues.slice(0, Math.max(left, 0)); + left -= issues.length; + return { ...group, issues }; + }) + .filter((group) => group.issues.length > 0); + }); + protected readonly shown = computed(() => + this.visibleGroups().reduce((sum, group) => sum + group.issues.length, 0), + ); + + protected readonly keyOf = projectKeyOf; + protected readonly tone = projectTone; + protected readonly initialsOf = initials; + protected readonly when = releaseWhen; + /** Each ticket is shown with its own project's names and colours. */ + private readonly workflows = computed( + () => + new Map( + this.projects + .projects() + .map((p) => [p.id, new Workflow(p.settings)] as const), + ), + ); + private workflowOf(issue: IssueSummary): Workflow { + return this.workflows().get(issue.projectId) ?? new Workflow(); + } + protected readonly statusLabel = (issue: IssueSummary) => + statusLook(this.workflowOf(issue), issue.status).label; + protected readonly statusTone = (issue: IssueSummary) => + statusLook(this.workflowOf(issue), issue.status).tone; + protected readonly typeTone = (issue: IssueSummary) => + typeLook(this.workflowOf(issue), issue.type).tone; + protected readonly categoryLabel = (category: StatusCategory) => + CATEGORY[category].label; + protected readonly categoryTone = (category: StatusCategory) => + CATEGORY[category].tone; + protected readonly projectToneOf = (issueKey: string) => + projectTone(projectKeyOf(issueKey)); + + ngOnInit(): void { + this.home.load(); + // Back to the tab after a while: refresh my work. + const onVisible = () => { + if (this.document.visibilityState === 'visible') this.home.load(); + }; + this.document.addEventListener('visibilitychange', onVisible); + this.destroyRef.onDestroy(() => + this.document.removeEventListener('visibilitychange', onVisible), + ); + } + + /** Releases carry the project id: shown and linked by the project's key. */ + protected projectOf(release: Release): string { + return ( + this.projects.projects().find((p) => p.id === release.projectId)?.key ?? + release.projectId + ); + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts index 7502839..b040bd6 100644 --- a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts @@ -60,8 +60,14 @@ import { projectTone, ROLE } from '../ui/copy'; Tablero Releases Seguimiento + Estadísticas Actividad Miembros + @if (facade.canManage()) { + Configuración + } } @else if (facade.error(); as error) { diff --git a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts index 9fcb199..90f0212 100644 --- a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + computed, inject, OnInit, signal, @@ -25,7 +26,10 @@ import { } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; import { ProjectsFacade } from '@task-stream/web/tracker/application'; +import { AuthFacade } from '@task-stream/web/identity/application'; import { AvatarsComponent } from '../ui/avatars'; +import { GoToComponent } from '../ui/go-to'; +import { MyWorkComponent } from './my-work'; import { projectTone, timeAgo } from '../ui/copy'; @Component({ @@ -35,6 +39,8 @@ import { projectTone, timeAgo } from '../ui/copy'; RouterLink, FormField, AvatarsComponent, + GoToComponent, + MyWorkComponent, PiActionButtonComponent, PiEmptyStateComponent, PiSkeletonComponent, @@ -45,20 +51,35 @@ import { projectTone, timeAgo } from '../ui/copy'; template: `
-

- Tus proyectos · - {{ pad(facade.projects().length) }} -

-

Proyectos

-

Los proyectos de los que formas parte, al día en tiempo real.

+

Pireon · TaskStream

+

+ Hola, + {{ firstName() }} +

+

Lo tuyo en todos tus proyectos, al día en tiempo real.

+
+
+ +
-
+ @if (facade.projects().length) { + +
+

+ Proyectos + {{ + pad(facade.projects().length) + }} +

+
+ } + @if (facade.loading() && !facade.projects().length) {
@for (i of [1, 2, 3]; track i) { @@ -141,6 +162,27 @@ import { projectTone, timeAgo } from '../ui/copy'; `, styles: ` + .ts-home-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + @media (max-width: 640px) { + width: 100%; + ts-go-to { + flex: 1; + } + } + } + .ts-section-header h2 { + display: flex; + align-items: baseline; + gap: 0.625rem; + .ts-mono { + font-size: 0.875rem; + font-weight: 400; + } + } .ts-projects { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); @@ -216,6 +258,10 @@ import { projectTone, timeAgo } from '../ui/copy'; }) export class ProjectsPage implements OnInit { protected readonly facade = inject(ProjectsFacade); + private readonly auth = inject(AuthFacade); + protected readonly firstName = computed( + () => this.auth.user()?.name.split(/\s+/)[0] ?? '', + ); private readonly drawer = inject(DrawerService); private readonly createForm = viewChild.required>('createForm'); diff --git a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts index 1673b74..a6ef7d9 100644 --- a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts @@ -18,7 +18,7 @@ import { import { DrawerService } from '@task-stream/web/shared/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain'; -import { RELEASE_STATUS, timeAgo } from '../ui/copy'; +import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; @Component({ selector: 'ts-releases-page', @@ -294,19 +294,7 @@ export class ReleasesPage { : release.status === ReleaseStatus.ARCHIVED ? 'var(--ts-s-cancelled)' : 'var(--ts-s-progress)'; - /** "publicada hace 2 días", "objetivo 17 oct · en 14 días". */ - protected when = (release: Release) => { - if (release.releasedAt) return `publicada ${timeAgo(release.releasedAt)}`; - if (!release.targetDate) return 'sin fecha'; - const target = new Date(release.targetDate); - const days = Math.ceil((target.getTime() - Date.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`; - }; + protected readonly when = releaseWhen; protected openCreate(): void { this.model.set({ name: '', description: '', targetDate: '' }); diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts new file mode 100644 index 0000000..50094e3 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -0,0 +1,947 @@ +import { + CdkDrag, + CdkDragDrop, + CdkDragHandle, + CdkDropList, + moveItemInArray, +} from '@angular/cdk/drag-drop'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { PiIconComponent, PiNoteComponent } from '@pi/ui'; +import { ProjectFacade } from '@task-stream/web/tracker/application'; +import { + ProjectSettings, + SETTING_COLORS, + StatusCategory, + TYPE_ICONS, +} from '@task-stream/web/tracker/domain'; +import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy'; + +type Section = 'statuses' | 'issueTypes' | 'areas'; + +const CATEGORIES = Object.values(StatusCategory); + +/** "Datos y análisis" → "DATOS_Y_ANALISIS": ids of new values (they never change after saving). */ +function idFrom(name: string): string { + const slug = name + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .replace(/^[^A-Z]+/, '') + .slice(0, 30); + return slug || 'NUEVO'; +} + +/** + * "Configuración" (owners and admins): the project's statuses, how tickets + * move between them, ticket types and areas. Edits a local draft, saved in + * one go; values can be archived but not removed once saved. + */ +@Component({ + selector: 'ts-settings-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CdkDropList, + CdkDrag, + CdkDragHandle, + NgTemplateOutlet, + PiIconComponent, + PiNoteComponent, + ], + template: ` +
+
+

Configuración

+

+ Estados, transiciones, tipos de ticket y áreas de este proyecto. Lo + que ya se usa no se borra: se archiva y deja de ofrecerse. +

+
+
+ + @if (!facade.canManage()) { + + } + + @if (draft(); as d) { +
+
+ +
+
+
+

Estados

+

+ En el orden de las columnas del tablero. La categoría dice qué + significa cada uno para releases, «Mi trabajo» y estadísticas. +

+
+ +
+ +
    + @for (status of d.statuses; track $index; let i = $index) { +
  1. + + + + + {{ status.id }} + + + + + +
  2. + } +
+
+ + +
+
+
+

Transiciones

+

+ A qué estados puede pasar un ticket desde cada uno (fila → + columna). Los archivados no reciben tickets. +

+
+
+
+ + + + + @for (to of activeStatuses(); track to.id) { + + } + + + + @for (from of d.statuses; track from.id) { + + + @for (to of activeStatuses(); track to.id) { + + } + + } + +
+ Desde + + {{ to.name }} +
+ {{ from.name }} + + @if (from.id !== to.id) { + + } +
+
+
+ +
+ +
+
+
+

Tipos de ticket

+

+ Los de icono de paleta piden un frame de Figma. +

+
+ +
+
    + @for (type of d.issueTypes; track $index; let i = $index) { +
  1. + + + + {{ type.id }} + + + + +
  2. + } +
+
+ + +
+
+
+

Áreas

+

Equipo o capa a la que va el ticket.

+
+ +
+
    + @for (area of d.areas; track $index; let i = $index) { +
  1. + + + {{ area.id }} + + +
  2. + } +
+
+
+
+ + @if (facade.canManage() && (dirty() || facade.savingSettings())) { +
+
+ @if (problems().length) { +
    + @for (problem of problems(); track problem) { +
  • + + {{ problem }} +
  • + } +
+ } @else { + Cambios sin guardar + } +
+ + +
+ } +
+ } + + + @if (isNew(section, item.id)) { + + } @else if (item.archived) { + + } @else { + + } + + `, + styles: ` + .ts-settings { + display: grid; + gap: 1rem; + padding-bottom: 5rem; + } + fieldset { + display: grid; + gap: 1rem; + min-width: 0; + margin: 0; + padding: 0; + border: 0; + } + .ts-settings__pair { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr)); + gap: 1rem; + align-items: start; + } + .ts-block { + display: grid; + gap: 0.75rem; + min-width: 0; + padding: 1.25rem; + h3 { + margin: 0; + font-size: 1rem; + font-weight: 700; + } + } + .ts-block__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + p { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + } + } + .ts-rows { + display: grid; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; + } + .ts-rows-head { + padding: 0 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ts-muted); + } + .ts-status-grid { + display: grid; + grid-template-columns: 1.25rem minmax(10rem, 1fr) 9rem 8rem 5.5rem 6.5rem; + align-items: center; + gap: 0.5rem; + } + .ts-type-grid { + display: grid; + grid-template-columns: 2rem minmax(8rem, 1fr) 7rem 7rem 6.5rem; + align-items: center; + gap: 0.5rem; + } + .ts-area-grid { + display: grid; + grid-template-columns: minmax(8rem, 1fr) 6.5rem; + align-items: center; + gap: 0.5rem; + } + .ts-row { + --tone: var(--ts-muted); + padding: 0.375rem 0.5rem; + border-radius: 0.625rem; + background: var(--ts-surface); + border: 1px solid var(--ts-line); + &.is-archived { + background: var(--ts-sunken); + .ts-input, + .ts-type-icon { + opacity: 0.6; + } + } + } + .cdk-drag-preview { + box-shadow: var(--ts-lift); + } + .cdk-drag-placeholder { + opacity: 0.35; + } + .ts-grip { + cursor: grab; + color: var(--ts-muted); + text-align: center; + user-select: none; + } + .ts-name { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + .ts-input { + flex: 1; + min-width: 0; + } + } + .ts-swatch { + flex: none; + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + background: var(--tone); + } + .ts-id { + flex: 0 0 5.5rem; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--ts-font-mono); + font-size: 0.625rem; + color: var(--ts-muted); + } + .ts-input { + box-sizing: border-box; + height: 2.125rem; + width: 100%; + padding: 0 0.5rem; + border: 1px solid var(--ts-line); + border-radius: 0.5rem; + background: var(--ts-surface); + color: var(--ts-ink); + font: inherit; + font-size: 0.875rem; + &:focus-visible { + outline: 2px solid var(--ts-brand); + outline-offset: 1px; + } + &:disabled { + color: var(--ts-ink-soft); + background: transparent; + } + } + .ts-check { + display: grid; + place-items: center; + } + input[type='checkbox'] { + width: 1rem; + height: 1rem; + accent-color: var(--ts-brand); + cursor: pointer; + } + .ts-type-icon { + display: inline-grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + color: var(--tone); + background: color-mix(in srgb, var(--tone) 12%, var(--ts-surface)); + } + .ts-matrix-wrap { + overflow-x: auto; + } + .ts-matrix { + border-collapse: collapse; + font-size: 0.8125rem; + th, + td { + padding: 0.375rem 0.5rem; + border-bottom: 1px solid var(--ts-line); + text-align: center; + } + th[scope='row'] { + text-align: left; + white-space: nowrap; + } + thead th { + vertical-align: bottom; + } + tr.is-archived th[scope='row'] { + opacity: 0.6; + } + td:hover { + background: var(--pi-color-primary-25); + } + } + /* @pi/ui styles every