diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index d78d7a7..c14e7ac 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -1059,6 +1059,16 @@
"type": "boolean"
}
},
+ {
+ "name": "sprintId",
+ "required": false,
+ "in": "query",
+ "description": "Only this sprint",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
{
"name": "perColumn",
"required": false,
@@ -2240,6 +2250,25 @@
"type": "string"
}
},
+ {
+ "name": "sprintId",
+ "required": false,
+ "in": "query",
+ "description": "Only this sprint",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ {
+ "name": "noSprint",
+ "required": false,
+ "in": "query",
+ "description": "Only tickets in no sprint",
+ "schema": {
+ "type": "boolean"
+ }
+ },
{
"name": "sort",
"required": false,
@@ -6272,6 +6301,1075 @@
"Saved filters"
]
}
+ },
+ "/api/projects/{project}/sprints": {
+ "get": {
+ "description": "The one under way, the planned ones, then the closed ones.",
+ "operationId": "SprintsController_list",
+ "parameters": [
+ {
+ "name": "project",
+ "required": true,
+ "in": "path",
+ "description": "Project id or key",
+ "schema": {
+ "example": "WEB",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SprintDto"
+ }
+ }
+ }
+ }
+ },
+ "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": "A project's sprints",
+ "tags": [
+ "Sprints"
+ ]
+ },
+ "post": {
+ "operationId": "SprintsController_create",
+ "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/CreateSprintDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "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": "Plan a sprint (owners and admins)",
+ "tags": [
+ "Sprints"
+ ]
+ }
+ },
+ "/api/sprints/{sprint}": {
+ "get": {
+ "operationId": "SprintsController_findOne",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SprintDto"
+ }
+ }
+ }
+ },
+ "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": "A sprint",
+ "tags": [
+ "Sprints"
+ ]
+ },
+ "patch": {
+ "operationId": "SprintsController_update",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSprintDto"
+ }
+ }
+ }
+ },
+ "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": "Rename, re-date or change the goal (not once closed)",
+ "tags": [
+ "Sprints"
+ ]
+ },
+ "delete": {
+ "description": "Its tickets go back to the backlog.",
+ "operationId": "SprintsController_delete",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "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": "Delete a sprint that never started",
+ "tags": [
+ "Sprints"
+ ]
+ }
+ },
+ "/api/sprints/{sprint}/start": {
+ "post": {
+ "operationId": "SprintsController_start",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "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": "Start a planned sprint (only one under way per project)",
+ "tags": [
+ "Sprints"
+ ]
+ }
+ },
+ "/api/sprints/{sprint}/close": {
+ "post": {
+ "description": "Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.",
+ "operationId": "SprintsController_close",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CloseSprintDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "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": "Close the sprint under way",
+ "tags": [
+ "Sprints"
+ ]
+ }
+ },
+ "/api/sprints/{sprint}/progress": {
+ "get": {
+ "operationId": "SprintsController_progress",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SprintProgressDto"
+ }
+ }
+ }
+ },
+ "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": "Finished tickets and points",
+ "tags": [
+ "Sprints"
+ ]
+ }
+ },
+ "/api/sprints/{sprint}/burndown": {
+ "get": {
+ "operationId": "SprintsController_burndown",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SprintBurndownDto"
+ }
+ }
+ }
+ },
+ "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": "Open tickets and points, day by day, against the ideal line",
+ "tags": [
+ "Sprints"
+ ]
+ }
+ },
+ "/api/sprints/{sprint}/issues": {
+ "get": {
+ "operationId": "SprintsController_issuesOf",
+ "parameters": [
+ {
+ "name": "sprint",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "minimum": 1,
+ "maximum": 200,
+ "default": 50,
+ "type": "number"
+ }
+ },
+ {
+ "name": "offset",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "minimum": 0,
+ "default": 0,
+ "type": "number"
+ }
+ }
+ ],
+ "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": "Tickets in the sprint, in backlog order",
+ "tags": [
+ "Sprints"
+ ]
+ }
+ },
+ "/api/issues/{issue}/sprint": {
+ "put": {
+ "operationId": "SprintsController_setIssueSprint",
+ "parameters": [
+ {
+ "name": "issue",
+ "required": true,
+ "in": "path",
+ "description": "Ticket id or key",
+ "schema": {
+ "example": "WEB-12",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SetIssueSprintDto"
+ }
+ }
+ }
+ },
+ "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": "Put the ticket in a sprint (null: the backlog)",
+ "tags": [
+ "Sprints"
+ ]
+ }
}
},
"info": {
@@ -7124,6 +8222,23 @@
"name"
]
},
+ "SprintRefDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "name": {
+ "type": "string",
+ "example": "Sprint 14"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ },
"IssueLinkType": {
"type": "string",
"enum": [
@@ -7351,6 +8466,15 @@
}
]
},
+ "sprint": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/SprintRefDto"
+ }
+ ]
+ },
"labels": {
"type": "array",
"items": {
@@ -7442,6 +8566,7 @@
"reporter",
"assignee",
"release",
+ "sprint",
"labels",
"figmaUrl",
"storyPoints",
@@ -8774,6 +9899,247 @@
"required": [
"id"
]
+ },
+ "SprintStatus": {
+ "type": "string",
+ "enum": [
+ "PLANNED",
+ "ACTIVE",
+ "CLOSED"
+ ]
+ },
+ "SprintDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "projectId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "name": {
+ "type": "string",
+ "example": "Sprint 14"
+ },
+ "goal": {
+ "type": "string",
+ "nullable": true
+ },
+ "startDate": {
+ "type": "string",
+ "format": "date"
+ },
+ "endDate": {
+ "type": "string",
+ "format": "date",
+ "description": "Last day, included"
+ },
+ "status": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/SprintStatus"
+ }
+ ]
+ },
+ "startedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "closedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "version": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "id",
+ "projectId",
+ "name",
+ "goal",
+ "startDate",
+ "endDate",
+ "status",
+ "startedAt",
+ "closedAt",
+ "createdAt",
+ "version"
+ ]
+ },
+ "CreateSprintDto": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "example": "Sprint 14",
+ "maxLength": 80
+ },
+ "goal": {
+ "type": "string",
+ "nullable": true,
+ "maxLength": 500,
+ "example": "Pagos con tarjeta en producción"
+ },
+ "startDate": {
+ "type": "string",
+ "format": "date",
+ "example": "2026-10-12"
+ },
+ "endDate": {
+ "type": "string",
+ "format": "date",
+ "example": "2026-10-23",
+ "description": "Last day, included"
+ }
+ },
+ "required": [
+ "name",
+ "startDate",
+ "endDate"
+ ]
+ },
+ "UpdateSprintDto": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "example": "Sprint 14",
+ "maxLength": 80
+ },
+ "goal": {
+ "type": "string",
+ "nullable": true,
+ "maxLength": 500,
+ "example": "Pagos con tarjeta en producción"
+ },
+ "startDate": {
+ "type": "string",
+ "format": "date",
+ "example": "2026-10-12"
+ },
+ "endDate": {
+ "type": "string",
+ "format": "date",
+ "example": "2026-10-23",
+ "description": "Last day, included"
+ }
+ }
+ },
+ "CloseSprintDto": {
+ "type": "object",
+ "properties": {
+ "carryOverTo": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid",
+ "description": "Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog"
+ }
+ }
+ },
+ "SprintProgressDto": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "number"
+ },
+ "finished": {
+ "type": "number",
+ "description": "Done or cancelled"
+ },
+ "percent": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 100
+ },
+ "storyPoints": {
+ "type": "number"
+ },
+ "finishedStoryPoints": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "total",
+ "finished",
+ "percent",
+ "storyPoints",
+ "finishedStoryPoints"
+ ]
+ },
+ "SprintBurndownDto": {
+ "type": "object",
+ "properties": {
+ "sprintId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "dates": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "date"
+ }
+ },
+ "scope": {
+ "nullable": true,
+ "description": "Tickets in the sprint (null: days to come)",
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ },
+ "remaining": {
+ "nullable": true,
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ },
+ "remainingPoints": {
+ "nullable": true,
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ },
+ "ideal": {
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ }
+ },
+ "required": [
+ "sprintId",
+ "dates",
+ "scope",
+ "remaining",
+ "remainingPoints",
+ "ideal"
+ ]
+ },
+ "SetIssueSprintDto": {
+ "type": "object",
+ "properties": {
+ "sprintId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid",
+ "description": "Open sprint of the project, or null for the backlog"
+ }
+ },
+ "required": [
+ "sprintId"
+ ]
}
}
}
diff --git a/apps/api/schema.gql b/apps/api/schema.gql
index c2f521f..8a0464e 100644
--- a/apps/api/schema.gql
+++ b/apps/api/schema.gql
@@ -176,6 +176,17 @@ input CreateReleaseInput {
targetDate: String
}
+input CreateSprintInput {
+ """YYYY-MM-DD, included"""
+ endDate: String!
+ goal: String
+ name: String!
+ projectId: ID!
+
+ """YYYY-MM-DD"""
+ startDate: String!
+}
+
type CreatedResolved {
created: Int!
resolved: Int!
@@ -249,6 +260,7 @@ type Issue {
rank: String!
release: ReleaseRef
reporter: UserRef!
+ sprint: SprintRef
"""A status id of the project's settings"""
status: String!
@@ -278,6 +290,9 @@ input IssueFilterInput {
categories: [StatusCategory!]
labels: [String!]
+ """Only tickets in no sprint"""
+ noSprint: Boolean
+
"""Only the tickets hanging from this one"""
parentId: ID
priorities: [IssuePriority!]
@@ -287,6 +302,7 @@ input IssueFilterInput {
"""Matches key, title or description"""
search: String
sort: IssueSort = RECENT
+ sprintId: ID
statuses: [String!]
types: [String!]
unassigned: Boolean
@@ -388,10 +404,18 @@ type Mutation {
"""Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
+
+ """
+ Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
+ """
+ closeSprint(carryOverToSprintId: ID, sprintId: ID!): CommandResult!
createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult!
+ """Plan a sprint (owners and admins)"""
+ createSprint(input: CreateSprintInput!): CommandResult!
+
"""Author or project admin"""
deleteComment(commentId: ID!): CommandResult!
@@ -402,6 +426,9 @@ type Mutation {
"""Delete a view: whoever saved it, or an admin if it is shared"""
deleteSavedFilter(id: ID!): Boolean!
+
+ """Delete a sprint that never started; its tickets go back to the backlog"""
+ deleteSprint(sprintId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
@@ -435,6 +462,12 @@ type Mutation {
"""Put a ticket in a release (null removes it)"""
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
+
+ """Put a ticket in a sprint (null: back to the backlog)"""
+ setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
+
+ """Start a planned sprint (only one under way per project)"""
+ startSprint(sprintId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Stop following a ticket"""
@@ -451,6 +484,7 @@ type Mutation {
"""
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult!
+ updateSprint(input: UpdateSprintInput!): CommandResult!
"""
Follow a ticket: be told when it moves, is reassigned or gets a comment
@@ -517,6 +551,7 @@ type Project {
"""Matches key, title or description"""
search: String
+ sprintId: ID
types: [String!]
unassigned: Boolean
): [BoardColumn!]!
@@ -668,6 +703,12 @@ type Query {
"""My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]!
+ sprint(id: ID!): Sprint!
+
+ """
+ A project's sprints: the one under way, the planned ones, then the closed ones
+ """
+ sprints(projectId: ID!): [Sprint!]!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
@@ -784,6 +825,73 @@ input SavedFilterCriteriaInput {
unassigned: Boolean
}
+"""A time-boxed iteration: the tickets the team commits to"""
+type Sprint {
+ burndown: SprintBurndown!
+ closedAt: String
+ createdAt: String!
+
+ """Last day (included), YYYY-MM-DD"""
+ endDate: String!
+ goal: String
+ id: ID!
+
+ """Tickets in this sprint, in backlog order"""
+ issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
+ name: String!
+ progress: SprintProgress!
+ projectId: ID!
+
+ """First day, YYYY-MM-DD"""
+ startDate: String!
+ startedAt: String
+ status: SprintStatus!
+ version: Int!
+}
+
+"""
+Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero.
+"""
+type SprintBurndown {
+ dates: [String!]!
+ ideal: [Float!]!
+ remaining: [Int]!
+ remainingPoints: [Int]!
+ scope: [Int]!
+ sprintId: ID!
+}
+
+type SprintChange {
+ action: String!
+ actorId: ID!
+
+ """Null: deleted"""
+ sprint: Sprint
+ sprintId: ID!
+ version: Int!
+}
+
+type SprintProgress {
+ """Done or cancelled"""
+ finished: Int!
+ finishedStoryPoints: Int!
+ percent: Int!
+ storyPoints: Int!
+ total: Int!
+}
+
+type SprintRef {
+ id: ID!
+ name: String!
+}
+
+"""PLANNED, ACTIVE (one per project at a time) or CLOSED"""
+enum SprintStatus {
+ ACTIVE
+ CLOSED
+ PLANNED
+}
+
"""
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
"""
@@ -820,6 +928,7 @@ type Subscription {
notificationAdded: Notification!
projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange!
+ sprintChanged(projectId: ID!): SprintChange!
}
type Throughput {
@@ -865,6 +974,14 @@ input UpdateReleaseInput {
targetDate: String
}
+input UpdateSprintInput {
+ endDate: String
+ goal: String
+ name: String
+ sprintId: ID!
+ startDate: String
+}
+
"""User managed by the Pireon identity server"""
type User {
email: String!
diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts
index f0e0dab..17bc158 100644
--- a/apps/mcp/src/app/api/taskstream.schema.ts
+++ b/apps/mcp/src/app/api/taskstream.schema.ts
@@ -909,6 +909,154 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/projects/{project}/sprints": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * A project's sprints
+ * @description The one under way, the planned ones, then the closed ones.
+ */
+ get: operations["SprintsController_list"];
+ put?: never;
+ /** Plan a sprint (owners and admins) */
+ post: operations["SprintsController_create"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/sprints/{sprint}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** A sprint */
+ get: operations["SprintsController_findOne"];
+ put?: never;
+ post?: never;
+ /**
+ * Delete a sprint that never started
+ * @description Its tickets go back to the backlog.
+ */
+ delete: operations["SprintsController_delete"];
+ options?: never;
+ head?: never;
+ /** Rename, re-date or change the goal (not once closed) */
+ patch: operations["SprintsController_update"];
+ trace?: never;
+ };
+ "/api/sprints/{sprint}/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Start a planned sprint (only one under way per project) */
+ post: operations["SprintsController_start"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/sprints/{sprint}/close": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Close the sprint under way
+ * @description Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.
+ */
+ post: operations["SprintsController_close"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/sprints/{sprint}/progress": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Finished tickets and points */
+ get: operations["SprintsController_progress"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/sprints/{sprint}/burndown": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Open tickets and points, day by day, against the ideal line */
+ get: operations["SprintsController_burndown"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/sprints/{sprint}/issues": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Tickets in the sprint, in backlog order */
+ get: operations["SprintsController_issuesOf"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/issues/{issue}/sprint": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /** Put the ticket in a sprint (null: the backlog) */
+ put: operations["SprintsController_setIssueSprint"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {
@@ -1167,6 +1315,12 @@ export interface components {
/** @example v1.4.0 */
name: string;
};
+ SprintRefDto: {
+ /** Format: uuid */
+ id: string;
+ /** @example Sprint 14 */
+ name: string;
+ };
/** @enum {string} */
IssueLinkType: "BLOCKS" | "DEPENDS_ON" | "TESTS" | "DUPLICATES" | "RELATES_TO";
/**
@@ -1254,6 +1408,7 @@ export interface components {
reporter: components["schemas"]["UserRefDto"];
assignee: components["schemas"]["UserRefDto"] | null;
release: components["schemas"]["ReleaseRefDto"] | null;
+ sprint: components["schemas"]["SprintRefDto"] | null;
labels: string[];
/** @description Figma frame */
figmaUrl: string | null;
@@ -1707,6 +1862,98 @@ export interface components {
/** Format: uuid */
id: string;
};
+ /** @enum {string} */
+ SprintStatus: "PLANNED" | "ACTIVE" | "CLOSED";
+ SprintDto: {
+ /** Format: uuid */
+ id: string;
+ /** Format: uuid */
+ projectId: string;
+ /** @example Sprint 14 */
+ name: string;
+ goal: string | null;
+ /** Format: date */
+ startDate: string;
+ /**
+ * Format: date
+ * @description Last day, included
+ */
+ endDate: string;
+ status: components["schemas"]["SprintStatus"];
+ /** Format: date-time */
+ startedAt: string | null;
+ /** Format: date-time */
+ closedAt: string | null;
+ /** Format: date-time */
+ createdAt: string;
+ version: number;
+ };
+ CreateSprintDto: {
+ /** @example Sprint 14 */
+ name: string;
+ /** @example Pagos con tarjeta en producción */
+ goal?: string | null;
+ /**
+ * Format: date
+ * @example 2026-10-12
+ */
+ startDate: string;
+ /**
+ * Format: date
+ * @description Last day, included
+ * @example 2026-10-23
+ */
+ endDate: string;
+ };
+ UpdateSprintDto: {
+ /** @example Sprint 14 */
+ name?: string;
+ /** @example Pagos con tarjeta en producción */
+ goal?: string | null;
+ /**
+ * Format: date
+ * @example 2026-10-12
+ */
+ startDate?: string;
+ /**
+ * Format: date
+ * @description Last day, included
+ * @example 2026-10-23
+ */
+ endDate?: string;
+ };
+ CloseSprintDto: {
+ /**
+ * Format: uuid
+ * @description Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog
+ */
+ carryOverTo?: string | null;
+ };
+ SprintProgressDto: {
+ total: number;
+ /** @description Done or cancelled */
+ finished: number;
+ percent: number;
+ storyPoints: number;
+ finishedStoryPoints: number;
+ };
+ SprintBurndownDto: {
+ /** Format: uuid */
+ sprintId: string;
+ dates: string[];
+ /** @description Tickets in the sprint (null: days to come) */
+ scope: number[] | null;
+ remaining: number[] | null;
+ remainingPoints: number[] | null;
+ ideal: number[];
+ };
+ SetIssueSprintDto: {
+ /**
+ * Format: uuid
+ * @description Open sprint of the project, or null for the backlog
+ */
+ sprintId: string | null;
+ };
};
responses: never;
parameters: never;
@@ -2438,6 +2685,8 @@ export interface operations {
search?: string;
/** @description Only tickets without assignee */
unassigned?: boolean;
+ /** @description Only this sprint */
+ sprintId?: string;
perColumn?: number;
};
header?: never;
@@ -3152,6 +3401,10 @@ export interface operations {
label?: string[];
/** @description Only the tickets hanging from this one (id or key) */
parent?: string;
+ /** @description Only this sprint */
+ sprintId?: string;
+ /** @description Only tickets in no sprint */
+ noSprint?: boolean;
/** @description RECENT (default): last updated first. RANK: backlog order. */
sort?: components["schemas"]["IssueSort"];
/** @description Matches key, title or description */
@@ -6012,4 +6265,761 @@ export interface operations {
};
};
};
+ SprintsController_list: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Project id or key */
+ project: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SprintDto"][];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_create: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Project id or key */
+ project: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateSprintDto"];
+ };
+ };
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommandResultDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Someone changed it at the same time: retry */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Business rule (e.g. a transition the workflow forbids) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_findOne: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SprintDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommandResultDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Someone changed it at the same time: retry */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Business rule (e.g. a transition the workflow forbids) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_update: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateSprintDto"];
+ };
+ };
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommandResultDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Someone changed it at the same time: retry */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Business rule (e.g. a transition the workflow forbids) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_start: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommandResultDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Someone changed it at the same time: retry */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Business rule (e.g. a transition the workflow forbids) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_close: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CloseSprintDto"];
+ };
+ };
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommandResultDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Someone changed it at the same time: retry */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Business rule (e.g. a transition the workflow forbids) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_progress: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SprintProgressDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_burndown: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SprintBurndownDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_issuesOf: {
+ parameters: {
+ query?: {
+ limit?: number;
+ offset?: number;
+ };
+ header?: never;
+ path: {
+ sprint: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["IssuePageDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
+ SprintsController_setIssueSprint: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Ticket id or key */
+ issue: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SetIssueSprintDto"];
+ };
+ };
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CommandResultDto"];
+ };
+ };
+ /** @description Invalid input */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Missing or rejected Pireon token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not a member of the project (or not allowed) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Someone changed it at the same time: retry */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ /** @description Business rule (e.g. a transition the workflow forbids) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiErrorDto"];
+ };
+ };
+ };
+ };
}
diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts
index 47b1960..dc1c631 100644
--- a/apps/web/src/app/app.routes.spec.ts
+++ b/apps/web/src/app/app.routes.spec.ts
@@ -29,6 +29,7 @@ describe('appRoutes', () => {
'board',
'issues/:issueKey',
'backlog',
+ 'sprints',
'epics',
'releases',
'dashboard',
diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md
index 99a6f67..cf6c53a 100644
--- a/docs/01-vision-general.md
+++ b/docs/01-vision-general.md
@@ -15,6 +15,7 @@
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
+| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md
index 909e439..b26a766 100644
--- a/docs/02-arquitectura.md
+++ b/docs/02-arquitectura.md
@@ -139,9 +139,10 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
| Saga | Evento | Comandos |
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
+| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
+| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
diff --git a/docs/03-api.md b/docs/03-api.md
index 85cb667..e9cb5d9 100644
--- a/docs/03-api.md
+++ b/docs/03-api.md
@@ -157,6 +157,11 @@ stateDiagram-v2
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
+- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
+ Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
+ sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
+ adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
+ registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
@@ -192,12 +197,13 @@ por defecto); un correo que falla se registra y no bloquea el stream.
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`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
-| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
-| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
-| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
+| Agregado | Eventos |
+| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
+| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
+| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
+| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
+| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
`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.
@@ -216,6 +222,7 @@ puras (`application/src/lib/analytics`):
| 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 |
+| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
| 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,
@@ -240,6 +247,7 @@ que funcionan también sin token.
| `publicProjects` | Proyectos públicos (sin token) |
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
+| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
@@ -255,6 +263,7 @@ Todas devuelven `CommandResult { id, key, version }`.
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
+| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
@@ -267,6 +276,7 @@ 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 |
+| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
| `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 |
@@ -459,6 +469,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/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`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
+| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts
index 042a9ca..74caf05 100644
--- a/libs/api/tracker/application/src/index.ts
+++ b/libs/api/tracker/application/src/index.ts
@@ -10,5 +10,6 @@ export * from './lib/workflows.service';
export * from './lib/notifications.service';
export * from './lib/notifications';
export * from './lib/saved-filters.service';
+export * from './lib/sprints.service';
export * from './lib/saved-filters/saved-filters';
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
index 3891c75..63e97e1 100644
--- a/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
+++ b/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
@@ -4,10 +4,13 @@ import {
ReleaseReadModel,
ReleaseStatus,
StatusCategory as C,
+ SprintReadModel,
+ SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
computeReleaseBurnup,
+ computeSprintBurndown,
weekOf,
} from './analytics';
@@ -182,3 +185,62 @@ describe('release burnup', () => {
expect(at(burnup.done, '2026-10-14')).toBe(2);
});
});
+
+describe('sprint burndown', () => {
+ const sprint = {
+ id: 's1',
+ projectId: 'p',
+ startDate: '2026-10-12',
+ endDate: '2026-10-16',
+ status: SprintStatus.ACTIVE,
+ closedAt: null,
+ } as SprintReadModel;
+ const into = (issueId: string, at: string): IssueTimelineEntry => ({
+ id: `${issueId}:${++seq}`,
+ issueId,
+ projectId: 'p',
+ at,
+ kind: 'sprint',
+ sprintId: 's1',
+ });
+ // X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
+ // X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
+ const issues = [
+ { ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
+ {
+ ...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
+ storyPoints: 5,
+ },
+ { ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
+ ] as IssueReadModel[];
+ const timeline = [
+ step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
+ step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
+ step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
+ into('x', '2026-10-12T08:00:00Z'),
+ into('y', '2026-10-12T08:00:00Z'),
+ into('z', '2026-10-13T10:00:00Z'),
+ step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
+ step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
+ ];
+
+ it('counts open tickets and points per day, and leaves the future blank', () => {
+ const burndown = computeSprintBurndown({
+ sprint,
+ issues,
+ timeline,
+ now: new Date('2026-10-14T12:00:00Z'),
+ });
+ expect(burndown.dates).toEqual([
+ '2026-10-12',
+ '2026-10-13',
+ '2026-10-14',
+ '2026-10-15',
+ '2026-10-16',
+ ]);
+ expect(burndown.scope).toEqual([2, 3, 2, null, null]);
+ expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
+ expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
+ expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
+ });
+});
diff --git a/libs/api/tracker/application/src/lib/analytics/analytics.ts b/libs/api/tracker/application/src/lib/analytics/analytics.ts
index fb07501..3ff487c 100644
--- a/libs/api/tracker/application/src/lib/analytics/analytics.ts
+++ b/libs/api/tracker/application/src/lib/analytics/analytics.ts
@@ -5,6 +5,8 @@ import {
ProjectAnalyticsReadModel,
ReleaseBurnupReadModel,
ReleaseReadModel,
+ SprintBurndownReadModel,
+ SprintReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
done,
};
}
+
+export interface SprintBurndownInput {
+ sprint: SprintReadModel;
+ issues: IssueReadModel[];
+ timeline: IssueTimelineEntry[];
+ now: Date;
+}
+
+/**
+ * Per day of the sprint (start to end date, UTC): tickets in it, how many
+ * are still open and their points, and the ideal line. Points are today's
+ * estimates (their history is not kept). Days after today, or after the
+ * sprint closed, are null.
+ */
+export function computeSprintBurndown(
+ input: SprintBurndownInput,
+): SprintBurndownReadModel {
+ const { sprint } = input;
+ const steps = stepsByIssue(input.issues, input.timeline);
+ const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
+ const membership = new Map<
+ string,
+ { at: number; sprintId: string | null }[]
+ >();
+ for (const entry of input.timeline) {
+ if (entry.kind !== 'sprint') continue;
+ const list = membership.get(entry.issueId) ?? [];
+ list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
+ membership.set(entry.issueId, list);
+ }
+ for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
+
+ const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
+ const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
+ const last = Math.min(
+ sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
+ end + DAY - 1,
+ );
+ const dayEnds = days(start, end + DAY - 1);
+ const scope: (number | null)[] = [];
+ const remaining: (number | null)[] = [];
+ const remainingPoints: (number | null)[] = [];
+ for (const time of dayEnds) {
+ // The day under way counts up to now; the ones to come are unknown.
+ const at = Math.min(time, last);
+ if (time - DAY >= last) {
+ scope.push(null);
+ remaining.push(null);
+ remainingPoints.push(null);
+ continue;
+ }
+ let inScope = 0;
+ let open = 0;
+ let openPoints = 0;
+ for (const [issueId, list] of membership) {
+ const current = list.filter((m) => m.at <= at).at(-1);
+ if (current?.sprintId !== sprint.id) continue;
+ const step = stepAt(steps.get(issueId) ?? [], at);
+ if (!step || step.category === StatusCategory.CANCELLED) continue;
+ inScope++;
+ if (step.category !== StatusCategory.DONE) {
+ open++;
+ openPoints += points.get(issueId) ?? 0;
+ }
+ }
+ scope.push(inScope);
+ remaining.push(open);
+ remainingPoints.push(openPoints);
+ }
+ const first = remaining[0] ?? 0;
+ const span = Math.max(dayEnds.length - 1, 1);
+ return {
+ sprintId: sprint.id,
+ dates: dayEnds.map(dayOf),
+ scope,
+ remaining,
+ remainingPoints,
+ ideal: dayEnds.map(
+ (_, i) => Math.round((first * (span - i) * 100) / span) / 100,
+ ),
+ };
+}
diff --git a/libs/api/tracker/application/src/lib/changes.service.ts b/libs/api/tracker/application/src/lib/changes.service.ts
index 6e062b0..1e6b995 100644
--- a/libs/api/tracker/application/src/lib/changes.service.ts
+++ b/libs/api/tracker/application/src/lib/changes.service.ts
@@ -8,6 +8,7 @@ import {
IssueChange,
ProjectChange,
ReleaseChange,
+ SprintChange,
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
@@ -52,6 +53,14 @@ export class ChangesService {
return this.changeFeed.releases(projectId);
}
+ async watchSprints(
+ viewerId: string,
+ projectId: string,
+ ): Promise> {
+ await this.assertCanView(viewerId, projectId);
+ return this.changeFeed.sprints(projectId);
+ }
+
async watchProject(
viewerId: string,
projectId: string,
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 c9c6f9e..631ff82 100644
--- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts
+++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts
@@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
import { RankIssueCommandHandler } from './rank-issue.command-handler';
+import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
AddChecklistItemCommandHandler,
@@ -71,6 +72,7 @@ export const COMMAND_HANDLERS = [
WatchIssueCommandHandler,
SetIssueParentCommandHandler,
RankIssueCommandHandler,
+ ...SPRINT_COMMAND_HANDLERS,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler,
diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts
index 2e68bb3..e2858b4 100644
--- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts
+++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts
@@ -31,6 +31,13 @@ import {
InMemoryTrackerStreams,
} from '../../testing/in-memory';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
+import { FindSprintsRepository } from '../ports/find-sprints.repository';
+import { FindIssuesRepository } from '../ports/find-issues.repository';
+import {
+ CreateSprintCommand,
+ SetIssueSprintCommand,
+ StartSprintCommand,
+} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
@@ -63,6 +70,10 @@ import { WriteAccess } from './write-access';
async function setup() {
const store = new InMemoryEventStore();
+ const sprints = {
+ active: null as { id: string; name: string } | null,
+ findActive: async () => sprints.active,
+ };
const files = new InMemoryAttachmentStorage();
const users = new FakeUserDirectory()
.add('owner')
@@ -92,6 +103,20 @@ async function setup() {
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
+ // Read-model lookups: the sprint under way is set by each test.
+ { provide: FindSprintsRepository, useValue: sprints },
+ {
+ provide: FindIssuesRepository,
+ useValue: {
+ sprintProgress: async () => ({
+ total: 0,
+ finished: 0,
+ percent: 0,
+ storyPoints: 0,
+ finishedStoryPoints: 0,
+ }),
+ },
+ },
],
}).compile();
await moduleRef.init();
@@ -103,6 +128,7 @@ async function setup() {
return {
store,
files,
+ sprints,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
};
@@ -405,6 +431,51 @@ describe('tracker commands (event sourced)', () => {
);
});
+ it('plans and starts sprints, one under way at a time, and fills them', async () => {
+ const { run, rehydrator, sprints } = await setup();
+ const projectId = await projectWithTeam(run);
+ const plan = (name: string) =>
+ run(
+ new CreateSprintCommand(
+ 'owner',
+ projectId,
+ name,
+ '2026-10-12',
+ '2026-10-23',
+ ),
+ );
+ const first = await plan('Sprint 1');
+ const second = await plan('Sprint 2');
+ await expect(
+ run(
+ new CreateSprintCommand(
+ 'dev',
+ projectId,
+ 'X',
+ '2026-10-12',
+ '2026-10-23',
+ ),
+ ),
+ ).rejects.toThrow(ForbiddenError);
+
+ await run(new StartSprintCommand('owner', first.id));
+ sprints.active = { id: first.id, name: 'Sprint 1' };
+ await expect(
+ run(new StartSprintCommand('owner', second.id)),
+ ).rejects.toThrow(/Sprint 1 is under way/);
+
+ const issue = await run(
+ new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
+ );
+ await run(new SetIssueSprintCommand('dev', issue.id, first.id));
+ expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
+ first.id,
+ );
+ await expect(
+ run(new SetIssueSprintCommand('pm', issue.id, null)),
+ ).rejects.toThrow(ForbiddenError);
+ });
+
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts
index eeddea1..87aed4c 100644
--- a/libs/api/tracker/application/src/lib/commands/index.ts
+++ b/libs/api/tracker/application/src/lib/commands/index.ts
@@ -33,3 +33,4 @@ export * from './set-issue-parent.command';
export * from './detach-from-parent.command';
export * from './change-checklist.command';
export * from './rank-issue.command';
+export * from './sprint.commands';
diff --git a/libs/api/tracker/application/src/lib/commands/sprint.command-handlers.ts b/libs/api/tracker/application/src/lib/commands/sprint.command-handlers.ts
new file mode 100644
index 0000000..689e838
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/sprint.command-handlers.ts
@@ -0,0 +1,219 @@
+import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
+import {
+ Issue,
+ Project,
+ Sprint,
+ SprintFactory,
+ SprintStatus,
+ StatusCategory,
+} from '@task-stream/api/tracker/domain';
+import { FindIssuesRepository } from '../ports/find-issues.repository';
+import { FindSprintsRepository } from '../ports/find-sprints.repository';
+import { CommandResult, ack } from './command-result';
+import {
+ CarryOverIssueCommand,
+ CloseSprintCommand,
+ CreateSprintCommand,
+ DeleteSprintCommand,
+ SetIssueSprintCommand,
+ StartSprintCommand,
+ UpdateSprintCommand,
+} from './sprint.commands';
+import { WriteAccess } from './write-access';
+
+/* Sprints are planned and run by whoever manages the project; any member puts tickets in them. */
+
+@CommandHandler(CreateSprintCommand)
+export class CreateSprintCommandHandler
+ implements ICommandHandler
+{
+ constructor(
+ private readonly access: WriteAccess,
+ private readonly sprintFactory: SprintFactory,
+ private readonly eventPublisher: EventPublisher,
+ ) {}
+
+ async execute(command: CreateSprintCommand): Promise {
+ const project = await this.access.project(command.projectId);
+ project.assertCanManage(command.actorId);
+ const sprint = this.sprintFactory.create(command.actorId, {
+ projectId: project.id,
+ name: command.name,
+ goal: command.goal,
+ startDate: command.startDate,
+ endDate: command.endDate,
+ });
+ this.eventPublisher.mergeObjectContext(sprint);
+ await sprint.commitAndWait();
+ return ack(sprint);
+ }
+}
+
+/** The sprint, checking the actor manages its project. */
+async function managedSprint(
+ access: WriteAccess,
+ sprintId: string,
+ actorId: string,
+): Promise {
+ const sprint = await access.sprint(sprintId);
+ (await access.project(sprint.projectId)).assertCanManage(actorId);
+ return sprint;
+}
+
+@CommandHandler(UpdateSprintCommand)
+export class UpdateSprintCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: UpdateSprintCommand): Promise {
+ const sprint = await managedSprint(
+ this.access,
+ command.sprintId,
+ command.actorId,
+ );
+ sprint.update(command.actorId, command.changes);
+ await sprint.commitAndWait();
+ return ack(sprint);
+ }
+}
+
+@CommandHandler(StartSprintCommand)
+export class StartSprintCommandHandler
+ implements ICommandHandler
+{
+ constructor(
+ private readonly access: WriteAccess,
+ private readonly findSprints: FindSprintsRepository,
+ ) {}
+
+ async execute(command: StartSprintCommand): Promise {
+ const sprint = await managedSprint(
+ this.access,
+ command.sprintId,
+ command.actorId,
+ );
+ const active = await this.findSprints.findActive(sprint.projectId);
+ sprint.start(
+ command.actorId,
+ active && active.id !== sprint.id ? active : null,
+ );
+ await sprint.commitAndWait();
+ return ack(sprint);
+ }
+}
+
+@CommandHandler(CloseSprintCommand)
+export class CloseSprintCommandHandler
+ implements ICommandHandler
+{
+ constructor(
+ private readonly access: WriteAccess,
+ private readonly findIssues: FindIssuesRepository,
+ ) {}
+
+ async execute(command: CloseSprintCommand): Promise {
+ const sprint = await managedSprint(
+ this.access,
+ command.sprintId,
+ command.actorId,
+ );
+ const target = command.carryOverTo
+ ? await this.access.sprint(command.carryOverTo)
+ : null;
+ const progress = await this.findIssues.sprintProgress(sprint.id);
+ sprint.close(command.actorId, target, progress.total - progress.finished);
+ await sprint.commitAndWait();
+ return ack(sprint);
+ }
+}
+
+@CommandHandler(DeleteSprintCommand)
+export class DeleteSprintCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: DeleteSprintCommand): Promise {
+ if (command.cascade) {
+ // Saga: idempotent, the sprint may be gone already.
+ const sprint = await this.access.live(command.sprintId, Sprint);
+ if (!sprint) return;
+ sprint.delete(command.actorId, true);
+ await sprint.commitAndWait();
+ return;
+ }
+ const sprint = await managedSprint(
+ this.access,
+ command.sprintId,
+ command.actorId,
+ );
+ sprint.delete(command.actorId);
+ await sprint.commitAndWait();
+ return ack(sprint);
+ }
+}
+
+@CommandHandler(SetIssueSprintCommand)
+export class SetIssueSprintCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: SetIssueSprintCommand): Promise {
+ const { issue, project } = await this.access.issueForContributor(
+ command.issueId,
+ command.actorId,
+ );
+ // A closed sprint keeps its tickets: neither side may be closed.
+ if (issue.sprintId) await this.access.openSprintOf(project, issue.sprintId);
+ if (command.sprintId)
+ await this.access.openSprintOf(project, command.sprintId);
+ issue.setSprint(command.actorId, command.sprintId);
+ await issue.commitAndWait();
+ return ack(issue);
+ }
+}
+
+const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
+
+@CommandHandler(CarryOverIssueCommand)
+export class CarryOverIssueCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: CarryOverIssueCommand): Promise {
+ const issue = await this.access.live(command.issueId, Issue);
+ // Gone, or it left that sprint meanwhile: nothing to do.
+ if (!issue || issue.sprintId !== command.fromSprintId) return;
+ const project = await this.access.live(issue.projectId, Project);
+ if (!project) return;
+ if (
+ command.onlyUnfinished &&
+ FINISHED.includes(project.workflow.categoryOf(issue.status))
+ )
+ return;
+ if (command.toSprintId) {
+ const target = await this.access.live(command.toSprintId, Sprint);
+ // The target closed or vanished meanwhile: back to the backlog instead.
+ const open =
+ target && !target.deleted && target.status !== SprintStatus.CLOSED;
+ issue.setSprint(command.actorId, open ? command.toSprintId : null);
+ } else {
+ issue.setSprint(command.actorId, null);
+ }
+ await issue.commitAndWait();
+ }
+}
+
+export const SPRINT_COMMAND_HANDLERS = [
+ CreateSprintCommandHandler,
+ UpdateSprintCommandHandler,
+ StartSprintCommandHandler,
+ CloseSprintCommandHandler,
+ DeleteSprintCommandHandler,
+ SetIssueSprintCommandHandler,
+ // Saga only.
+ CarryOverIssueCommandHandler,
+];
diff --git a/libs/api/tracker/application/src/lib/commands/sprint.commands.ts b/libs/api/tracker/application/src/lib/commands/sprint.commands.ts
new file mode 100644
index 0000000..e558cfd
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/sprint.commands.ts
@@ -0,0 +1,85 @@
+import { Command } from '@nestjs/cqrs';
+import { SprintDetails } from '@task-stream/api/tracker/domain';
+import { CommandResult } from './command-result';
+
+export class CreateSprintCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly projectId: string,
+ public readonly name: string,
+ public readonly startDate: string,
+ public readonly endDate: string,
+ public readonly goal: string | null = null,
+ ) {
+ super();
+ }
+}
+
+export class UpdateSprintCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly sprintId: string,
+ public readonly changes: SprintDetails,
+ ) {
+ super();
+ }
+}
+
+export class StartSprintCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly sprintId: string,
+ ) {
+ super();
+ }
+}
+
+/** Ends a sprint; unfinished tickets go to `carryOverTo` (null: backlog). */
+export class CloseSprintCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly sprintId: string,
+ public readonly carryOverTo: string | null,
+ ) {
+ super();
+ }
+}
+
+/** A planned sprint (people), or any sprint of a deleted project (`cascade`, saga). */
+export class DeleteSprintCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly sprintId: string,
+ public readonly cascade = false,
+ ) {
+ super();
+ }
+}
+
+/** Puts a ticket in a sprint (null: back to the backlog). */
+export class SetIssueSprintCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly issueId: string,
+ public readonly sprintId: string | null,
+ ) {
+ super();
+ }
+}
+
+/**
+ * Saga only: a sprint closed (or was deleted), so a ticket of it moves to
+ * `toSprintId` (null: backlog). With `onlyUnfinished`, finished ones stay as
+ * the record of what the sprint achieved.
+ */
+export class CarryOverIssueCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly issueId: string,
+ public readonly fromSprintId: string,
+ public readonly toSprintId: string | null,
+ public readonly onlyUnfinished: boolean,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/write-access.ts b/libs/api/tracker/application/src/lib/commands/write-access.ts
index 4d8668e..21a9d56 100644
--- a/libs/api/tracker/application/src/lib/commands/write-access.ts
+++ b/libs/api/tracker/application/src/lib/commands/write-access.ts
@@ -10,6 +10,7 @@ import {
Issue,
Project,
Release,
+ Sprint,
} from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
@@ -38,6 +39,22 @@ export class WriteAccess {
return this.load(id, Release);
}
+ sprint(id: string): Promise {
+ return this.load(id, Sprint);
+ }
+
+ /** Sprint of the same project that still takes tickets (not closed). */
+ async openSprintOf(project: Project, sprintId: string): Promise {
+ const sprint = await this.sprint(sprintId);
+ if (sprint.projectId !== project.id) {
+ throw new BusinessRuleError(
+ `Sprint ${sprint.name} belongs to another project`,
+ );
+ }
+ sprint.assertAcceptsIssues();
+ return sprint;
+ }
+
comment(id: string): Promise {
return this.load(id, Comment);
}
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 534ae9b..2d9af91 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
@@ -28,6 +28,12 @@ import {
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
+ IssueSprintChangedEvent,
+ SprintClosedEvent,
+ SprintCreatedEvent,
+ SprintDeletedEvent,
+ SprintStartedEvent,
+ SprintUpdatedEvent,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
@@ -37,6 +43,8 @@ export interface SummaryContext {
issueKey: string;
projectKey: string;
releaseName: string;
+ /** '' when unknown (e.g. the sprint was deleted). */
+ sprintName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
}
@@ -146,6 +154,12 @@ export function describeEvent(
if (event instanceof IssueAttachmentRemovedEvent)
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
+ if (event instanceof IssueSprintChangedEvent) {
+ const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
+ return event.sprintId
+ ? `${actor} metió ${issueKey} en ${sprint}`
+ : `${actor} sacó ${issueKey} de ${sprint}`;
+ }
if (event instanceof IssueParentChangedEvent) {
return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
@@ -178,5 +192,20 @@ export function describeEvent(
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`;
+ const sprint = ctx.sprintName || 'un sprint';
+ if (event instanceof SprintCreatedEvent)
+ return `${actor} planificó el sprint ${event.sprint.name}`;
+ if (event instanceof SprintUpdatedEvent)
+ return `${actor} actualizó el sprint ${sprint}`;
+ if (event instanceof SprintStartedEvent)
+ return `${actor} empezó el sprint ${sprint} 🏁`;
+ if (event instanceof SprintClosedEvent) {
+ const left = event.unfinishedIssues
+ ? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}`
+ : '';
+ return `${actor} cerró el sprint ${sprint}${left}`;
+ }
+ if (event instanceof SprintDeletedEvent)
+ return `${actor} borró el sprint ${sprint}`;
return `${actor}: ${event.constructor.name}`;
}
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 4114fd0..a9a9b6c 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
@@ -23,6 +23,12 @@ import {
ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent,
ReleaseArchivedEvent,
+ IssueSprintChangedEvent,
+ SprintClosedEvent,
+ SprintCreatedEvent,
+ SprintDeletedEvent,
+ SprintStartedEvent,
+ SprintUpdatedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
@@ -37,6 +43,7 @@ import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
+import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
import { UserDirectory } from '../ports/user-directory';
import { describeEvent } from './activity-summary';
@@ -70,6 +77,12 @@ import { versionOf } from './read-model-notifier';
ReleaseUpdatedEvent,
ReleaseShippedEvent,
ReleaseArchivedEvent,
+ IssueSprintChangedEvent,
+ SprintCreatedEvent,
+ SprintUpdatedEvent,
+ SprintStartedEvent,
+ SprintClosedEvent,
+ SprintDeletedEvent,
)
export class ActivityEventHandler implements IEventHandler {
constructor(
@@ -77,6 +90,7 @@ export class ActivityEventHandler implements IEventHandler {
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findReleases: FindReleasesRepository,
+ private readonly findSprints: FindSprintsRepository,
private readonly users: UserDirectory,
private readonly changeFeed: ChangeFeed,
) {}
@@ -93,6 +107,7 @@ export class ActivityEventHandler implements IEventHandler {
const release = releaseId
? await this.findReleases.findById(releaseId)
: null;
+ const sprint = await this.sprintOf(event);
const project = await this.findProjects.findById(event.projectId);
// The project was deleted (cascade in progress): no feed to write to.
if (!project) return;
@@ -118,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler {
issueKey: issueKey ?? '',
projectKey: project?.key ?? '',
releaseName: release?.name ?? '',
+ sprintName: sprint,
workflow: new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
),
@@ -129,6 +145,17 @@ export class ActivityEventHandler implements IEventHandler {
}
}
+ /** Name of the sprint the event is about ('' when it is gone). */
+ private async sprintOf(event: TrackerEvent): Promise {
+ if (event instanceof SprintCreatedEvent) return event.sprint.name;
+ const id = event.constructor.name.startsWith('Sprint')
+ ? event.aggregateId
+ : event instanceof IssueSprintChangedEvent
+ ? (event.sprintId ?? event.previousSprintId)
+ : null;
+ return id ? ((await this.findSprints.findById(id))?.name ?? '') : '';
+ }
+
private issueIdOf(event: TrackerEvent): string | null {
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
if (event instanceof CommentAddedEvent) return event.comment.issueId;
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 d86ea89..3959aad 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
@@ -31,6 +31,8 @@ import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
+import { SprintEventHandler } from './sprint.event-handler';
+import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -66,6 +68,8 @@ export const EVENT_HANDLERS = [
IssueParentChangedEventHandler,
IssueChecklistEventHandler,
IssueRankedEventHandler,
+ SprintEventHandler,
+ IssueSprintChangedEventHandler,
];
export { ReadModelNotifier };
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 f41d4fb..8f4b73b 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
@@ -50,6 +50,7 @@ export class IssueCreatedEventHandler
assignee: users.ofNullable(issue.assigneeId),
release: release ? { id: release.id, name: release.name } : null,
parent: null,
+ sprint: null,
rank: implicitRank(issue.number),
labels: issue.labels,
figmaUrl: issue.figmaUrl,
diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-sprint-changed.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-sprint-changed.event-handler.ts
new file mode 100644
index 0000000..7a9511e
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/event-handlers/issue-sprint-changed.event-handler.ts
@@ -0,0 +1,31 @@
+import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
+import { IssueSprintChangedEvent } from '@task-stream/api/tracker/domain';
+import { FindSprintsRepository } from '../ports/find-sprints.repository';
+import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
+import { ReadModelNotifier, versionOf } from './read-model-notifier';
+
+@EventsHandler(IssueSprintChangedEvent)
+export class IssueSprintChangedEventHandler
+ implements IEventHandler
+{
+ constructor(
+ private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
+ private readonly findSprints: FindSprintsRepository,
+ private readonly notifier: ReadModelNotifier,
+ ) {}
+
+ async handle(event: IssueSprintChangedEvent) {
+ const sprint = event.sprintId
+ ? await this.findSprints.findById(event.sprintId)
+ : null;
+ await this.upsertMaterializedIssueRepository.upsert({
+ id: event.aggregateId,
+ sprint: event.sprintId
+ ? { id: event.sprintId, name: sprint?.name ?? '' }
+ : null,
+ updatedAt: event.occurredAt,
+ version: versionOf(event),
+ });
+ await this.notifier.issue(event.aggregateId, event);
+ }
+}
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
index ebec13c..a6fad64 100644
--- 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
@@ -3,6 +3,7 @@ import {
DEFAULT_PROJECT_SETTINGS,
IssueCreatedEvent,
IssueReleaseChangedEvent,
+ IssueSprintChangedEvent,
IssueStatusChangedEvent,
ProjectWorkflow,
TrackerEvent,
@@ -14,12 +15,13 @@ 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.
+ * and which release and sprint it joined. Statistics are computed from it.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueReleaseChangedEvent,
+ IssueSprintChangedEvent,
)
export class IssueTimelineEventHandler implements IEventHandler {
constructor(
@@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler {
kind: 'release',
releaseId: event.releaseId,
});
+ return;
+ }
+ if (event instanceof IssueSprintChangedEvent) {
+ await this.timeline.record({
+ ...base,
+ kind: 'sprint',
+ sprintId: event.sprintId,
+ });
}
}
diff --git a/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts b/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts
index 55f2f7e..de2532f 100644
--- a/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts
+++ b/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts
@@ -5,6 +5,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
+import { FindSprintsRepository } from '../ports/find-sprints.repository';
/** Position of the event in its stream (stamped by the event store). */
export function versionOf(event: TrackerEvent): number {
@@ -23,6 +24,7 @@ export class ReadModelNotifier {
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly findReleases: FindReleasesRepository,
+ private readonly findSprints: FindSprintsRepository,
) {}
async project(
@@ -63,6 +65,16 @@ export class ReadModelNotifier {
await this.changeFeed.publishRelease({ ...this.meta(event), release });
}
+ async sprint(sprintId: string, event: TrackerEvent): Promise {
+ const sprint = await this.findSprints.findById(sprintId);
+ await this.changeFeed.publishSprint({
+ ...this.meta(event),
+ sprintId,
+ projectId: event.projectId,
+ sprint,
+ });
+ }
+
private meta(event: TrackerEvent) {
return {
action: event.constructor.name,
diff --git a/libs/api/tracker/application/src/lib/event-handlers/sprint.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/sprint.event-handler.ts
new file mode 100644
index 0000000..0b9f276
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/event-handlers/sprint.event-handler.ts
@@ -0,0 +1,65 @@
+import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
+import {
+ SprintClosedEvent,
+ SprintCreatedEvent,
+ SprintDeletedEvent,
+ SprintStartedEvent,
+ SprintStatus,
+ SprintUpdatedEvent,
+} from '@task-stream/api/tracker/domain';
+import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
+import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository';
+import { ReadModelNotifier, versionOf } from './read-model-notifier';
+
+type SprintEvent =
+ | SprintCreatedEvent
+ | SprintUpdatedEvent
+ | SprintStartedEvent
+ | SprintClosedEvent
+ | SprintDeletedEvent;
+
+/** The sprints view (`sprints_view`), one document per sprint. */
+@EventsHandler(
+ SprintCreatedEvent,
+ SprintUpdatedEvent,
+ SprintStartedEvent,
+ SprintClosedEvent,
+ SprintDeletedEvent,
+)
+export class SprintEventHandler implements IEventHandler {
+ constructor(
+ private readonly sprints: UpsertMaterializedSprintRepository,
+ private readonly issues: UpsertMaterializedIssueRepository,
+ private readonly notifier: ReadModelNotifier,
+ ) {}
+
+ async handle(event: SprintEvent) {
+ const version = versionOf(event);
+ const id = event.aggregateId;
+ if (event instanceof SprintCreatedEvent) {
+ await this.sprints.upsert({ ...event.sprint, version });
+ } else if (event instanceof SprintUpdatedEvent) {
+ await this.sprints.upsert({ id, ...event.changes, version });
+ // Tickets show its name.
+ if (event.changes.name)
+ await this.issues.renameSprint(id, event.changes.name);
+ } else if (event instanceof SprintStartedEvent) {
+ await this.sprints.upsert({
+ id,
+ status: SprintStatus.ACTIVE,
+ startedAt: event.occurredAt,
+ version,
+ });
+ } else if (event instanceof SprintClosedEvent) {
+ await this.sprints.upsert({
+ id,
+ status: SprintStatus.CLOSED,
+ closedAt: event.occurredAt,
+ version,
+ });
+ } else {
+ await this.sprints.remove(id);
+ }
+ await this.notifier.sprint(id, event);
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/ports/change-feed.ts b/libs/api/tracker/application/src/lib/ports/change-feed.ts
index 12523d9..de66e49 100644
--- a/libs/api/tracker/application/src/lib/ports/change-feed.ts
+++ b/libs/api/tracker/application/src/lib/ports/change-feed.ts
@@ -5,6 +5,7 @@ import {
IssueReadModel,
ProjectReadModel,
ReleaseReadModel,
+ SprintReadModel,
} from '@task-stream/api/tracker/domain';
/** Every realtime message says which event caused it and the resulting version. */
@@ -30,6 +31,13 @@ export interface ReleaseChange extends Change {
release: ReleaseReadModel;
}
+export interface SprintChange extends Change {
+ /** Null when the sprint was deleted. */
+ sprint: SprintReadModel | null;
+ sprintId: string;
+ projectId: string;
+}
+
export interface ProjectChange extends Change {
project: ProjectReadModel;
}
@@ -43,6 +51,7 @@ export abstract class ChangeFeed {
abstract publishComment(change: CommentChange): Promise;
abstract publishActivity(activity: ActivityReadModel): Promise;
abstract publishRelease(change: ReleaseChange): Promise;
+ abstract publishSprint(change: SprintChange): Promise;
/** @param notifyUserIds users whose project list changed (joined / left). */
abstract publishProject(
change: ProjectChange,
@@ -59,6 +68,7 @@ export abstract class ChangeFeed {
projectId: string,
): AsyncIterableIterator;
abstract releases(projectId: string): AsyncIterableIterator;
+ abstract sprints(projectId: string): AsyncIterableIterator;
abstract project(projectId: string): AsyncIterableIterator;
abstract userProjects(userId: string): AsyncIterableIterator;
abstract notifications(
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 e2f4ad3..b38128a 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
@@ -4,6 +4,7 @@ import {
IssueReadModel,
ProjectStatsReadModel,
ReleaseProgressReadModel,
+ SprintProgressReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -26,6 +27,9 @@ export interface IssueFilter {
labels?: string[];
/** Only the tickets hanging from this one. */
parentId?: string;
+ sprintId?: string;
+ /** Only tickets in no sprint (wins over `sprintId`). */
+ noSprint?: boolean;
/** RECENT (default): last updated first. RANK: backlog order. */
sort?: IssueSort;
search?: string;
@@ -63,6 +67,7 @@ export abstract class FindIssuesRepository {
abstract findByProject(projectId: string): Promise;
/** Top-level tickets with others hanging from them, by key. */
abstract epics(projectId: string): Promise;
+ abstract sprintProgress(sprintId: string): Promise;
abstract releaseProgress(
releaseId: string,
): Promise;
diff --git a/libs/api/tracker/application/src/lib/ports/find-sprints.repository.ts b/libs/api/tracker/application/src/lib/ports/find-sprints.repository.ts
new file mode 100644
index 0000000..5482ad7
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/ports/find-sprints.repository.ts
@@ -0,0 +1,9 @@
+import { SprintReadModel } from '@task-stream/api/tracker/domain';
+
+export abstract class FindSprintsRepository {
+ abstract findById(id: string): Promise;
+ /** Active first, then planned by start date, then closed (newest first). */
+ abstract findByProject(projectId: string): Promise;
+ /** The sprint under way in the project, if any. */
+ abstract findActive(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 a5e2517..57ccfe6 100644
--- a/libs/api/tracker/application/src/lib/ports/index.ts
+++ b/libs/api/tracker/application/src/lib/ports/index.ts
@@ -21,3 +21,5 @@ export * from './attachment-storage';
export * from './notifications.repository';
export * from './notification-mailer';
export * from './saved-filters.repository';
+export * from './find-sprints.repository';
+export * from './upsert-materialized-sprint.repository';
diff --git a/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts b/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts
index 37df65b..14d7065 100644
--- a/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts
+++ b/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts
@@ -7,6 +7,9 @@
export abstract class TrackerStreamsRepository {
abstract issueIdsOfProject(projectId: string): Promise;
abstract releaseIdsOfProject(projectId: string): Promise;
+ abstract sprintIdsOfProject(projectId: string): Promise;
+ /** Tickets that were ever put in `sprintId` (they may have left since). */
+ abstract issueIdsEverInSprint(sprintId: string): Promise;
abstract commentIdsOfIssue(issueId: string): Promise;
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
abstract issueIdsLinkingTo(issueId: string): Promise;
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 16c1f51..bdee5d7 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
@@ -60,6 +60,8 @@ export abstract class UpsertMaterializedIssueRepository {
): Promise;
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
abstract remove(issueId: string): Promise;
+ /** Keeps the denormalised sprint name of every issue in sync. */
+ abstract renameSprint(sprintId: string, name: string): 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-sprint.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-sprint.repository.ts
new file mode 100644
index 0000000..21f5860
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-sprint.repository.ts
@@ -0,0 +1,9 @@
+import { SprintReadModel } from '@task-stream/api/tracker/domain';
+
+export abstract class UpsertMaterializedSprintRepository {
+ /** Applied only if `version` is newer than the stored one (idempotent). */
+ abstract upsert(
+ sprint: Pick & Partial,
+ ): Promise;
+ abstract remove(sprintId: string): Promise;
+}
diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts
index 551d24c..2e151ee 100644
--- a/libs/api/tracker/application/src/lib/queries/index.ts
+++ b/libs/api/tracker/application/src/lib/queries/index.ts
@@ -18,3 +18,4 @@ export * from './search-my-issues.query';
export * from './get-public-projects.query';
export * from './get-attachment.query';
export * from './get-epics.query';
+export * from './sprint.queries';
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 af2a621..f2e60ea 100644
--- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts
+++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts
@@ -17,6 +17,7 @@ import { GetReleasesQueryHandler } from './get-releases.query-handler';
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { GetEpicsQueryHandler } from './get-epics.query-handler';
+import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
GetUpcomingReleasesQueryHandler,
SearchIssuesQueryHandler,
GetEpicsQueryHandler,
+ ...SPRINT_QUERY_HANDLERS,
SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler,
];
diff --git a/libs/api/tracker/application/src/lib/queries/sprint.queries.ts b/libs/api/tracker/application/src/lib/queries/sprint.queries.ts
new file mode 100644
index 0000000..e0e8f2f
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/queries/sprint.queries.ts
@@ -0,0 +1,159 @@
+import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
+import {
+ SprintBurndownReadModel,
+ SprintProgressReadModel,
+ SprintReadModel,
+} from '@task-stream/api/tracker/domain';
+import { computeSprintBurndown } from '../analytics/analytics';
+import { FindIssuesRepository } from '../ports/find-issues.repository';
+import { FindSprintsRepository } from '../ports/find-sprints.repository';
+import { FindTimelineRepository } from '../ports/find-timeline.repository';
+import { ReadAccess } from './read-access';
+
+export class GetSprintsQuery extends Query {
+ constructor(
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
+ public readonly projectId: string,
+ ) {
+ super();
+ }
+}
+
+export class GetSprintQuery extends Query {
+ constructor(
+ public readonly viewerId: string | null,
+ public readonly sprintId: string,
+ ) {
+ super();
+ }
+}
+
+export class GetSprintProgressQuery extends Query {
+ constructor(
+ public readonly viewerId: string | null,
+ public readonly sprintId: string,
+ ) {
+ super();
+ }
+}
+
+export class GetSprintBurndownQuery extends Query {
+ constructor(
+ public readonly viewerId: string | null,
+ public readonly sprintId: string,
+ ) {
+ super();
+ }
+}
+
+/** The sprint, if the viewer may see its project (same error otherwise). */
+async function visibleSprint(
+ findSprints: FindSprintsRepository,
+ access: ReadAccess,
+ viewerId: string | null,
+ sprintId: string,
+): Promise {
+ const sprint = access.assertFound(
+ await findSprints.findById(sprintId),
+ 'Sprint',
+ sprintId,
+ );
+ await access.assertCanView(sprint.projectId, viewerId, {
+ entity: 'Sprint',
+ ref: sprintId,
+ });
+ return sprint;
+}
+
+@QueryHandler(GetSprintsQuery)
+export class GetSprintsQueryHandler
+ implements IQueryHandler
+{
+ constructor(
+ private readonly findSprints: FindSprintsRepository,
+ private readonly access: ReadAccess,
+ ) {}
+
+ async execute(query: GetSprintsQuery): Promise {
+ await this.access.assertCanView(query.projectId, query.viewerId);
+ return this.findSprints.findByProject(query.projectId);
+ }
+}
+
+@QueryHandler(GetSprintQuery)
+export class GetSprintQueryHandler
+ implements IQueryHandler
+{
+ constructor(
+ private readonly findSprints: FindSprintsRepository,
+ private readonly access: ReadAccess,
+ ) {}
+
+ execute(query: GetSprintQuery): Promise {
+ return visibleSprint(
+ this.findSprints,
+ this.access,
+ query.viewerId,
+ query.sprintId,
+ );
+ }
+}
+
+@QueryHandler(GetSprintProgressQuery)
+export class GetSprintProgressQueryHandler
+ implements IQueryHandler
+{
+ constructor(
+ private readonly findSprints: FindSprintsRepository,
+ private readonly findIssues: FindIssuesRepository,
+ private readonly access: ReadAccess,
+ ) {}
+
+ async execute(
+ query: GetSprintProgressQuery,
+ ): Promise {
+ const sprint = await visibleSprint(
+ this.findSprints,
+ this.access,
+ query.viewerId,
+ query.sprintId,
+ );
+ return this.findIssues.sprintProgress(sprint.id);
+ }
+}
+
+@QueryHandler(GetSprintBurndownQuery)
+export class GetSprintBurndownQueryHandler
+ implements IQueryHandler
+{
+ constructor(
+ private readonly findSprints: FindSprintsRepository,
+ private readonly findIssues: FindIssuesRepository,
+ private readonly findTimeline: FindTimelineRepository,
+ private readonly access: ReadAccess,
+ ) {}
+
+ async execute(
+ query: GetSprintBurndownQuery,
+ ): Promise {
+ const sprint = await visibleSprint(
+ this.findSprints,
+ this.access,
+ query.viewerId,
+ query.sprintId,
+ );
+ const [issues, timeline] = await Promise.all([
+ this.findIssues.findByProject(sprint.projectId),
+ this.findTimeline.findByProject(sprint.projectId),
+ ]);
+ return computeSprintBurndown({ sprint, issues, timeline, now: new Date() });
+ }
+}
+
+export const SPRINT_QUERY_HANDLERS = [
+ GetSprintsQueryHandler,
+ GetSprintQueryHandler,
+ GetSprintProgressQueryHandler,
+ GetSprintBurndownQueryHandler,
+];
diff --git a/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts b/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts
index 04c7d6a..c4d9b3a 100644
--- a/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts
+++ b/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts
@@ -9,6 +9,7 @@ import { DeleteIssueCommand } from '../commands/delete-issue.command';
import { DeleteReleaseCommand } from '../commands/delete-release.command';
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
+import { DeleteSprintCommand } from '../commands/sprint.commands';
import { PurgeCommentCommand } from '../commands/purge-comment.command';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
@@ -17,6 +18,7 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
* with its own "deleted" event):
*
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
+ * + DeleteSprint (each sprint, as a cascade)
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
* of other projects that pointed to it) + DetachFromParent
* (tickets that hung from it go back to the top)
@@ -34,11 +36,14 @@ export class ProjectDeletionSaga {
ofType(ProjectDeletedEvent),
mergeMap((event) =>
from(this.contentsOf(event.projectId)).pipe(
- mergeMap(({ issueIds, releaseIds }) => [
+ mergeMap(({ issueIds, releaseIds, sprintIds }) => [
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
...releaseIds.map(
(id) => new DeleteReleaseCommand(event.actorId, id),
),
+ ...sprintIds.map(
+ (id) => new DeleteSprintCommand(event.actorId, id, true),
+ ),
]),
),
),
@@ -76,11 +81,12 @@ export class ProjectDeletionSaga {
);
private async contentsOf(projectId: string) {
- const [issueIds, releaseIds] = await Promise.all([
+ const [issueIds, releaseIds, sprintIds] = await Promise.all([
this.streams.issueIdsOfProject(projectId),
this.streams.releaseIdsOfProject(projectId),
+ this.streams.sprintIdsOfProject(projectId),
]);
- return { issueIds, releaseIds };
+ return { issueIds, releaseIds, sprintIds };
}
private async dependentsOf(issueId: string) {
diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
index 4b21e69..157123d 100644
--- a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
+++ b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
@@ -47,6 +47,15 @@ import { LinkIssuesCommand } from '../commands/link-issues.command';
import { AssignIssueCommand } from '../commands/assign-issue.command';
import { WriteAccess } from '../commands/write-access';
import { IssueTree } from '../commands/issue-tree';
+import {
+ CloseSprintCommand,
+ CreateSprintCommand,
+ DeleteSprintCommand,
+ SetIssueSprintCommand,
+ StartSprintCommand,
+} from '../commands/sprint.commands';
+import { FindSprintsRepository } from '../ports/find-sprints.repository';
+import { SprintCarryOverSaga } from './sprint-carry-over.saga';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
@@ -73,6 +82,7 @@ async function setup() {
ProjectDeletionSaga,
ReleaseArchivedSaga,
UserDeactivatedSaga,
+ SprintCarryOverSaga,
WriteAccess,
IssueTree,
AggregateRehydrator,
@@ -90,11 +100,22 @@ async function setup() {
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
+ {
+ provide: FindSprintsRepository,
+ useValue: { findActive: async () => null },
+ },
{
provide: FindIssuesRepository,
useValue: {
findOpenAssignedTo: async (userId: string) =>
(openAssigned.get(userId) ?? []).map((id) => ({ id })),
+ sprintProgress: async () => ({
+ total: 2,
+ finished: 1,
+ percent: 50,
+ storyPoints: 0,
+ finishedStoryPoints: 0,
+ }),
},
},
],
@@ -109,6 +130,7 @@ async function setup() {
...Object.values(moduleRef.get(ProjectDeletionSaga)),
...Object.values(moduleRef.get(ReleaseArchivedSaga)),
...Object.values(moduleRef.get(UserDeactivatedSaga)),
+ ...Object.values(moduleRef.get(SprintCarryOverSaga)),
].filter((value): value is ISaga => typeof value === 'function');
/** What the events bridge does: every stored event through every saga, until quiet. */
@@ -206,6 +228,41 @@ describe('sagas', () => {
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
});
+ it('closing a sprint carries its unfinished tickets over; deleting one empties it', async () => {
+ const { run, drain, rehydrator } = await setup();
+ const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
+ const sprint = async (name: string) =>
+ (
+ await run(
+ new CreateSprintCommand(
+ 'owner',
+ web,
+ name,
+ '2026-10-12',
+ '2026-10-23',
+ ),
+ )
+ ).id;
+ const [one, two] = [await sprint('One'), await sprint('Two')];
+ const open = (await run(ticket(web, 'open'))).id;
+ const done = (await run(ticket(web, 'done'))).id;
+ for (const id of [open, done])
+ await run(new SetIssueSprintCommand('owner', id, one));
+ for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
+ await run(new ChangeIssueStatusCommand('owner', done, status));
+
+ await run(new StartSprintCommand('owner', one));
+ await run(new CloseSprintCommand('owner', one, two));
+ await drain();
+
+ expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBe(two);
+ expect((await rehydrator.rehydrate(done, Issue)).sprintId).toBe(one);
+
+ await run(new DeleteSprintCommand('owner', two));
+ await drain();
+ expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBeNull();
+ });
+
it('only the owner deletes a project', async () => {
const { run } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.ts b/libs/api/tracker/application/src/lib/sagas/sagas.ts
index ac4a1ce..f213700 100644
--- a/libs/api/tracker/application/src/lib/sagas/sagas.ts
+++ b/libs/api/tracker/application/src/lib/sagas/sagas.ts
@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
import { ReleaseArchivedSaga } from './release-archived.saga';
import { UserDeactivatedSaga } from './user-deactivated.saga';
import { CommentersFollowSaga } from './commenters-follow.saga';
+import { SprintCarryOverSaga } from './sprint-carry-over.saga';
/** Reactions to events that change other aggregates (run by the events bridge). */
export const SAGAS = [
@@ -9,4 +10,5 @@ export const SAGAS = [
ReleaseArchivedSaga,
UserDeactivatedSaga,
CommentersFollowSaga,
+ SprintCarryOverSaga,
];
diff --git a/libs/api/tracker/application/src/lib/sagas/sprint-carry-over.saga.ts b/libs/api/tracker/application/src/lib/sagas/sprint-carry-over.saga.ts
new file mode 100644
index 0000000..3a7bb3d
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sagas/sprint-carry-over.saga.ts
@@ -0,0 +1,46 @@
+import { Injectable } from '@nestjs/common';
+import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
+import {
+ SprintClosedEvent,
+ SprintDeletedEvent,
+} from '@task-stream/api/tracker/domain';
+import { from, mergeMap, Observable } from 'rxjs';
+import { CarryOverIssueCommand } from '../commands/sprint.commands';
+import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
+
+/**
+ * When a sprint ends, what was left moves on:
+ *
+ * SprintClosed → CarryOverIssue for its unfinished tickets, to the sprint
+ * chosen when closing it (or back to the backlog)
+ * SprintDeleted → CarryOverIssue for all its tickets, back to the backlog
+ *
+ * Who was in the sprint comes from the event store. The moves are
+ * attributed to whoever closed or deleted the sprint.
+ */
+@Injectable()
+export class SprintCarryOverSaga {
+ constructor(private readonly streams: TrackerStreamsRepository) {}
+
+ @Saga()
+ sprintEnded = (events$: Observable): Observable =>
+ events$.pipe(
+ ofType(SprintClosedEvent, SprintDeletedEvent),
+ mergeMap((event) =>
+ from(this.streams.issueIdsEverInSprint(event.aggregateId)).pipe(
+ mergeMap((issueIds) =>
+ issueIds.map(
+ (issueId) =>
+ new CarryOverIssueCommand(
+ event.actorId,
+ issueId,
+ event.aggregateId,
+ event instanceof SprintClosedEvent ? event.carryOverTo : null,
+ event instanceof SprintClosedEvent,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/libs/api/tracker/application/src/lib/sprints.service.ts b/libs/api/tracker/application/src/lib/sprints.service.ts
new file mode 100644
index 0000000..6391985
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sprints.service.ts
@@ -0,0 +1,122 @@
+import { Injectable } from '@nestjs/common';
+import { CommandBus, QueryBus } from '@nestjs/cqrs';
+import {
+ SprintBurndownReadModel,
+ SprintDetails,
+ SprintProgressReadModel,
+ SprintReadModel,
+} from '@task-stream/api/tracker/domain';
+import { CommandResult } from './commands/command-result';
+import {
+ CloseSprintCommand,
+ CreateSprintCommand,
+ DeleteSprintCommand,
+ SetIssueSprintCommand,
+ StartSprintCommand,
+ UpdateSprintCommand,
+} from './commands/sprint.commands';
+import {
+ GetSprintBurndownQuery,
+ GetSprintProgressQuery,
+ GetSprintQuery,
+ GetSprintsQuery,
+} from './queries/sprint.queries';
+
+export interface CreateSprintInput {
+ projectId: string;
+ name: string;
+ goal?: string | null;
+ startDate: string;
+ endDate: string;
+}
+
+/** Sprints: plan them, run them, close them (and what was left moves on). */
+@Injectable()
+export class SprintsService {
+ constructor(
+ private readonly commandBus: CommandBus,
+ private readonly queryBus: QueryBus,
+ ) {}
+
+ list(viewerId: string | null, projectId: string): Promise {
+ return this.queryBus.execute(new GetSprintsQuery(viewerId, projectId));
+ }
+
+ findOne(viewerId: string | null, sprintId: string): Promise {
+ return this.queryBus.execute(new GetSprintQuery(viewerId, sprintId));
+ }
+
+ progress(
+ viewerId: string | null,
+ sprintId: string,
+ ): Promise {
+ return this.queryBus.execute(
+ new GetSprintProgressQuery(viewerId, sprintId),
+ );
+ }
+
+ burndown(
+ viewerId: string | null,
+ sprintId: string,
+ ): Promise {
+ return this.queryBus.execute(
+ new GetSprintBurndownQuery(viewerId, sprintId),
+ );
+ }
+
+ create(actorId: string, input: CreateSprintInput): Promise {
+ return this.commandBus.execute(
+ new CreateSprintCommand(
+ actorId,
+ input.projectId,
+ input.name,
+ input.startDate,
+ input.endDate,
+ input.goal ?? null,
+ ),
+ );
+ }
+
+ update(
+ actorId: string,
+ sprintId: string,
+ changes: SprintDetails,
+ ): Promise {
+ return this.commandBus.execute(
+ new UpdateSprintCommand(actorId, sprintId, changes),
+ );
+ }
+
+ start(actorId: string, sprintId: string): Promise {
+ return this.commandBus.execute(new StartSprintCommand(actorId, sprintId));
+ }
+
+ /** Unfinished tickets go to `carryOverTo` (null: the backlog). */
+ close(
+ actorId: string,
+ sprintId: string,
+ carryOverTo: string | null,
+ ): Promise {
+ return this.commandBus.execute(
+ new CloseSprintCommand(actorId, sprintId, carryOverTo),
+ );
+ }
+
+ /** Planned sprints only; its tickets go back to the backlog. */
+ delete(actorId: string, sprintId: string): Promise {
+ return this.commandBus.execute(
+ new DeleteSprintCommand(actorId, sprintId),
+ ) as Promise;
+ }
+
+ /** Puts a ticket in a sprint (null: back to the backlog). */
+ setIssueSprint(
+ actorId: string,
+ issueId: string,
+ sprintId: string | null,
+ ): Promise {
+ return this.commandBus.execute(
+ new SetIssueSprintCommand(actorId, issueId, sprintId),
+ );
+ }
+}
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 811b8ee..20bc3c9 100644
--- a/libs/api/tracker/application/src/lib/tracker-application.module.ts
+++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts
@@ -31,6 +31,7 @@ import {
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { SavedFiltersService } from './saved-filters.service';
+import { SprintsService } from './sprints.service';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -51,6 +52,7 @@ const SERVICES = [
WorkflowsService,
NotificationsService,
SavedFiltersService,
+ SprintsService,
];
/**
diff --git a/libs/api/tracker/application/src/testing/in-memory.ts b/libs/api/tracker/application/src/testing/in-memory.ts
index b783d45..e79b397 100644
--- a/libs/api/tracker/application/src/testing/in-memory.ts
+++ b/libs/api/tracker/application/src/testing/in-memory.ts
@@ -243,6 +243,20 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository {
);
}
+ async sprintIdsOfProject(projectId: string) {
+ return this.streams(
+ 'SprintCreatedEvent',
+ (d) => d['projectId'] === projectId,
+ );
+ }
+
+ async issueIdsEverInSprint(sprintId: string) {
+ return this.streams(
+ 'IssueSprintChangedEvent',
+ (d) => d['sprintId'] === sprintId,
+ );
+ }
+
async commentIdsOfIssue(issueId: string) {
return this.streams(
'CommentAddedEvent',
diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts
index 94b1c8f..440b51c 100644
--- a/libs/api/tracker/domain/src/index.ts
+++ b/libs/api/tracker/domain/src/index.ts
@@ -11,6 +11,8 @@ export * from './lib/project/project-settings';
export * from './lib/comment/comment';
export * from './lib/release/release';
export * from './lib/release/release-status';
+export * from './lib/sprint/sprint';
+export * from './lib/sprint/sprint-status';
export * from './lib/events';
export * from './lib/factories';
export * from './lib/read-models';
diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts
index 293196e..530e0e9 100644
--- a/libs/api/tracker/domain/src/lib/events/index.ts
+++ b/libs/api/tracker/domain/src/lib/events/index.ts
@@ -31,3 +31,9 @@ export * from './issue-checklist-item-added.event';
export * from './issue-checklist-item-updated.event';
export * from './issue-checklist-item-removed.event';
export * from './issue-ranked.event';
+export * from './sprint-created.event';
+export * from './sprint-updated.event';
+export * from './sprint-started.event';
+export * from './sprint-closed.event';
+export * from './sprint-deleted.event';
+export * from './issue-sprint-changed.event';
diff --git a/libs/api/tracker/domain/src/lib/events/issue-sprint-changed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-sprint-changed.event.ts
new file mode 100644
index 0000000..6373a00
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/issue-sprint-changed.event.ts
@@ -0,0 +1,16 @@
+import { AutowiredEvent } from '@task-stream/api/shared/domain';
+import { TrackerEvent } from './tracker.event';
+
+/** A ticket was moved in or out of a sprint. */
+@AutowiredEvent
+export class IssueSprintChangedEvent extends TrackerEvent {
+ constructor(
+ actorId: string,
+ issueId: string,
+ projectId: string,
+ readonly sprintId: string | null,
+ readonly previousSprintId: string | null,
+ ) {
+ super(actorId, issueId, projectId);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/events/sprint-closed.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-closed.event.ts
new file mode 100644
index 0000000..342c540
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/sprint-closed.event.ts
@@ -0,0 +1,19 @@
+import { AutowiredEvent } from '@task-stream/api/shared/domain';
+import { TrackerEvent } from './tracker.event';
+
+/**
+ * A sprint ended. Its unfinished tickets move to `carryOverTo` (null: back
+ * to the backlog); the SprintCarryOverSaga moves them.
+ */
+@AutowiredEvent
+export class SprintClosedEvent extends TrackerEvent {
+ constructor(
+ actorId: string,
+ sprintId: string,
+ projectId: string,
+ readonly carryOverTo: string | null,
+ readonly unfinishedIssues: number,
+ ) {
+ super(actorId, sprintId, projectId);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/events/sprint-created.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-created.event.ts
new file mode 100644
index 0000000..8014499
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/sprint-created.event.ts
@@ -0,0 +1,14 @@
+import { AutowiredEvent } from '@task-stream/api/shared/domain';
+import type { SprintProps } from '../sprint/sprint';
+import { TrackerEvent } from './tracker.event';
+
+/** A sprint was planned. */
+@AutowiredEvent
+export class SprintCreatedEvent extends TrackerEvent {
+ constructor(
+ actorId: string,
+ readonly sprint: SprintProps,
+ ) {
+ super(actorId, sprint.id, sprint.projectId);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/events/sprint-deleted.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-deleted.event.ts
new file mode 100644
index 0000000..e19f0d4
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/sprint-deleted.event.ts
@@ -0,0 +1,10 @@
+import { AutowiredEvent } from '@task-stream/api/shared/domain';
+import { TrackerEvent } from './tracker.event';
+
+/** A sprint was deleted: its tickets go back to the backlog. */
+@AutowiredEvent
+export class SprintDeletedEvent extends TrackerEvent {
+ constructor(actorId: string, sprintId: string, projectId: string) {
+ super(actorId, sprintId, projectId);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/events/sprint-started.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-started.event.ts
new file mode 100644
index 0000000..70d6002
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/sprint-started.event.ts
@@ -0,0 +1,10 @@
+import { AutowiredEvent } from '@task-stream/api/shared/domain';
+import { TrackerEvent } from './tracker.event';
+
+/** A sprint got under way. */
+@AutowiredEvent
+export class SprintStartedEvent extends TrackerEvent {
+ constructor(actorId: string, sprintId: string, projectId: string) {
+ super(actorId, sprintId, projectId);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/events/sprint-updated.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-updated.event.ts
new file mode 100644
index 0000000..c167cb1
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/sprint-updated.event.ts
@@ -0,0 +1,16 @@
+import { AutowiredEvent } from '@task-stream/api/shared/domain';
+import type { SprintDetails } from '../sprint/sprint';
+import { TrackerEvent } from './tracker.event';
+
+/** A sprint was renamed, re-dated or got another goal (only what changed). */
+@AutowiredEvent
+export class SprintUpdatedEvent extends TrackerEvent {
+ constructor(
+ actorId: string,
+ sprintId: string,
+ projectId: string,
+ readonly changes: SprintDetails,
+ ) {
+ super(actorId, sprintId, projectId);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/factories/index.ts b/libs/api/tracker/domain/src/lib/factories/index.ts
index 49a810e..3c8d390 100644
--- a/libs/api/tracker/domain/src/lib/factories/index.ts
+++ b/libs/api/tracker/domain/src/lib/factories/index.ts
@@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory';
import { IssueFactory } from './issue.factory';
import { ProjectFactory } from './project.factory';
import { ReleaseFactory } from './release.factory';
+import { SprintFactory } from './sprint.factory';
export * from './issue.factory';
-export { CommentFactory, ProjectFactory, ReleaseFactory };
+export { CommentFactory, ProjectFactory, ReleaseFactory, SprintFactory };
export const TRACKER_FACTORIES = [
ProjectFactory,
IssueFactory,
CommentFactory,
ReleaseFactory,
+ SprintFactory,
];
diff --git a/libs/api/tracker/domain/src/lib/factories/sprint.factory.ts b/libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
new file mode 100644
index 0000000..9faba5a
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
@@ -0,0 +1,39 @@
+import { Injectable } from '@nestjs/common';
+import { newId } from '@task-stream/api/shared/domain';
+import { SprintCreatedEvent } from '../events/sprint-created.event';
+import { Sprint, SprintProps, normalizeSprintDetails } from '../sprint/sprint';
+import { SprintStatus } from '../sprint/sprint-status';
+
+@Injectable()
+export class SprintFactory {
+ create(
+ actorId: string,
+ props: {
+ projectId: string;
+ name: string;
+ goal?: string | null;
+ startDate: string;
+ endDate: string;
+ },
+ ): Sprint {
+ const details = normalizeSprintDetails({
+ name: props.name,
+ goal: props.goal ?? null,
+ startDate: props.startDate,
+ endDate: props.endDate,
+ });
+ const data: SprintProps = {
+ id: newId(),
+ projectId: props.projectId,
+ ...details,
+ status: SprintStatus.PLANNED,
+ startedAt: null,
+ closedAt: null,
+ createdAt: new Date().toISOString(),
+ };
+ const sprint = new Sprint(data.id);
+ Object.assign(sprint, { ...data });
+ sprint.apply(new SprintCreatedEvent(actorId, data), { skipHandler: true });
+ return sprint;
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts
index c26b955..f7763ba 100644
--- a/libs/api/tracker/domain/src/lib/issue/issue.ts
+++ b/libs/api/tracker/domain/src/lib/issue/issue.ts
@@ -16,6 +16,7 @@ import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueRankedEvent } from '../events/issue-ranked.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
+import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
@@ -112,6 +113,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public checklist: ChecklistItem[] = [];
/** Place in the backlog once someone moved it (see `backlogRank`). */
public rank: string | null = null;
+ public sprintId: string | null = null;
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -246,6 +248,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
+ /** Moves the ticket in or out of a sprint (null: back to the backlog). */
+ setSprint(actorId: string, sprintId: string | null): void {
+ if (this.sprintId === sprintId) return;
+ this.apply(
+ new IssueSprintChangedEvent(
+ actorId,
+ this.id,
+ this.projectId,
+ sprintId,
+ this.sprintId,
+ ),
+ );
+ }
+
/** Records a file stored for this ticket (or one of its comments). */
attach(actorId: string, attachment: IssueAttachment): void {
const next = normalizeAttachment(attachment);
@@ -519,6 +535,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.deleted = true;
}
+ onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
+ this.sprintId = event.sprintId;
+ this.updatedAt = event.occurredAt;
+ }
+
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
this.releaseId = event.releaseId;
this.updatedAt = event.occurredAt;
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
index b67cd5f..05b2a9b 100644
--- 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
@@ -12,12 +12,13 @@ export class IssueTimelineEntry {
issueId!: string;
projectId!: string;
at!: string;
- kind!: 'status' | 'release';
+ kind!: 'status' | 'release' | 'sprint';
status?: string;
category?: StatusCategory;
/** Assignee when the status changed (throughput per person). */
assigneeId?: string | null;
releaseId?: string | null;
+ sprintId?: string | null;
}
export class DurationStats {
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 129e599..01a9130 100644
--- a/libs/api/tracker/domain/src/lib/read-models/index.ts
+++ b/libs/api/tracker/domain/src/lib/read-models/index.ts
@@ -8,3 +8,4 @@ export * from './project-stats.read-model';
export * from './analytics.read-model';
export * from './notification.read-model';
export * from './saved-filter.read-model';
+export * from './sprint.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 178037b..23e336b 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
@@ -70,6 +70,7 @@ export class IssueReadModel {
reporter!: UserRefReadModel;
assignee!: UserRefReadModel | null;
release!: { id: string; name: string } | null;
+ sprint!: { id: string; name: string } | null;
labels!: string[];
figmaUrl!: string | null;
storyPoints!: number | null;
diff --git a/libs/api/tracker/domain/src/lib/read-models/sprint.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/sprint.read-model.ts
new file mode 100644
index 0000000..91176e6
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/read-models/sprint.read-model.ts
@@ -0,0 +1,40 @@
+import { SprintStatus } from '../sprint/sprint-status';
+
+export class SprintReadModel {
+ id!: string;
+ projectId!: string;
+ name!: string;
+ goal!: string | null;
+ /** `YYYY-MM-DD`, both included. */
+ startDate!: string;
+ endDate!: string;
+ status!: SprintStatus;
+ startedAt!: string | null;
+ closedAt!: string | null;
+ createdAt!: string;
+ version!: number;
+}
+
+/** How far a sprint's tickets have got. */
+export class SprintProgressReadModel {
+ total!: number;
+ /** Done or cancelled. */
+ finished!: number;
+ percent!: number;
+ storyPoints!: number;
+ finishedStoryPoints!: number;
+}
+
+/**
+ * Per day of the sprint: tickets in it (cancelled ones aside), how many are
+ * still open and their points. Days still to come are null; `ideal` falls in
+ * a straight line from what was open on the first day to zero on the last.
+ */
+export class SprintBurndownReadModel {
+ sprintId!: string;
+ dates!: string[];
+ scope!: (number | null)[];
+ remaining!: (number | null)[];
+ remainingPoints!: (number | null)[];
+ ideal!: number[];
+}
diff --git a/libs/api/tracker/domain/src/lib/sprint/sprint-status.ts b/libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
new file mode 100644
index 0000000..c1eeff2
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
@@ -0,0 +1,8 @@
+export enum SprintStatus {
+ /** Being prepared: tickets come and go. */
+ PLANNED = 'PLANNED',
+ /** Under way (one per project at a time). */
+ ACTIVE = 'ACTIVE',
+ /** Over: what was left went to another sprint or back to the backlog. */
+ CLOSED = 'CLOSED',
+}
diff --git a/libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts b/libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
new file mode 100644
index 0000000..02e9377
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
@@ -0,0 +1,79 @@
+import {
+ BusinessRuleError,
+ ValidationError,
+} from '@task-stream/api/shared/domain';
+import { SprintClosedEvent } from '../events/sprint-closed.event';
+import { SprintFactory } from '../factories/sprint.factory';
+import { replay } from '../testing';
+import { Sprint } from './sprint';
+import { SprintStatus } from './sprint-status';
+
+const factory = new SprintFactory();
+const newSprint = (name = 'Sprint 1', projectId = 'p-1') =>
+ factory.create('u-pm', {
+ projectId,
+ name,
+ goal: ' Pagos con tarjeta ',
+ startDate: '2026-10-12',
+ endDate: '2026-10-23',
+ });
+
+describe('Sprint', () => {
+ it('is planned with tidy details', () => {
+ const sprint = newSprint();
+ expect(sprint.status).toBe(SprintStatus.PLANNED);
+ expect(sprint.goal).toBe('Pagos con tarjeta');
+ });
+
+ it('refuses dates the wrong way round, too long or malformed', () => {
+ const create = (startDate: string, endDate: string) =>
+ factory.create('u', { projectId: 'p', name: 'S', startDate, endDate });
+ expect(() => create('2026-10-23', '2026-10-12')).toThrow(ValidationError);
+ expect(() => create('2026-01-01', '2026-12-31')).toThrow(/at most/);
+ expect(() => create('12/10/2026', '2026-10-23')).toThrow(ValidationError);
+ });
+
+ it('starts once, only when nothing else is under way, and closes', () => {
+ const sprint = newSprint();
+ const next = newSprint('Sprint 2');
+ expect(() => sprint.start('u', { name: 'Sprint 0' })).toThrow(
+ BusinessRuleError,
+ );
+ sprint.start('u', null);
+ expect(() => sprint.start('u', null)).toThrow(BusinessRuleError);
+ expect(() => sprint.update('u', { startDate: '2026-10-13' })).toThrow(
+ /start date/,
+ );
+ sprint.update('u', { endDate: '2026-10-26' });
+
+ sprint.close('u', next, 3);
+ const closed = sprint
+ .getUncommittedEvents()
+ .find((e) => e instanceof SprintClosedEvent);
+ expect(closed).toMatchObject({ carryOverTo: next.id, unfinishedIssues: 3 });
+ const { rebuilt } = replay(sprint, Sprint);
+ expect(rebuilt.status).toBe(SprintStatus.CLOSED);
+ expect(rebuilt.endDate).toBe('2026-10-26');
+ expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError);
+ });
+
+ it('carries over only to another open sprint of the same project', () => {
+ const sprint = newSprint();
+ sprint.start('u', null);
+ expect(() => sprint.close('u', sprint, 0)).toThrow(BusinessRuleError);
+ expect(() => sprint.close('u', newSprint('X', 'p-2'), 0)).toThrow(
+ BusinessRuleError,
+ );
+ });
+
+ it('deletes planned sprints, and any on cascade', () => {
+ const planned = newSprint();
+ planned.delete('u');
+ expect(planned.deleted).toBe(true);
+ const active = newSprint();
+ active.start('u', null);
+ expect(() => active.delete('u')).toThrow(BusinessRuleError);
+ active.delete('u', true);
+ expect(active.deleted).toBe(true);
+ });
+});
diff --git a/libs/api/tracker/domain/src/lib/sprint/sprint.ts b/libs/api/tracker/domain/src/lib/sprint/sprint.ts
new file mode 100644
index 0000000..716e398
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/sprint/sprint.ts
@@ -0,0 +1,192 @@
+import {
+ BusinessRuleError,
+ VersionedAggregateRoot,
+ assertThat,
+} from '@task-stream/api/shared/domain';
+import { SprintClosedEvent } from '../events/sprint-closed.event';
+import { SprintCreatedEvent } from '../events/sprint-created.event';
+import { SprintDeletedEvent } from '../events/sprint-deleted.event';
+import { SprintStartedEvent } from '../events/sprint-started.event';
+import { SprintUpdatedEvent } from '../events/sprint-updated.event';
+import { SprintStatus } from './sprint-status';
+
+/** Data of a sprint (what SprintCreatedEvent carries). */
+export interface SprintProps {
+ id: string;
+ projectId: string;
+ name: string;
+ goal: string | null;
+ /** First and last day, `YYYY-MM-DD`. */
+ startDate: string;
+ endDate: string;
+ status: SprintStatus;
+ startedAt: string | null;
+ closedAt: string | null;
+ createdAt: string;
+}
+
+export type SprintDetails = Partial<
+ Pick
+>;
+
+const DATE = /^\d{4}-\d{2}-\d{2}$/;
+/** Long enough for any real iteration, short enough to catch a typo. */
+export const MAX_SPRINT_DAYS = 90;
+
+/** Checks the name and the dates of a sprint; returns them tidied. */
+export function normalizeSprintDetails(
+ details: Required> &
+ Pick,
+): Required> {
+ const name = details.name.trim();
+ assertThat(
+ name.length > 0 && name.length <= 80,
+ 'A sprint needs a name (up to 80 characters)',
+ );
+ for (const date of [details.startDate, details.endDate])
+ assertThat(
+ DATE.test(date) && !Number.isNaN(Date.parse(date)),
+ 'Sprint dates must be YYYY-MM-DD',
+ );
+ const days =
+ (Date.parse(details.endDate) - Date.parse(details.startDate)) / 86_400_000;
+ assertThat(days >= 0, 'A sprint cannot end before it starts');
+ assertThat(
+ days < MAX_SPRINT_DAYS,
+ `A sprint lasts at most ${MAX_SPRINT_DAYS} days`,
+ );
+ return {
+ name,
+ goal: details.goal?.trim() || null,
+ startDate: details.startDate,
+ endDate: details.endDate,
+ };
+}
+
+/** A time-boxed iteration: the tickets the team commits to for a few weeks. */
+export class Sprint extends VersionedAggregateRoot implements SprintProps {
+ public projectId!: string;
+ public name!: string;
+ public goal: string | null = null;
+ public startDate!: string;
+ public endDate!: string;
+ public status: SprintStatus = SprintStatus.PLANNED;
+ public startedAt: string | null = null;
+ public closedAt: string | null = null;
+ public createdAt!: string;
+ public deleted = false;
+
+ constructor(id: string) {
+ super();
+ this.id = id;
+ }
+
+ /** Closed sprints keep what they had: their record is fixed. */
+ assertAcceptsIssues(): void {
+ if (this.status === SprintStatus.CLOSED)
+ throw new BusinessRuleError(`Sprint ${this.name} is closed`);
+ }
+
+ update(actorId: string, changes: SprintDetails): void {
+ this.assertAcceptsIssues();
+ const next = normalizeSprintDetails({
+ name: changes.name ?? this.name,
+ goal: changes.goal !== undefined ? changes.goal : this.goal,
+ startDate: changes.startDate ?? this.startDate,
+ endDate: changes.endDate ?? this.endDate,
+ });
+ if (
+ this.status === SprintStatus.ACTIVE &&
+ next.startDate !== this.startDate
+ )
+ throw new BusinessRuleError('A sprint under way keeps its start date');
+ const patch: SprintDetails = {};
+ for (const field of ['name', 'goal', 'startDate', 'endDate'] as const)
+ if (next[field] !== this[field])
+ (patch as Record)[field] = next[field];
+ if (Object.keys(patch).length === 0) return;
+ this.apply(new SprintUpdatedEvent(actorId, this.id, this.projectId, patch));
+ }
+
+ /** `otherActive`: the sprint already under way in the project, if any. */
+ start(actorId: string, otherActive: { name: string } | null): void {
+ if (this.status !== SprintStatus.PLANNED)
+ throw new BusinessRuleError(`Sprint ${this.name} is ${this.status}`);
+ if (otherActive)
+ throw new BusinessRuleError(
+ `Sprint ${otherActive.name} is under way; close it first`,
+ );
+ this.apply(new SprintStartedEvent(actorId, this.id, this.projectId));
+ }
+
+ /**
+ * Ends the sprint. Its unfinished tickets go to `carryOverTo` (another
+ * open sprint of the project) or, with null, back to the backlog.
+ */
+ close(
+ actorId: string,
+ carryOverTo: Sprint | null,
+ unfinishedIssues: number,
+ ): void {
+ if (this.status !== SprintStatus.ACTIVE)
+ throw new BusinessRuleError(
+ `Only a sprint under way can be closed; ${this.name} is ${this.status}`,
+ );
+ if (carryOverTo) {
+ if (
+ carryOverTo.id === this.id ||
+ carryOverTo.projectId !== this.projectId
+ )
+ throw new BusinessRuleError(
+ 'Unfinished tickets go to another sprint of the same project',
+ );
+ carryOverTo.assertAcceptsIssues();
+ }
+ this.apply(
+ new SprintClosedEvent(
+ actorId,
+ this.id,
+ this.projectId,
+ carryOverTo?.id ?? null,
+ unfinishedIssues,
+ ),
+ );
+ }
+
+ /**
+ * Removes a sprint that never started (its tickets go back to the
+ * backlog), or any sprint when its project is deleted (`cascade`).
+ */
+ delete(actorId: string, cascade = false): void {
+ if (this.deleted) return;
+ if (!cascade && this.status !== SprintStatus.PLANNED)
+ throw new BusinessRuleError(
+ `Sprint ${this.name} is ${this.status}; only planned sprints can be deleted`,
+ );
+ this.apply(new SprintDeletedEvent(actorId, this.id, this.projectId));
+ }
+
+ /* ---- State transitions (also replayed by loadFromHistory) ---- */
+
+ onSprintCreatedEvent({ sprint }: SprintCreatedEvent): void {
+ Object.assign(this, sprint);
+ }
+
+ onSprintUpdatedEvent(event: SprintUpdatedEvent): void {
+ Object.assign(this, event.changes);
+ }
+
+ onSprintStartedEvent(event: SprintStartedEvent): void {
+ this.status = SprintStatus.ACTIVE;
+ this.startedAt = event.occurredAt;
+ }
+
+ onSprintClosedEvent(event: SprintClosedEvent): void {
+ this.status = SprintStatus.CLOSED;
+ this.closedAt = event.occurredAt;
+ }
+
+ onSprintDeletedEvent(): void {
+ this.deleted = true;
+ }
+}
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 d91eef8..cd47f37 100644
--- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts
+++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts
@@ -24,6 +24,7 @@ import {
ProjectRole,
ProjectStatsReadModel,
ReleaseProgressReadModel,
+ SprintProgressReadModel,
ReleaseReadModel,
ReleaseStatus,
StatusCategory,
@@ -281,6 +282,37 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
});
}
+ async sprintProgress(sprintId: string): Promise {
+ const finished = { $in: ['$statusCategory', FINISHED] };
+ const points = { $ifNull: ['$storyPoints', 0] };
+ const [row] = await this.issues.aggregate<{
+ total: number;
+ finished: number;
+ storyPoints: number;
+ finishedStoryPoints: number;
+ }>([
+ { $match: { 'sprint.id': sprintId } },
+ {
+ $group: {
+ _id: null,
+ total: { $sum: 1 },
+ finished: { $sum: { $cond: [finished, 1, 0] } },
+ storyPoints: { $sum: points },
+ finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } },
+ },
+ },
+ ]);
+ const total = row?.total ?? 0;
+ return {
+ total,
+ finished: row?.finished ?? 0,
+ percent:
+ total === 0 ? 0 : Math.round(((row?.finished ?? 0) / total) * 100),
+ storyPoints: row?.storyPoints ?? 0,
+ finishedStoryPoints: row?.finishedStoryPoints ?? 0,
+ };
+ }
+
async releaseProgress(releaseId: string): Promise {
const rows = await this.issues.aggregate<{
_id: { status: string; category: StatusCategory };
@@ -322,6 +354,8 @@ function toMongoFilter(filter: IssueFilter): FilterQuery {
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
if (filter.releaseId) query['release.id'] = filter.releaseId;
if (filter.parentId) query['parent.id'] = filter.parentId;
+ if (filter.noSprint) query['sprint'] = null;
+ else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.search?.trim()) {
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 93e8e15..a4e85f2 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
@@ -8,6 +8,7 @@ import {
ProjectReadModel,
ReleaseReadModel,
SavedFilterReadModel,
+ SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
import { Schema } from 'mongoose';
@@ -24,6 +25,7 @@ export type IssueDoc = Doc> & {
};
export type CommentDoc = Doc;
export type ReleaseDoc = Doc;
+export type SprintDoc = Doc;
export type ActivityDoc = Doc;
export type TimelineDoc = Doc;
export type NotificationDoc = Doc;
@@ -43,6 +45,7 @@ export const ReadModels = {
Notification: 'NotificationReadModel',
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
+ Sprint: 'SprintReadModel',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -79,6 +82,7 @@ export const IssueSchema = new Schema(
reporter: userRef,
assignee: userRef,
release: { _id: false, id: String, name: String },
+ sprint: { _id: false, id: String, name: String },
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
@@ -108,6 +112,7 @@ IssueSchema.index({ 'watchers.id': 1 });
IssueSchema.index({ 'parent.id': 1 });
IssueSchema.index({ projectId: 1, rank: 1 });
IssueSchema.index({ 'release.id': 1 });
+IssueSchema.index({ 'sprint.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema(
@@ -126,6 +131,16 @@ export const ReleaseSchema = new Schema(
{ ...loose, collection: 'releases_view' },
);
+export const SprintSchema = new Schema(
+ {
+ _id: String,
+ projectId: { type: String, index: true },
+ status: String,
+ version: Number,
+ },
+ { ...loose, collection: 'sprints_view' },
+);
+
export const ActivitySchema = new Schema(
{
_id: String,
@@ -182,6 +197,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Issue, schema: IssueSchema },
{ name: ReadModels.Comment, schema: CommentSchema },
{ name: ReadModels.Release, schema: ReleaseSchema },
+ { name: ReadModels.Sprint, schema: SprintSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema },
@@ -207,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
watchers: rest.watchers ?? [],
parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number),
+ sprint: rest.sprint?.id ? rest.sprint : null,
checklist: rest.checklist ?? [],
};
}
diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts
new file mode 100644
index 0000000..5fcccd4
--- /dev/null
+++ b/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts
@@ -0,0 +1,72 @@
+import { Injectable } from '@nestjs/common';
+import { InjectModel } from '@nestjs/mongoose';
+import {
+ FindSprintsRepository,
+ UpsertMaterializedSprintRepository,
+} from '@task-stream/api/tracker/application';
+import { SprintReadModel, SprintStatus } from '@task-stream/api/tracker/domain';
+import { Model } from 'mongoose';
+import { ReadModels, SprintDoc, fromDoc } from './read-model.schemas';
+import { upsertIfNewer } from './upsert-if-newer';
+
+const ORDER: Record = {
+ [SprintStatus.ACTIVE]: 0,
+ [SprintStatus.PLANNED]: 1,
+ [SprintStatus.CLOSED]: 2,
+};
+
+@Injectable()
+export class MongoFindSprintsRepository extends FindSprintsRepository {
+ constructor(
+ @InjectModel(ReadModels.Sprint) private readonly sprints: Model,
+ ) {
+ super();
+ }
+
+ async findById(id: string): Promise {
+ const doc = await this.sprints.findById(id).lean();
+ return doc ? fromDoc(doc) : null;
+ }
+
+ async findByProject(projectId: string): Promise {
+ const docs = await this.sprints.find({ projectId }).lean();
+ return docs
+ .map(fromDoc)
+ .sort(
+ (a, b) =>
+ ORDER[a.status] - ORDER[b.status] ||
+ (a.status === SprintStatus.CLOSED
+ ? b.startDate.localeCompare(a.startDate)
+ : a.startDate.localeCompare(b.startDate)) ||
+ a.name.localeCompare(b.name),
+ );
+ }
+
+ async findActive(projectId: string): Promise {
+ const doc = await this.sprints
+ .findOne({ projectId, status: SprintStatus.ACTIVE })
+ .lean();
+ return doc ? fromDoc(doc) : null;
+ }
+}
+
+@Injectable()
+export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedSprintRepository {
+ constructor(
+ @InjectModel(ReadModels.Sprint) private readonly sprints: Model,
+ ) {
+ super();
+ }
+
+ async upsert({
+ id,
+ version,
+ ...fields
+ }: Pick & Partial) {
+ await upsertIfNewer(this.sprints, id, version, fields);
+ }
+
+ async remove(sprintId: string) {
+ await this.sprints.deleteOne({ _id: sprintId });
+ }
+}
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 8898c46..3482c9f 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
@@ -230,6 +230,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
if (ops.length) await this.issues.bulkWrite(ops);
}
+ async renameSprint(sprintId: string, name: string) {
+ await this.issues.updateMany(
+ { 'sprint.id': sprintId },
+ { $set: { 'sprint.name': name } },
+ );
+ }
+
async renameRelease(releaseId: string, name: string) {
await this.issues.updateMany(
{ 'release.id': releaseId },
diff --git a/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts b/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts
index 7ede509..5946b23 100644
--- a/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts
+++ b/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts
@@ -5,7 +5,9 @@ import {
IssueCreatedEvent,
IssueLinkedEvent,
IssueParentChangedEvent,
+ IssueSprintChangedEvent,
ReleaseCreatedEvent,
+ SprintCreatedEvent,
} from '@task-stream/api/tracker/domain';
import { DataSource } from 'typeorm';
@@ -32,6 +34,22 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
);
}
+ sprintIdsOfProject(projectId: string): Promise {
+ return this.streams(
+ SprintCreatedEvent.name,
+ `data->>'projectId'`,
+ projectId,
+ );
+ }
+
+ issueIdsEverInSprint(sprintId: string): Promise {
+ return this.streams(
+ IssueSprintChangedEvent.name,
+ `data->>'sprintId'`,
+ sprintId,
+ );
+ }
+
commentIdsOfIssue(issueId: string): Promise {
return this.streams(
CommentAddedEvent.name,
diff --git a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts
index 8d535c2..0c40710 100644
--- a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts
+++ b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts
@@ -6,6 +6,7 @@ import {
IssueChange,
ProjectChange,
ReleaseChange,
+ SprintChange,
} from '@task-stream/api/tracker/application';
import {
ActivityReadModel,
@@ -19,6 +20,7 @@ const Channels = {
comment: (issueId: string) => `tracker.comment.${issueId}`,
activity: (projectId: string) => `tracker.activity.${projectId}`,
release: (projectId: string) => `tracker.release.${projectId}`,
+ sprint: (projectId: string) => `tracker.sprint.${projectId}`,
project: (projectId: string) => `tracker.project.${projectId}`,
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
notifications: (userId: string) => `tracker.notifications.${userId}`,
@@ -49,6 +51,10 @@ export class RedisChangeFeed extends ChangeFeed {
);
}
+ publishSprint(change: SprintChange): Promise {
+ return this.pubSub.publish(Channels.sprint(change.projectId), change);
+ }
+
async publishProject(
change: ProjectChange,
notifyUserIds: string[] = [],
@@ -88,6 +94,10 @@ export class RedisChangeFeed extends ChangeFeed {
return this.pubSub.asyncIterableIterator(Channels.release(projectId));
}
+ sprints(projectId: string): AsyncIterableIterator {
+ return this.pubSub.asyncIterableIterator(Channels.sprint(projectId));
+ }
+
project(projectId: string): AsyncIterableIterator {
return this.pubSub.asyncIterableIterator(Channels.project(projectId));
}
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 7c8d33a..797d4ee 100644
--- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
+++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
@@ -13,6 +13,8 @@ import {
FindIssuesRepository,
FindProjectsRepository,
FindReleasesRepository,
+ FindSprintsRepository,
+ UpsertMaterializedSprintRepository,
FindTimelineRepository,
IssueNumbersRepository,
ProjectKeysRepository,
@@ -58,6 +60,10 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
+import {
+ MongoFindSprintsRepository,
+ MongoUpsertMaterializedSprintRepository,
+} from './mongo/sprints.repositories';
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
@@ -79,6 +85,11 @@ const PORTS = [
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
+ { provide: FindSprintsRepository, useClass: MongoFindSprintsRepository },
+ {
+ provide: UpsertMaterializedSprintRepository,
+ useClass: MongoUpsertMaterializedSprintRepository,
+ },
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
{
diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts
index cae9e6d..81cbdc6 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts
@@ -6,6 +6,7 @@ import {
NotificationKind,
ProjectRole,
ReleaseStatus,
+ SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -23,6 +24,10 @@ registerEnumType(StatusCategory, {
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
+registerEnumType(SprintStatus, {
+ name: 'SprintStatus',
+ description: 'PLANNED, ACTIVE (one per project at a time) or CLOSED',
+});
registerEnumType(LinkDirection, { name: 'LinkDirection' });
registerEnumType(IssueSort, {
name: 'IssueSort',
diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
index f192bae..967faaf 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
@@ -181,6 +181,41 @@ export class ChangeIssueStatusInput {
reason?: string | null;
}
+const DAY = /^\d{4}-\d{2}-\d{2}$/;
+
+@InputType()
+export class CreateSprintInput {
+ @Field(() => ID) @IsUUID() projectId!: string;
+ @Field() @IsString() @MinLength(1) @MaxLength(80) name!: string;
+ @Field(() => String, { nullable: true })
+ @IsOptional()
+ @IsString()
+ @MaxLength(500)
+ goal?: string | null;
+ @Field({ description: 'YYYY-MM-DD' }) @Matches(DAY) startDate!: string;
+ @Field({ description: 'YYYY-MM-DD, included' })
+ @Matches(DAY)
+ endDate!: string;
+}
+
+@InputType()
+export class UpdateSprintInput {
+ @Field(() => ID) @IsUUID() sprintId!: string;
+ @Field({ nullable: true })
+ @IsOptional()
+ @IsString()
+ @MinLength(1)
+ @MaxLength(80)
+ name?: string;
+ @Field(() => String, { nullable: true })
+ @IsOptional()
+ @IsString()
+ @MaxLength(500)
+ goal?: string | null;
+ @Field({ nullable: true }) @IsOptional() @Matches(DAY) startDate?: string;
+ @Field({ nullable: true }) @IsOptional() @Matches(DAY) endDate?: string;
+}
+
@InputType()
export class SavedFilterCriteriaInput {
@Field(() => [String], { nullable: true })
@@ -320,6 +355,14 @@ export class IssueFilterInput {
@IsOptional()
@IsUUID()
parentId?: string;
+ @Field(() => ID, { nullable: true })
+ @IsOptional()
+ @IsUUID()
+ sprintId?: string;
+ @Field({ nullable: true, description: 'Only tickets in no sprint' })
+ @IsOptional()
+ @IsBoolean()
+ noSprint?: boolean;
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
@IsOptional()
@IsEnum(IssueSort)
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 63d0a55..4a3fca1 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts
@@ -125,6 +125,8 @@ export class ProjectResolver {
search?: string | null,
@Args('unassigned', { type: () => Boolean, nullable: true })
unassigned?: boolean | null,
+ @Args('sprintId', { type: () => ID, nullable: true })
+ sprintId?: string | null,
) {
return this.projects.board(
viewerOf(me),
@@ -138,6 +140,7 @@ export class ProjectResolver {
labels: labels ?? undefined,
search: search?.slice(0, 200) ?? undefined,
unassigned: unassigned ?? undefined,
+ sprintId: sprintId ?? undefined,
},
perColumn,
);
diff --git a/libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
new file mode 100644
index 0000000..acbaf65
--- /dev/null
+++ b/libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
@@ -0,0 +1,170 @@
+import {
+ Args,
+ ID,
+ Int,
+ Mutation,
+ Parent,
+ Query,
+ ResolveField,
+ Resolver,
+} from '@nestjs/graphql';
+import { User } from '@task-stream/api/identity/domain';
+import {
+ CurrentUser,
+ OptionalAuth,
+} from '@task-stream/api/identity/presenters';
+import {
+ IssuesService,
+ IssueSort,
+ SprintsService,
+} from '@task-stream/api/tracker/application';
+import { SprintReadModel } from '@task-stream/api/tracker/domain';
+import { viewerOf } from '../common/viewer';
+import { CreateSprintInput, UpdateSprintInput } from './inputs';
+import {
+ CommandResultGql,
+ IssuePageGql,
+ SprintBurndownGql,
+ SprintGql,
+ SprintProgressGql,
+} from './types';
+
+@Resolver(() => SprintGql)
+export class SprintResolver {
+ constructor(
+ private readonly sprintsService: SprintsService,
+ private readonly issuesService: IssuesService,
+ ) {}
+
+ @OptionalAuth()
+ @Query(() => [SprintGql], {
+ description:
+ "A project's sprints: the one under way, the planned ones, then the closed ones",
+ })
+ sprints(
+ @CurrentUser() me: User | undefined,
+ @Args('projectId', { type: () => ID }) projectId: string,
+ ) {
+ return this.sprintsService.list(viewerOf(me), projectId);
+ }
+
+ @OptionalAuth()
+ @Query(() => SprintGql)
+ sprint(
+ @CurrentUser() me: User | undefined,
+ @Args('id', { type: () => ID }) id: string,
+ ) {
+ return this.sprintsService.findOne(viewerOf(me), id);
+ }
+
+ @ResolveField(() => SprintProgressGql)
+ progress(
+ @CurrentUser() me: User | undefined,
+ @Parent() sprint: SprintReadModel,
+ ) {
+ return this.sprintsService.progress(viewerOf(me), sprint.id);
+ }
+
+ @ResolveField(() => SprintBurndownGql)
+ burndown(
+ @CurrentUser() me: User | undefined,
+ @Parent() sprint: SprintReadModel,
+ ) {
+ return this.sprintsService.burndown(viewerOf(me), sprint.id);
+ }
+
+ @ResolveField(() => IssuePageGql, {
+ description: 'Tickets in this sprint, in backlog order',
+ })
+ issues(
+ @CurrentUser() me: User | undefined,
+ @Parent() sprint: SprintReadModel,
+ @Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
+ @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
+ ) {
+ return this.issuesService.search(viewerOf(me), {
+ projectId: sprint.projectId,
+ sprintId: sprint.id,
+ sort: IssueSort.RANK,
+ limit,
+ offset,
+ });
+ }
+
+ @Mutation(() => CommandResultGql, {
+ description: 'Plan a sprint (owners and admins)',
+ })
+ createSprint(
+ @CurrentUser() me: User,
+ @Args('input') input: CreateSprintInput,
+ ) {
+ return this.sprintsService.create(me.id, input);
+ }
+
+ @Mutation(() => CommandResultGql)
+ updateSprint(
+ @CurrentUser() me: User,
+ @Args('input') input: UpdateSprintInput,
+ ) {
+ const { sprintId, ...changes } = input;
+ return this.sprintsService.update(
+ me.id,
+ sprintId,
+ Object.fromEntries(
+ Object.entries(changes).filter(
+ ([field, value]) => value !== null || field === 'goal',
+ ),
+ ),
+ );
+ }
+
+ @Mutation(() => CommandResultGql, {
+ description: 'Start a planned sprint (only one under way per project)',
+ })
+ startSprint(
+ @CurrentUser() me: User,
+ @Args('sprintId', { type: () => ID }) sprintId: string,
+ ) {
+ return this.sprintsService.start(me.id, sprintId);
+ }
+
+ @Mutation(() => CommandResultGql, {
+ description:
+ 'Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.',
+ })
+ closeSprint(
+ @CurrentUser() me: User,
+ @Args('sprintId', { type: () => ID }) sprintId: string,
+ @Args('carryOverToSprintId', { type: () => ID, nullable: true })
+ carryOverToSprintId: string | null,
+ ) {
+ return this.sprintsService.close(
+ me.id,
+ sprintId,
+ carryOverToSprintId ?? null,
+ );
+ }
+
+ @Mutation(() => CommandResultGql, {
+ description:
+ 'Delete a sprint that never started; its tickets go back to the backlog',
+ })
+ deleteSprint(
+ @CurrentUser() me: User,
+ @Args('sprintId', { type: () => ID }) sprintId: string,
+ ) {
+ return this.sprintsService.delete(me.id, sprintId);
+ }
+
+ @Mutation(() => CommandResultGql, {
+ description: 'Put a ticket in a sprint (null: back to the backlog)',
+ })
+ setIssueSprint(
+ @CurrentUser() me: User,
+ @Args('issueId', { type: () => ID }) issueId: string,
+ @Args('sprintId', { type: () => ID, nullable: true })
+ sprintId: string | null,
+ ) {
+ return this.sprintsService.setIssueSprint(me.id, issueId, sprintId ?? null);
+ }
+}
diff --git a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts
index a333683..30149c3 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts
@@ -12,6 +12,7 @@ import {
NotificationGql,
ProjectChangeGql,
ReleaseChangeGql,
+ SprintChangeGql,
} from './types';
const asIs = { resolve: (payload: T) => payload };
@@ -66,6 +67,14 @@ export class SubscriptionResolver {
return this.changes.watchReleases(me.id, projectId);
}
+ @Subscription(() => SprintChangeGql, asIs)
+ sprintChanged(
+ @CurrentUser() me: User,
+ @Args('projectId', { type: () => ID }) projectId: string,
+ ) {
+ return this.changes.watchSprints(me.id, projectId);
+ }
+
@Subscription(() => ProjectChangeGql, asIs)
projectChanged(
@CurrentUser() me: User,
diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts
index 7b5b99e..22e48e5 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/types.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts
@@ -5,6 +5,7 @@ import {
NotificationKind,
ProjectRole,
ReleaseStatus,
+ SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums';
@@ -110,6 +111,12 @@ export class AttachmentGql {
commentId!: string | null;
}
+@ObjectType('SprintRef')
+export class SprintRefGql {
+ @Field(() => ID) id!: string;
+ @Field() name!: string;
+}
+
@ObjectType('ReleaseRef')
export class ReleaseRefGql {
@Field(() => ID) id!: string;
@@ -154,6 +161,7 @@ export class IssueGql {
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@Field(() => ReleaseRefGql, { nullable: true })
release!: ReleaseRefGql | null;
+ @Field(() => SprintRefGql, { nullable: true }) sprint!: SprintRefGql | null;
@Field(() => [String]) labels!: string[];
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
@@ -270,6 +278,56 @@ export class ReleaseGql {
@Field(() => Int) version!: number;
}
+@ObjectType('Sprint', {
+ description: 'A time-boxed iteration: the tickets the team commits to',
+})
+export class SprintGql {
+ @Field(() => ID) id!: string;
+ @Field(() => ID) projectId!: string;
+ @Field() name!: string;
+ @Field(() => String, { nullable: true }) goal!: string | null;
+ @Field({ description: 'First day, YYYY-MM-DD' }) startDate!: string;
+ @Field({ description: 'Last day (included), YYYY-MM-DD' }) endDate!: string;
+ @Field(() => SprintStatus) status!: SprintStatus;
+ @Field(() => String, { nullable: true }) startedAt!: string | null;
+ @Field(() => String, { nullable: true }) closedAt!: string | null;
+ @Field() createdAt!: string;
+ @Field(() => Int) version!: number;
+}
+
+@ObjectType('SprintProgress')
+export class SprintProgressGql {
+ @Field(() => Int) total!: number;
+ @Field(() => Int, { description: 'Done or cancelled' }) finished!: number;
+ @Field(() => Int) percent!: number;
+ @Field(() => Int) storyPoints!: number;
+ @Field(() => Int) finishedStoryPoints!: number;
+}
+
+@ObjectType('SprintBurndown', {
+ description:
+ 'Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero.',
+})
+export class SprintBurndownGql {
+ @Field(() => ID) sprintId!: string;
+ @Field(() => [String]) dates!: string[];
+ @Field(() => [Int], { nullable: 'items' }) scope!: (number | null)[];
+ @Field(() => [Int], { nullable: 'items' }) remaining!: (number | null)[];
+ @Field(() => [Int], { nullable: 'items' })
+ remainingPoints!: (number | null)[];
+ @Field(() => [Float]) ideal!: number[];
+}
+
+@ObjectType('SprintChange')
+export class SprintChangeGql {
+ @Field() action!: string;
+ @Field(() => Int) version!: number;
+ @Field(() => ID) actorId!: string;
+ @Field(() => ID) sprintId!: string;
+ @Field(() => SprintGql, { nullable: true, description: 'Null: deleted' })
+ sprint!: SprintGql | null;
+}
+
@ObjectType('CountByStatus')
export class CountByStatusGql {
@Field({ description: 'Status id' }) key!: string;
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 a2daa49..21b8216 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
@@ -285,6 +285,11 @@ export class BoardQueryDto {
@IsBoolean()
unassigned?: boolean;
+ @ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' })
+ @IsOptional()
+ @IsUUID()
+ sprintId?: string;
+
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
@IsOptional()
@Type(() => Number)
@@ -451,6 +456,17 @@ export class IssueSearchQueryDto extends PageQueryDto {
@IsString()
parent?: string;
+ @ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' })
+ @IsOptional()
+ @IsUUID()
+ sprintId?: string;
+
+ @ApiPropertyOptional({ description: 'Only tickets in no sprint' })
+ @IsOptional()
+ @Transform(toBoolean)
+ @IsBoolean()
+ noSprint?: boolean;
+
@ApiPropertyOptional({
enum: IssueSort,
enumName: 'IssueSort',
@@ -819,6 +835,66 @@ export class CreateReleaseDto {
export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
+const DAY = /^\d{4}-\d{2}-\d{2}$/;
+
+export class CreateSprintDto {
+ @ApiProperty({ example: 'Sprint 14', maxLength: 80 })
+ @IsString()
+ @MinLength(1)
+ @MaxLength(80)
+ name!: string;
+
+ @ApiPropertyOptional({
+ type: String,
+ nullable: true,
+ maxLength: 500,
+ example: 'Pagos con tarjeta en producción',
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(500)
+ goal?: string | null;
+
+ @ApiProperty({ format: 'date', example: '2026-10-12' })
+ @Matches(DAY)
+ startDate!: string;
+
+ @ApiProperty({
+ format: 'date',
+ example: '2026-10-23',
+ description: 'Last day, included',
+ })
+ @Matches(DAY)
+ endDate!: string;
+}
+
+export class UpdateSprintDto extends PartialType(CreateSprintDto) {}
+
+export class CloseSprintDto {
+ @ApiPropertyOptional({
+ type: String,
+ nullable: true,
+ format: 'uuid',
+ description:
+ 'Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog',
+ })
+ @IsOptional()
+ @IsUUID()
+ carryOverTo?: string | null;
+}
+
+export class SetIssueSprintDto {
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ format: 'uuid',
+ description: 'Open sprint of the project, or null for the backlog',
+ })
+ @ValidateIf((dto: SetIssueSprintDto) => dto.sprintId !== null)
+ @IsUUID()
+ sprintId!: string | null;
+}
+
export class PublishReleaseDto {
@ApiPropertyOptional({
type: Boolean,
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 63f4090..18ee3a1 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
@@ -5,6 +5,7 @@ import {
NotificationKind,
ProjectRole,
ReleaseStatus,
+ SprintStatus,
SETTING_COLORS,
StatusCategory,
TYPE_ICONS,
@@ -135,6 +136,11 @@ export class IssueParentDto {
@ApiProperty({ example: 'Pago con tarjeta' }) title!: string;
}
+export class SprintRefDto {
+ @ApiProperty({ format: 'uuid' }) id!: string;
+ @ApiProperty({ example: 'Sprint 14' }) name!: string;
+}
+
export class IssueDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) projectId!: string;
@@ -163,6 +169,8 @@ export class IssueDto {
assignee!: UserRefDto | null;
@ApiProperty({ type: ReleaseRefDto, nullable: true })
release!: ReleaseRefDto | null;
+ @ApiProperty({ type: SprintRefDto, nullable: true })
+ sprint!: SprintRefDto | null;
@ApiProperty({ type: [String] }) labels!: string[];
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
figmaUrl!: string | null;
@@ -255,6 +263,50 @@ export class CommentPageDto {
@ApiProperty() total!: number;
}
+export class SprintDto {
+ @ApiProperty({ format: 'uuid' }) id!: string;
+ @ApiProperty({ format: 'uuid' }) projectId!: string;
+ @ApiProperty({ example: 'Sprint 14' }) name!: string;
+ @ApiProperty({ type: String, nullable: true }) goal!: string | null;
+ @ApiProperty({ format: 'date' }) startDate!: string;
+ @ApiProperty({ format: 'date', description: 'Last day, included' })
+ endDate!: string;
+ @ApiProperty({ enum: SprintStatus, enumName: 'SprintStatus' })
+ status!: SprintStatus;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ startedAt!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ closedAt!: string | null;
+ @ApiProperty({ format: 'date-time' }) createdAt!: string;
+ @ApiProperty() version!: number;
+}
+
+export class SprintProgressDto {
+ @ApiProperty() total!: number;
+ @ApiProperty({ description: 'Done or cancelled' }) finished!: number;
+ @ApiProperty({ minimum: 0, maximum: 100 }) percent!: number;
+ @ApiProperty() storyPoints!: number;
+ @ApiProperty() finishedStoryPoints!: number;
+}
+
+export class SprintBurndownDto {
+ @ApiProperty({ format: 'uuid' }) sprintId!: string;
+ @ApiProperty({ type: [String], format: 'date' }) dates!: string[];
+ @ApiProperty({
+ type: [Number],
+ nullable: true,
+ description: 'Tickets in the sprint (null: days to come)',
+ })
+ scope!: (number | null)[];
+ @ApiProperty({ type: [Number], nullable: true }) remaining!: (
+ | number
+ | null
+ )[];
+ @ApiProperty({ type: [Number], nullable: true })
+ remainingPoints!: (number | null)[];
+ @ApiProperty({ type: [Number] }) ideal!: number[];
+}
+
export class ReleaseDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) projectId!: string;
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 2842b96..d3c9311 100644
--- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
+++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
@@ -157,6 +157,8 @@ export class IssuesController {
unassigned: query.unassigned,
releaseId: query.releaseId,
labels: query.label,
+ sprintId: query.sprintId,
+ noSprint: query.noSprint,
sort: query.sort,
search: query.search,
limit: query.limit,
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 572322d..6a5ce30 100644
--- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts
+++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts
@@ -187,6 +187,7 @@ export class ProjectsController {
labels: query.label,
search: query.search,
unassigned: query.unassigned,
+ sprintId: query.sprintId,
},
query.perColumn,
);
diff --git a/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts b/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
new file mode 100644
index 0000000..4908329
--- /dev/null
+++ b/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
@@ -0,0 +1,249 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+ Put,
+ Query,
+} from '@nestjs/common';
+import {
+ ApiCreatedResponse,
+ ApiOkResponse,
+ ApiOperation,
+ ApiParam,
+ ApiTags,
+} from '@nestjs/swagger';
+import { User } from '@task-stream/api/identity/domain';
+import {
+ ApiOptionalPireonAuth,
+ ApiPireonAuth,
+ CurrentUser,
+} from '@task-stream/api/identity/presenters';
+import {
+ ApiCommandErrors,
+ ApiReadErrors,
+} from '@task-stream/api/shared/presenters';
+import {
+ IssuesService,
+ IssueSort,
+ SprintsService,
+} from '@task-stream/api/tracker/application';
+import { viewerOf } from '../common/viewer';
+import {
+ CloseSprintDto,
+ CreateSprintDto,
+ PageQueryDto,
+ SetIssueSprintDto,
+ UpdateSprintDto,
+} from './dto/requests.dto';
+import {
+ CommandResultDto,
+ IssuePageDto,
+ SprintBurndownDto,
+ SprintDto,
+ SprintProgressDto,
+} from './dto/responses.dto';
+import { Refs } from './refs';
+
+const PROJECT = ApiParam({
+ name: 'project',
+ description: 'Project id or key',
+ example: 'WEB',
+});
+const SPRINT = ApiParam({ name: 'sprint', format: 'uuid' });
+
+/** REST adapter of the sprint use cases: plan, run and close iterations. */
+@ApiTags('Sprints')
+@ApiPireonAuth()
+@Controller()
+export class SprintsController {
+ constructor(
+ private readonly sprints: SprintsService,
+ private readonly issues: IssuesService,
+ private readonly refs: Refs,
+ ) {}
+
+ @Get('projects/:project/sprints')
+ @ApiOptionalPireonAuth()
+ @PROJECT
+ @ApiOperation({
+ summary: "A project's sprints",
+ description: 'The one under way, the planned ones, then the closed ones.',
+ })
+ @ApiOkResponse({ type: [SprintDto] })
+ @ApiReadErrors()
+ async list(
+ @CurrentUser() me: User | undefined,
+ @Param('project') project: string,
+ ) {
+ return this.sprints.list(
+ viewerOf(me),
+ await this.refs.projectId(viewerOf(me), project),
+ );
+ }
+
+ @Post('projects/:project/sprints')
+ @PROJECT
+ @ApiOperation({ summary: 'Plan a sprint (owners and admins)' })
+ @ApiCreatedResponse({ type: CommandResultDto })
+ @ApiCommandErrors()
+ async create(
+ @CurrentUser() me: User,
+ @Param('project') project: string,
+ @Body() body: CreateSprintDto,
+ ) {
+ return this.sprints.create(me.id, {
+ ...body,
+ projectId: await this.refs.projectId(me.id, project),
+ });
+ }
+
+ @Get('sprints/:sprint')
+ @ApiOptionalPireonAuth()
+ @SPRINT
+ @ApiOperation({ summary: 'A sprint' })
+ @ApiOkResponse({ type: SprintDto })
+ @ApiReadErrors()
+ findOne(
+ @CurrentUser() me: User | undefined,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ ) {
+ return this.sprints.findOne(viewerOf(me), sprint);
+ }
+
+ @Patch('sprints/:sprint')
+ @SPRINT
+ @ApiOperation({
+ summary: 'Rename, re-date or change the goal (not once closed)',
+ })
+ @ApiOkResponse({ type: CommandResultDto })
+ @ApiCommandErrors()
+ update(
+ @CurrentUser() me: User,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ @Body() body: UpdateSprintDto,
+ ) {
+ return this.sprints.update(me.id, sprint, body);
+ }
+
+ @Delete('sprints/:sprint')
+ @SPRINT
+ @ApiOperation({
+ summary: 'Delete a sprint that never started',
+ description: 'Its tickets go back to the backlog.',
+ })
+ @ApiOkResponse({ type: CommandResultDto })
+ @ApiCommandErrors()
+ delete(
+ @CurrentUser() me: User,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ ) {
+ return this.sprints.delete(me.id, sprint);
+ }
+
+ @Post('sprints/:sprint/start')
+ @SPRINT
+ @ApiOperation({
+ summary: 'Start a planned sprint (only one under way per project)',
+ })
+ @ApiCreatedResponse({ type: CommandResultDto })
+ @ApiCommandErrors()
+ start(
+ @CurrentUser() me: User,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ ) {
+ return this.sprints.start(me.id, sprint);
+ }
+
+ @Post('sprints/:sprint/close')
+ @SPRINT
+ @ApiOperation({
+ summary: 'Close the sprint under way',
+ description:
+ 'Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.',
+ })
+ @ApiCreatedResponse({ type: CommandResultDto })
+ @ApiCommandErrors()
+ close(
+ @CurrentUser() me: User,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ @Body() body: CloseSprintDto,
+ ) {
+ return this.sprints.close(me.id, sprint, body.carryOverTo ?? null);
+ }
+
+ @Get('sprints/:sprint/progress')
+ @ApiOptionalPireonAuth()
+ @SPRINT
+ @ApiOperation({ summary: 'Finished tickets and points' })
+ @ApiOkResponse({ type: SprintProgressDto })
+ @ApiReadErrors()
+ progress(
+ @CurrentUser() me: User | undefined,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ ) {
+ return this.sprints.progress(viewerOf(me), sprint);
+ }
+
+ @Get('sprints/:sprint/burndown')
+ @ApiOptionalPireonAuth()
+ @SPRINT
+ @ApiOperation({
+ summary: 'Open tickets and points, day by day, against the ideal line',
+ })
+ @ApiOkResponse({ type: SprintBurndownDto })
+ @ApiReadErrors()
+ burndown(
+ @CurrentUser() me: User | undefined,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ ) {
+ return this.sprints.burndown(viewerOf(me), sprint);
+ }
+
+ @Get('sprints/:sprint/issues')
+ @ApiOptionalPireonAuth()
+ @SPRINT
+ @ApiOperation({ summary: 'Tickets in the sprint, in backlog order' })
+ @ApiOkResponse({ type: IssuePageDto })
+ @ApiReadErrors()
+ async issuesOf(
+ @CurrentUser() me: User | undefined,
+ @Param('sprint', ParseUUIDPipe) sprint: string,
+ @Query() query: PageQueryDto,
+ ) {
+ const found = await this.sprints.findOne(viewerOf(me), sprint);
+ const page = await this.issues.search(viewerOf(me), {
+ projectId: found.projectId,
+ sprintId: found.id,
+ sort: IssueSort.RANK,
+ limit: query.limit,
+ offset: query.offset,
+ });
+ return { ...page, items: await this.refs.issueDtos(page.items) };
+ }
+
+ @Put('issues/:issue/sprint')
+ @ApiParam({
+ name: 'issue',
+ description: 'Ticket id or key',
+ example: 'WEB-12',
+ })
+ @ApiOperation({ summary: 'Put the ticket in a sprint (null: the backlog)' })
+ @ApiOkResponse({ type: CommandResultDto })
+ @ApiCommandErrors()
+ async setIssueSprint(
+ @CurrentUser() me: User,
+ @Param('issue') issue: string,
+ @Body() body: SetIssueSprintDto,
+ ) {
+ return this.sprints.setIssueSprint(
+ me.id,
+ await this.refs.issueId(me.id, issue),
+ body.sprintId,
+ );
+ }
+}
diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
index 44e638d..b01b956 100644
--- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
+++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
@@ -18,6 +18,8 @@ import { ReleasesController } from './http/releases.controller';
import { NotificationsController } from './http/notifications.controller';
import { SavedFiltersController } from './http/saved-filters.controller';
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
+import { SprintResolver } from './graphql/sprint.resolver';
+import { SprintsController } from './http/sprints.controller';
/**
* Driving adapters of the tracker context: GraphQL (queries, mutations,
@@ -48,6 +50,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
ExploreController,
NotificationsController,
SavedFiltersController,
+ SprintsController,
],
providers: [
Refs,
@@ -58,6 +61,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
SubscriptionResolver,
NotificationResolver,
SavedFilterResolver,
+ SprintResolver,
],
})
export class TrackerPresentersModule {
diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts
index 4de0017..47ab353 100644
--- a/libs/web/tracker/application/src/lib/events/issue.events.ts
+++ b/libs/web/tracker/application/src/lib/events/issue.events.ts
@@ -26,6 +26,10 @@ export const issuePageEvents = eventGroup({
targetKey: string;
}>(),
unlinkRequested: type(),
+ sprintChangeRequested: type<{
+ issueId: string;
+ sprintId: string | null;
+ }>(),
releaseChangeRequested: type<{
issueId: string;
releaseId: string | null;
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 a392b0c..8551288 100644
--- a/libs/web/tracker/application/src/lib/events/project.events.ts
+++ b/libs/web/tracker/application/src/lib/events/project.events.ts
@@ -10,6 +10,10 @@ import {
ProjectStats,
Release,
ReleaseBurnup,
+ CreateSprintInput,
+ Sprint,
+ SprintBurndown,
+ UpdateSprintInput,
UserDirectoryEntry,
} from '@task-stream/web/tracker/domain';
@@ -23,6 +27,15 @@ export const projectPageEvents = eventGroup({
releaseCreateRequested: type(),
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(),
+ sprintCreateRequested: type(),
+ sprintUpdateRequested: type(),
+ sprintStartRequested: type<{ sprintId: string }>(),
+ sprintCloseRequested: type<{
+ sprintId: string;
+ carryOverTo: string | null;
+ }>(),
+ sprintDeleteRequested: type<{ sprintId: string }>(),
+ burndownRequested: type<{ sprintId: string }>(),
userSearchRequested: type(),
settingsSaveRequested: type<{
projectId: string;
@@ -42,6 +55,8 @@ export const projectApiEvents = eventGroup({
loadFailed: type(),
statsLoaded: type(),
releasesLoaded: type(),
+ sprintsLoaded: type(),
+ burndownLoaded: type(),
activityLoaded: type(),
commandSucceeded: type(),
commandFailed: type(),
@@ -63,5 +78,6 @@ export const projectRealtimeEvents = eventGroup({
projectDeleted: type(),
activityAdded: type(),
releaseChanged: type(),
+ sprintChanged: type<{ sprintId: string }>(),
},
});
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 241e773..fa38508 100644
--- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts
+++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts
@@ -13,6 +13,7 @@ import {
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
+ SprintsGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -70,6 +71,14 @@ function gateways() {
download: vi.fn(() => of(new Blob(['x']))),
remove: vi.fn(() => of(ok)),
},
+ sprints: {
+ list: vi.fn(() => of([])),
+ burndown: vi.fn(() => NEVER),
+ create: vi.fn(() => of(ok)),
+ start: vi.fn(() => of(ok)),
+ close: vi.fn(() => of(ok)),
+ setIssueSprint: vi.fn(() => of(ok)),
+ },
views: {
list: vi.fn(() => of([])),
save: vi.fn(() => of('v1')),
@@ -80,6 +89,7 @@ function gateways() {
commentChanges: () => NEVER,
activity: () => NEVER,
releaseChanges: () => NEVER,
+ sprintChanges: () => NEVER,
projectChanges: vi.fn((): unknown => NEVER),
},
};
@@ -113,6 +123,7 @@ function setup() {
{ provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{ provide: SavedFiltersGateway, useValue: fakes.views },
+ { provide: SprintsGateway, useValue: fakes.sprints },
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
@@ -219,6 +230,28 @@ describe('ProjectFacade', () => {
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
});
+ it('plans, starts and closes sprints of the open project', () => {
+ const { project, fakes } = setup();
+ project.open('WEB');
+ project.createSprint({
+ name: 'Sprint 1',
+ startDate: '2026-10-12',
+ endDate: '2026-10-23',
+ });
+ project.startSprint('s1');
+ project.closeSprint('s1', null);
+
+ expect(fakes.sprints.list).toHaveBeenCalledWith('p1');
+ expect(fakes.sprints.create).toHaveBeenCalledWith({
+ projectId: 'p1',
+ name: 'Sprint 1',
+ startDate: '2026-10-12',
+ endDate: '2026-10-23',
+ });
+ expect(fakes.sprints.start).toHaveBeenCalledWith('s1');
+ expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null);
+ });
+
it('makes the project public and deletes it, then goes home', () => {
const { project, fakes, navigate, notifications } = setup();
project.open('WEB');
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 0a60f39..7565362 100644
--- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts
+++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts
@@ -79,6 +79,13 @@ export class IssueFacade {
);
}
+ /** null: back to the backlog. */
+ setSprint(sprintId: string | null): void {
+ this.withIssue((issueId) =>
+ this.dispatch.sprintChangeRequested({ issueId, sprintId }),
+ );
+ }
+
setRelease(releaseId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.releaseChangeRequested({ issueId, releaseId }),
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 4a4c6c6..5203c21 100644
--- a/libs/web/tracker/application/src/lib/facades/project.facade.ts
+++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts
@@ -2,8 +2,11 @@ import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
+ CreateSprintInput,
ProjectRole,
ProjectSettings,
+ SprintStatus,
+ UpdateSprintInput,
} from '@task-stream/web/tracker/domain';
import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store';
@@ -21,6 +24,20 @@ export class ProjectFacade {
readonly members = this.store.members;
readonly stats = this.store.stats;
readonly releases = this.store.releases;
+ /** Under way, planned, then closed; with their progress. */
+ readonly sprints = this.store.sprints;
+ /** The sprint under way, if any. */
+ readonly activeSprint = computed(
+ () =>
+ this.store.sprints().find((s) => s.status === SprintStatus.ACTIVE) ??
+ null,
+ );
+ /** Sprints that still take tickets (under way or planned). */
+ readonly openSprints = computed(() =>
+ this.store.sprints().filter((s) => s.status !== SprintStatus.CLOSED),
+ );
+ /** Burndown of the sprint asked for with `loadBurndown`. */
+ readonly burndown = this.store.burndown;
readonly activity = this.store.activity;
/** Pireon directory results for `searchUsers()`. */
readonly userResults = this.store.userResults;
@@ -80,6 +97,33 @@ export class ProjectFacade {
}
/** `force` publishes even with unfinished tickets. */
+ createSprint(sprint: Omit): void {
+ const projectId = this.projectId();
+ if (projectId)
+ this.dispatch.sprintCreateRequested({ projectId, ...sprint });
+ }
+
+ updateSprint(changes: UpdateSprintInput): void {
+ this.dispatch.sprintUpdateRequested(changes);
+ }
+
+ startSprint(sprintId: string): void {
+ this.dispatch.sprintStartRequested({ sprintId });
+ }
+
+ /** Unfinished tickets go to `carryOverTo` (null: back to the backlog). */
+ closeSprint(sprintId: string, carryOverTo: string | null): void {
+ this.dispatch.sprintCloseRequested({ sprintId, carryOverTo });
+ }
+
+ deleteSprint(sprintId: string): void {
+ this.dispatch.sprintDeleteRequested({ sprintId });
+ }
+
+ loadBurndown(sprintId: string): void {
+ this.dispatch.burndownRequested({ sprintId });
+ }
+
publishRelease(releaseId: string, force = false): void {
this.dispatch.releasePublishRequested({ releaseId, force });
}
diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts
index 954a32d..34d0d08 100644
--- a/libs/web/tracker/application/src/lib/stores/issue.store.ts
+++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts
@@ -19,6 +19,7 @@ import {
Issue,
IssuesGateway,
IssueSummary,
+ SprintsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
@@ -122,6 +123,7 @@ export const IssueStore = signalStore(
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
attachments = inject(AttachmentsGateway),
+ sprints = inject(SprintsGateway),
) => {
/** One file after another: the ticket's version moves with each. */
const uploadAll = (
@@ -410,6 +412,20 @@ export const IssueStore = signalStore(
),
),
),
+ events
+ .on(issuePageEvents.sprintChangeRequested)
+ .pipe(
+ mergeMap(({ payload }) =>
+ runCommand(
+ sprints.setIssueSprint(payload.issueId, payload.sprintId),
+ payload.sprintId
+ ? 'Metido en el sprint'
+ : 'De vuelta al backlog',
+ done,
+ failed,
+ ),
+ ),
+ ),
events
.on(issuePageEvents.releaseChangeRequested)
.pipe(
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 ebdca62..9363e93 100644
--- a/libs/web/tracker/application/src/lib/stores/project.store.ts
+++ b/libs/web/tracker/application/src/lib/stores/project.store.ts
@@ -19,6 +19,9 @@ import {
Release,
ReleaseBurnup,
ReleasesGateway,
+ Sprint,
+ SprintBurndown,
+ SprintsGateway,
TrackerRealtimeGateway,
UserDirectoryEntry,
UsersGateway,
@@ -50,6 +53,10 @@ interface ProjectState {
project: Project | null;
stats: ProjectStats | null;
releases: Release[];
+ /** Under way, planned, then closed; with their progress. */
+ sprints: Sprint[];
+ /** Burndown of the sprint asked for with `burndownRequested`. */
+ burndown: SprintBurndown | null;
activity: Activity[];
userResults: UserDirectoryEntry[];
analytics: ProjectAnalytics | null;
@@ -71,6 +78,8 @@ export const ProjectStore = signalStore(
project: null,
stats: null,
releases: [],
+ sprints: [],
+ burndown: null,
activity: [],
userResults: [],
analytics: null,
@@ -109,6 +118,8 @@ export const ProjectStore = signalStore(
project: null,
stats: null,
releases: [],
+ sprints: [],
+ burndown: null,
activity: [],
analytics: null,
analyticsError: null,
@@ -153,6 +164,10 @@ export const ProjectStore = signalStore(
error: payload,
})),
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
+ on(projectApiEvents.sprintsLoaded, ({ payload }) => ({ sprints: payload })),
+ on(projectApiEvents.burndownLoaded, ({ payload }) => ({
+ burndown: payload,
+ })),
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
releases: payload,
})),
@@ -175,6 +190,7 @@ export const ProjectStore = signalStore(
events = inject(Events),
projects = inject(ProjectsGateway),
releases = inject(ReleasesGateway),
+ sprints = inject(SprintsGateway),
activity = inject(ActivityGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
@@ -191,6 +207,23 @@ export const ProjectStore = signalStore(
}),
);
+ const reloadSprints = (projectId: string) =>
+ sprints.list(projectId).pipe(
+ mapResponse({
+ next: (list) => projectApiEvents.sprintsLoaded(list),
+ error: (error) =>
+ projectApiEvents.commandFailed(describeError(error)),
+ }),
+ );
+ const loadBurndown = (sprintId: string) =>
+ sprints.burndown(sprintId).pipe(
+ mapResponse({
+ next: (burndown) => projectApiEvents.burndownLoaded(burndown),
+ error: (error) =>
+ projectApiEvents.commandFailed(describeError(error)),
+ }),
+ );
+
return {
load$: events.on(projectPageEvents.opened).pipe(
switchMap(({ payload }) =>
@@ -216,6 +249,7 @@ export const ProjectStore = signalStore(
.stats(payload.id)
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
reloadReleases(payload.id),
+ reloadSprints(payload.id),
activity
.list(payload.id)
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
@@ -240,6 +274,9 @@ export const ProjectStore = signalStore(
realtime
.releaseChanges(payload.id)
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
+ realtime
+ .sprintChanges(payload.id)
+ .pipe(map((c) => projectRealtimeEvents.sprintChanged(c))),
),
),
),
@@ -267,6 +304,24 @@ export const ProjectStore = signalStore(
return id ? reloadReleases(id) : [];
}),
),
+ /** Progress and burndown follow the tickets: refetch when they or a sprint change. */
+ refreshSprints$: merge(
+ events.on(projectRealtimeEvents.sprintChanged),
+ events
+ .on(projectRealtimeEvents.activityAdded)
+ .pipe(debounceTime(800)),
+ ).pipe(
+ switchMap(() => {
+ const id = store.project()?.id;
+ const shown = store.burndown()?.sprintId;
+ return id
+ ? merge(reloadSprints(id), shown ? loadBurndown(shown) : [])
+ : [];
+ }),
+ ),
+ burndown$: events
+ .on(projectPageEvents.burndownRequested)
+ .pipe(switchMap(({ payload }) => loadBurndown(payload.sprintId))),
analytics$: events.on(projectPageEvents.analyticsRequested).pipe(
switchMap(({ payload }) =>
projects.analytics(payload.projectId, payload.days).pipe(
@@ -376,6 +431,68 @@ export const ProjectStore = signalStore(
),
),
),
+ events
+ .on(projectPageEvents.sprintCreateRequested)
+ .pipe(
+ mergeMap(({ payload }) =>
+ runCommand(
+ sprints.create(payload),
+ `Sprint ${payload.name} planificado`,
+ projectApiEvents.commandSucceeded,
+ projectApiEvents.commandFailed,
+ ),
+ ),
+ ),
+ events
+ .on(projectPageEvents.sprintUpdateRequested)
+ .pipe(
+ mergeMap(({ payload }) =>
+ runCommand(
+ sprints.update(payload),
+ 'Sprint actualizado',
+ projectApiEvents.commandSucceeded,
+ projectApiEvents.commandFailed,
+ ),
+ ),
+ ),
+ events
+ .on(projectPageEvents.sprintStartRequested)
+ .pipe(
+ mergeMap(({ payload }) =>
+ runCommand(
+ sprints.start(payload.sprintId),
+ 'Sprint en marcha 🏁',
+ projectApiEvents.commandSucceeded,
+ projectApiEvents.commandFailed,
+ ),
+ ),
+ ),
+ events
+ .on(projectPageEvents.sprintCloseRequested)
+ .pipe(
+ mergeMap(({ payload }) =>
+ runCommand(
+ sprints.close(payload.sprintId, payload.carryOverTo),
+ payload.carryOverTo
+ ? 'Sprint cerrado: lo pendiente pasa al siguiente'
+ : 'Sprint cerrado: lo pendiente vuelve al backlog',
+ projectApiEvents.commandSucceeded,
+ projectApiEvents.commandFailed,
+ ),
+ ),
+ ),
+ events
+ .on(projectPageEvents.sprintDeleteRequested)
+ .pipe(
+ mergeMap(({ payload }) =>
+ runCommand(
+ sprints.remove(payload.sprintId),
+ 'Sprint eliminado',
+ projectApiEvents.commandSucceeded,
+ projectApiEvents.commandFailed,
+ ),
+ ),
+ ),
events
.on(projectPageEvents.releasePublishRequested)
.pipe(
diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts
index c99fd23..7a35265 100644
--- a/libs/web/tracker/domain/src/lib/models/enums.ts
+++ b/libs/web/tracker/domain/src/lib/models/enums.ts
@@ -39,6 +39,13 @@ export enum ReleaseStatus {
ARCHIVED = 'ARCHIVED',
}
+export enum SprintStatus {
+ PLANNED = 'PLANNED',
+ /** Under way: one per project at a time. */
+ ACTIVE = 'ACTIVE',
+ CLOSED = 'CLOSED',
+}
+
/** Why someone is told. */
export enum NotificationKind {
ASSIGNED = 'ASSIGNED',
diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts
index 6692274..0513f16 100644
--- a/libs/web/tracker/domain/src/lib/models/inputs.ts
+++ b/libs/web/tracker/domain/src/lib/models/inputs.ts
@@ -86,4 +86,22 @@ export interface BoardFilter {
labels?: string[];
/** Matches key, title or description. */
search?: string;
+ sprintId?: string;
+}
+
+export interface CreateSprintInput {
+ projectId: string;
+ name: string;
+ goal?: string | null;
+ /** `YYYY-MM-DD`. */
+ startDate: string;
+ endDate: string;
+}
+
+export interface UpdateSprintInput {
+ sprintId: string;
+ name?: string;
+ goal?: string | null;
+ startDate?: string;
+ endDate?: string;
}
diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts
index d9a48c2..bbe788f 100644
--- a/libs/web/tracker/domain/src/lib/models/models.ts
+++ b/libs/web/tracker/domain/src/lib/models/models.ts
@@ -4,6 +4,7 @@ import {
IssuePriority,
ProjectRole,
ReleaseStatus,
+ SprintStatus,
StatusCategory,
} from './enums';
import { ProjectSettings } from './workflow';
@@ -107,6 +108,7 @@ export interface IssueSummary {
priority: IssuePriority;
assignee: UserRef | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
labels: string[];
/** The epic or ticket this one breaks down. */
parent: IssueParent | null;
@@ -159,6 +161,40 @@ export interface Comment {
editedAt: string | null;
}
+export interface SprintProgress {
+ total: number;
+ /** Done or cancelled. */
+ finished: number;
+ percent: number;
+ storyPoints: number;
+ finishedStoryPoints: number;
+}
+
+/** A time-boxed iteration of a project. */
+export interface Sprint {
+ id: string;
+ projectId: string;
+ name: string;
+ goal: string | null;
+ /** `YYYY-MM-DD`, both included. */
+ startDate: string;
+ endDate: string;
+ status: SprintStatus;
+ startedAt: string | null;
+ closedAt: string | null;
+ progress: SprintProgress;
+}
+
+/** Per day of a sprint; null for the days to come. */
+export interface SprintBurndown {
+ sprintId: string;
+ dates: string[];
+ scope: (number | null)[];
+ remaining: (number | null)[];
+ remainingPoints: (number | null)[];
+ ideal: number[];
+}
+
export interface Release {
id: string;
projectId: string;
diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts
index 88428dc..caf78f8 100644
--- a/libs/web/tracker/domain/src/lib/ports/gateways.ts
+++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts
@@ -6,6 +6,8 @@ import {
Comment,
Epic,
SavedFilter,
+ Sprint,
+ SprintBurndown,
SavedFilterCriteria,
CommandResult,
Issue,
@@ -32,6 +34,8 @@ import {
MyIssuesFilter,
UpdateIssueInput,
UpdateProjectInput,
+ CreateSprintInput,
+ UpdateSprintInput,
} from '../models/inputs';
/*
@@ -173,6 +177,25 @@ export abstract class ReleasesGateway {
abstract burnup(releaseId: string): Observable;
}
+export abstract class SprintsGateway {
+ /** With their progress: under way, planned, then closed. */
+ abstract list(projectId: string): Observable;
+ abstract burndown(sprintId: string): Observable;
+ abstract create(input: CreateSprintInput): Observable;
+ abstract update(input: UpdateSprintInput): Observable;
+ abstract start(sprintId: string): Observable;
+ /** Unfinished tickets go to `carryOverTo` (null: the backlog). */
+ abstract close(
+ sprintId: string,
+ carryOverTo: string | null,
+ ): Observable;
+ abstract remove(sprintId: string): Observable;
+ abstract setIssueSprint(
+ issueId: string,
+ sprintId: string | null,
+ ): Observable;
+}
+
export abstract class ActivityGateway {
abstract list(projectId: string, issueId?: string): Observable;
}
@@ -219,6 +242,8 @@ export abstract class TrackerRealtimeGateway {
abstract commentChanges(issueId: string): Observable;
abstract activity(projectId: string): Observable;
abstract releaseChanges(projectId: string): Observable;
+ /** Something about a sprint of the project changed (or it was deleted). */
+ abstract sprintChanges(projectId: string): Observable<{ sprintId: string }>;
abstract projectChanges(projectId: string): Observable;
abstract myProjectsChanges(): Observable;
abstract notifications(): Observable;
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 5f4fdfd..bbc180d 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
@@ -59,6 +59,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
labels: filter.labels?.length ? filter.labels : null,
search: filter.search?.trim() || null,
unassigned: filter.unassigned ?? null,
+ sprintId: filter.sprintId ?? null,
})
.pipe(
map((d) =>
diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts
index f1fecbb..5cc9f18 100644
--- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts
+++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts
@@ -18,6 +18,7 @@ import {
NotificationAddedDocument,
ProjectChangedDocument,
ReleaseChangedDocument,
+ SprintChangedDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import {
@@ -85,6 +86,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
);
}
+ sprintChanges(projectId: string): Observable<{ sprintId: string }> {
+ return this.signedIn(() =>
+ this.gql
+ .subscribe(SprintChangedDocument, { projectId })
+ .pipe(map((d) => ({ sprintId: d.sprintChanged.sprintId }))),
+ );
+ }
+
projectChanges(projectId: string): Observable {
return this.signedIn(() =>
this.gql
diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sprints.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sprints.gateway.ts
new file mode 100644
index 0000000..c524a6e
--- /dev/null
+++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sprints.gateway.ts
@@ -0,0 +1,84 @@
+import { inject, Injectable } from '@angular/core';
+import {
+ CommandResult,
+ CreateSprintInput,
+ Sprint,
+ SprintBurndown,
+ SprintsGateway,
+ UpdateSprintInput,
+} from '@task-stream/web/tracker/domain';
+import { map, Observable } from 'rxjs';
+import {
+ CloseSprintDocument,
+ CreateSprintDocument,
+ DeleteSprintDocument,
+ SetIssueSprintDocument,
+ SprintBurndownDocument,
+ SprintsDocument,
+ StartSprintDocument,
+ UpdateSprintDocument,
+} from '../graphql/generated';
+import { GraphqlOperations } from './graphql-operations';
+import { toCommandResult } from './mappers';
+
+@Injectable()
+export class GraphqlSprintsGateway extends SprintsGateway {
+ private readonly gql = inject(GraphqlOperations);
+
+ list(projectId: string): Observable {
+ return this.gql
+ .query(SprintsDocument, { projectId })
+ .pipe(map((d) => d.sprints as Sprint[]));
+ }
+
+ burndown(sprintId: string): Observable {
+ return this.gql
+ .query(SprintBurndownDocument, { id: sprintId })
+ .pipe(map((d) => d.sprint.burndown));
+ }
+
+ create(input: CreateSprintInput): Observable {
+ return this.gql
+ .mutate(CreateSprintDocument, { input })
+ .pipe(map((d) => toCommandResult(d.createSprint)));
+ }
+
+ update(input: UpdateSprintInput): Observable {
+ return this.gql
+ .mutate(UpdateSprintDocument, { input })
+ .pipe(map((d) => toCommandResult(d.updateSprint)));
+ }
+
+ start(sprintId: string): Observable {
+ return this.gql
+ .mutate(StartSprintDocument, { sprintId })
+ .pipe(map((d) => toCommandResult(d.startSprint)));
+ }
+
+ close(
+ sprintId: string,
+ carryOverTo: string | null,
+ ): Observable {
+ return this.gql
+ .mutate(CloseSprintDocument, {
+ sprintId,
+ carryOverToSprintId: carryOverTo,
+ })
+ .pipe(map((d) => toCommandResult(d.closeSprint)));
+ }
+
+ remove(sprintId: string): Observable {
+ return this.gql
+ .mutate(DeleteSprintDocument, { sprintId })
+ .pipe(map((d) => toCommandResult(d.deleteSprint)));
+ }
+
+ setIssueSprint(
+ issueId: string,
+ sprintId: string | null,
+ ): Observable {
+ return this.gql
+ .mutate(SetIssueSprintDocument, { issueId, sprintId })
+ .pipe(map((d) => toCommandResult(d.setIssueSprint)));
+ }
+}
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
index 37da01e..2572800 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
@@ -69,6 +69,10 @@ fragment IssueSummaryFields on Issue {
id
name
}
+ sprint {
+ id
+ name
+ }
parent {
id
key
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
index 2906bc7..0537503 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
@@ -63,11 +63,23 @@ export type CreateReleaseInput = {
targetDate?: string | null | undefined;
};
+export type CreateSprintInput = {
+ /** YYYY-MM-DD, included */
+ endDate: string;
+ goal?: string | null | undefined;
+ name: string;
+ projectId: string;
+ /** YYYY-MM-DD */
+ startDate: string;
+};
+
export type IssueFilterInput = {
areas?: Array | null | undefined;
assigneeId?: string | null | undefined;
categories?: Array | null | undefined;
labels?: Array | null | undefined;
+ /** Only tickets in no sprint */
+ noSprint?: boolean | null | undefined;
/** Only the tickets hanging from this one */
parentId?: string | null | undefined;
priorities?: Array | null | undefined;
@@ -76,6 +88,7 @@ export type IssueFilterInput = {
/** Matches key, title or description */
search?: string | null | undefined;
sort?: IssueSort | null | undefined;
+ sprintId?: string | null | undefined;
statuses?: Array | null | undefined;
types?: Array | null | undefined;
unassigned?: boolean | null | undefined;
@@ -166,6 +179,9 @@ export type SavedFilterCriteriaInput = {
unassigned?: boolean | null | undefined;
};
+/** PLANNED, ACTIVE (one per project at a time) or CLOSED */
+export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
+
/** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */
export type StatusCategory =
| 'CANCELLED'
@@ -208,6 +224,14 @@ export type UpdateProjectInput = {
projectId: string;
};
+export type UpdateSprintInput = {
+ endDate?: string | null | undefined;
+ goal?: string | null | undefined;
+ name?: string | null | undefined;
+ sprintId: string;
+ startDate?: string | null | undefined;
+};
+
export type WorkflowStatusInput = {
archived?: boolean;
category: StatusCategory;
@@ -267,6 +291,7 @@ export type IssueSummaryFieldsFragment = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
};
@@ -311,6 +336,7 @@ export type IssueFieldsFragment = {
}>;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
};
@@ -359,6 +385,7 @@ export type BoardQueryVariables = Exact<{
labels?: Array | string | null | undefined;
search?: string | null | undefined;
unassigned?: boolean | null | undefined;
+ sprintId?: string | null | undefined;
}>;
export type BoardQuery = {
@@ -384,6 +411,7 @@ export type BoardQuery = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
}>;
@@ -436,6 +464,7 @@ export type IssueQuery = {
}>;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
};
};
@@ -463,6 +492,7 @@ export type SearchIssuesQuery = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
};
@@ -614,6 +644,7 @@ export type MyIssuesQuery = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
};
@@ -642,6 +673,7 @@ export type IssueChildrenQuery = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
};
@@ -670,6 +702,7 @@ export type EpicsQuery = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
};
progress: {
@@ -740,6 +773,7 @@ export type BacklogQuery = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
};
@@ -1218,6 +1252,126 @@ export type DeleteSavedFilterMutationVariables = Exact<{
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
+export type SprintFieldsFragment = {
+ id: string;
+ projectId: string;
+ name: string;
+ goal: string | null;
+ startDate: string;
+ endDate: string;
+ status: SprintStatus;
+ startedAt: string | null;
+ closedAt: string | null;
+ progress: {
+ total: number;
+ finished: number;
+ percent: number;
+ storyPoints: number;
+ finishedStoryPoints: number;
+ };
+};
+
+export type SprintsQueryVariables = Exact<{
+ projectId: string;
+}>;
+
+export type SprintsQuery = {
+ sprints: Array<{
+ id: string;
+ projectId: string;
+ name: string;
+ goal: string | null;
+ startDate: string;
+ endDate: string;
+ status: SprintStatus;
+ startedAt: string | null;
+ closedAt: string | null;
+ progress: {
+ total: number;
+ finished: number;
+ percent: number;
+ storyPoints: number;
+ finishedStoryPoints: number;
+ };
+ }>;
+};
+
+export type SprintBurndownQueryVariables = Exact<{
+ id: string;
+}>;
+
+export type SprintBurndownQuery = {
+ sprint: {
+ id: string;
+ burndown: {
+ sprintId: string;
+ dates: Array;
+ scope: Array;
+ remaining: Array;
+ remainingPoints: Array;
+ ideal: Array;
+ };
+ };
+};
+
+export type CreateSprintMutationVariables = Exact<{
+ input: CreateSprintInput;
+}>;
+
+export type CreateSprintMutation = {
+ createSprint: { id: string; key: string | null; version: number };
+};
+
+export type UpdateSprintMutationVariables = Exact<{
+ input: UpdateSprintInput;
+}>;
+
+export type UpdateSprintMutation = {
+ updateSprint: { id: string; key: string | null; version: number };
+};
+
+export type StartSprintMutationVariables = Exact<{
+ sprintId: string;
+}>;
+
+export type StartSprintMutation = {
+ startSprint: { id: string; key: string | null; version: number };
+};
+
+export type CloseSprintMutationVariables = Exact<{
+ sprintId: string;
+ carryOverToSprintId?: string | null | undefined;
+}>;
+
+export type CloseSprintMutation = {
+ closeSprint: { id: string; key: string | null; version: number };
+};
+
+export type DeleteSprintMutationVariables = Exact<{
+ sprintId: string;
+}>;
+
+export type DeleteSprintMutation = {
+ deleteSprint: { id: string; key: string | null; version: number };
+};
+
+export type SetIssueSprintMutationVariables = Exact<{
+ issueId: string;
+ sprintId?: string | null | undefined;
+}>;
+
+export type SetIssueSprintMutation = {
+ setIssueSprint: { id: string; key: string | null; version: number };
+};
+
+export type SprintChangedSubscriptionVariables = Exact<{
+ projectId: string;
+}>;
+
+export type SprintChangedSubscription = {
+ sprintChanged: { action: string; sprintId: string };
+};
+
export type IssueChangedSubscriptionVariables = Exact<{
projectId: string;
}>;
@@ -1244,6 +1398,7 @@ export type IssueChangedSubscription = {
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
+ sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
};
};
@@ -1604,6 +1759,17 @@ export const IssueSummaryFieldsFragmentDoc = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -1807,6 +1973,17 @@ export const IssueFieldsFragmentDoc = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -2097,6 +2274,50 @@ export const SavedFilterFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode;
+export const SprintFieldsFragmentDoc = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'FragmentDefinition',
+ name: { kind: 'Name', value: 'SprintFields' },
+ typeCondition: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'Sprint' },
+ },
+ 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: 'goal' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'endDate' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'status' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'startedAt' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'closedAt' } },
+ {
+ 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: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'finishedStoryPoints' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+} as unknown as DocumentNode;
export const BoardDocument = {
kind: 'Document',
definitions: [
@@ -2207,6 +2428,14 @@ export const BoardDocument = {
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
],
selectionSet: {
kind: 'SelectionSet',
@@ -2296,6 +2525,14 @@ export const BoardDocument = {
name: { kind: 'Name', value: 'unassigned' },
},
},
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'sprintId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ },
{
kind: 'Argument',
name: { kind: 'Name', value: 'perColumn' },
@@ -2397,6 +2634,17 @@ export const BoardDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -2529,6 +2777,17 @@ export const IssueDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -2780,6 +3039,17 @@ export const SearchIssuesDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -3996,6 +4266,17 @@ export const MyIssuesDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -4142,6 +4423,17 @@ export const IssueChildrenDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -4306,6 +4598,17 @@ export const EpicsDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -4774,6 +5077,17 @@ export const BacklogDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
@@ -7624,6 +7938,669 @@ export const DeleteSavedFilterDocument = {
DeleteSavedFilterMutation,
DeleteSavedFilterMutationVariables
>;
+export const SprintsDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'query',
+ name: { kind: 'Name', value: 'Sprints' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'projectId' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprints' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'projectId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'projectId' },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'FragmentSpread',
+ name: { kind: 'Name', value: 'SprintFields' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'FragmentDefinition',
+ name: { kind: 'Name', value: 'SprintFields' },
+ typeCondition: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'Sprint' },
+ },
+ 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: 'goal' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'endDate' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'status' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'startedAt' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'closedAt' } },
+ {
+ 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: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'finishedStoryPoints' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+} as unknown as DocumentNode;
+export const SprintBurndownDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'query',
+ name: { kind: 'Name', value: 'SprintBurndown' },
+ 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: 'sprint' },
+ 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: 'burndown' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ { kind: 'Field', name: { kind: 'Name', value: 'dates' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'scope' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'remaining' },
+ },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'remainingPoints' },
+ },
+ { kind: 'Field', name: { kind: 'Name', value: 'ideal' } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+} as unknown as DocumentNode;
+export const CreateSprintDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'CreateSprint' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'input' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'CreateSprintInput' },
+ },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'createSprint' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'input' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'input' },
+ },
+ },
+ ],
+ 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<
+ CreateSprintMutation,
+ CreateSprintMutationVariables
+>;
+export const UpdateSprintDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'UpdateSprint' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'input' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'UpdateSprintInput' },
+ },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'updateSprint' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'input' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'input' },
+ },
+ },
+ ],
+ 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<
+ UpdateSprintMutation,
+ UpdateSprintMutationVariables
+>;
+export const StartSprintDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'StartSprint' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'startSprint' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'sprintId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ },
+ ],
+ 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;
+export const CloseSprintDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'CloseSprint' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ },
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'carryOverToSprintId' },
+ },
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'closeSprint' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'sprintId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ },
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'carryOverToSprintId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'carryOverToSprintId' },
+ },
+ },
+ ],
+ 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;
+export const DeleteSprintDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'DeleteSprint' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'deleteSprint' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'sprintId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ },
+ ],
+ 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<
+ DeleteSprintMutation,
+ DeleteSprintMutationVariables
+>;
+export const SetIssueSprintDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'SetIssueSprint' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'issueId' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ },
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'setIssueSprint' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'issueId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'issueId' },
+ },
+ },
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'sprintId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'sprintId' },
+ },
+ },
+ ],
+ 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<
+ SetIssueSprintMutation,
+ SetIssueSprintMutationVariables
+>;
+export const SprintChangedDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'subscription',
+ name: { kind: 'Name', value: 'SprintChanged' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'projectId' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprintChanged' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'projectId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'projectId' },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'action' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'sprintId' } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+} as unknown as DocumentNode<
+ SprintChangedSubscription,
+ SprintChangedSubscriptionVariables
+>;
export const IssueChangedDocument = {
kind: 'Document',
definitions: [
@@ -7747,6 +8724,17 @@ export const IssueChangedDocument = {
],
},
},
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'sprint' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql
index bbe2a39..46c0cba 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql
@@ -8,6 +8,7 @@ query Board(
$labels: [String!]
$search: String
$unassigned: Boolean
+ $sprintId: ID
) {
project(idOrKey: $idOrKey) {
id
@@ -20,6 +21,7 @@ query Board(
labels: $labels
search: $search
unassigned: $unassigned
+ sprintId: $sprintId
perColumn: 100
) {
status
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/sprints.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/sprints.graphql
new file mode 100644
index 0000000..8cab134
--- /dev/null
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/sprints.graphql
@@ -0,0 +1,81 @@
+fragment SprintFields on Sprint {
+ id
+ projectId
+ name
+ goal
+ startDate
+ endDate
+ status
+ startedAt
+ closedAt
+ progress {
+ total
+ finished
+ percent
+ storyPoints
+ finishedStoryPoints
+ }
+}
+
+query Sprints($projectId: ID!) {
+ sprints(projectId: $projectId) {
+ ...SprintFields
+ }
+}
+
+query SprintBurndown($id: ID!) {
+ sprint(id: $id) {
+ id
+ burndown {
+ sprintId
+ dates
+ scope
+ remaining
+ remainingPoints
+ ideal
+ }
+ }
+}
+
+mutation CreateSprint($input: CreateSprintInput!) {
+ createSprint(input: $input) {
+ ...CommandResultFields
+ }
+}
+
+mutation UpdateSprint($input: UpdateSprintInput!) {
+ updateSprint(input: $input) {
+ ...CommandResultFields
+ }
+}
+
+mutation StartSprint($sprintId: ID!) {
+ startSprint(sprintId: $sprintId) {
+ ...CommandResultFields
+ }
+}
+
+mutation CloseSprint($sprintId: ID!, $carryOverToSprintId: ID) {
+ closeSprint(sprintId: $sprintId, carryOverToSprintId: $carryOverToSprintId) {
+ ...CommandResultFields
+ }
+}
+
+mutation DeleteSprint($sprintId: ID!) {
+ deleteSprint(sprintId: $sprintId) {
+ ...CommandResultFields
+ }
+}
+
+mutation SetIssueSprint($issueId: ID!, $sprintId: ID) {
+ setIssueSprint(issueId: $issueId, sprintId: $sprintId) {
+ ...CommandResultFields
+ }
+}
+
+subscription SprintChanged($projectId: ID!) {
+ sprintChanged(projectId: $projectId) {
+ action
+ sprintId
+ }
+}
diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts
index 53c7e99..540bbb1 100644
--- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts
+++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts
@@ -11,6 +11,7 @@ import {
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
+ SprintsGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -26,6 +27,7 @@ import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
+import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
/** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders {
@@ -40,5 +42,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
+ { provide: SprintsGateway, useClass: GraphqlSprintsGateway },
]);
}
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 acac6af..8b30497 100644
--- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts
@@ -137,6 +137,12 @@ const NOBODY = '__nobody__';
[options]="priorityFilterOptions"
[(value)]="priorityFilter"
/>
+
();
/** `?assignee=` (from the tracking page's team list), bound by the router. */
readonly assignee = input();
+ /** `?sprint=` (from the sprints page), bound by the router. */
+ readonly sprint = input();
protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade);
@@ -353,6 +361,16 @@ export class BoardPage {
protected readonly releaseFilter = signal(ALL);
protected readonly area = signal(ALL);
protected readonly typeFilter = signal(ALL);
+ protected readonly sprintFilter = signal(ALL);
+ protected readonly sprintFilterOptions = computed<
+ PiSelectFieldOption[]
+ >(() => [
+ { value: ALL, label: 'Todos los sprints' },
+ ...this.project.openSprints().map((s) => ({
+ value: s.id,
+ label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
+ })),
+ ]);
protected readonly priorityFilter = signal(ALL);
/** What is typed; `search` follows it once typing pauses. */
protected readonly searchInput = signal('');
@@ -364,6 +382,7 @@ export class BoardPage {
!!(
this.assigneeFilter() ||
this.releaseFilter() ||
+ this.sprintFilter() ||
this.area() ||
this.typeFilter() ||
this.priorityFilter() ||
@@ -485,6 +504,10 @@ export class BoardPage {
const assignee = this.assignee();
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
});
+ effect(() => {
+ const sprint = this.sprint();
+ if (sprint) untracked(() => this.sprintFilter.set(sprint));
+ });
effect((onCleanup) => {
const text = this.searchInput().trim();
const timer = setTimeout(() => this.search.set(text), 300);
@@ -505,6 +528,7 @@ export class BoardPage {
priorities: priority ? [priority] : undefined,
labels: this.labels().length ? this.labels() : undefined,
search: this.search() || undefined,
+ sprintId: this.sprintFilter() || undefined,
};
if (!projectId) return;
untracked(() =>
@@ -569,6 +593,7 @@ export class BoardPage {
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
);
this.releaseFilter.set(filter.releaseId ?? ALL);
+ this.sprintFilter.set(ALL);
this.area.set(filter.area ?? ALL);
this.typeFilter.set(filter.types?.[0] ?? ALL);
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
@@ -581,6 +606,7 @@ export class BoardPage {
for (const filter of [
this.assigneeFilter,
this.releaseFilter,
+ this.sprintFilter,
this.area,
this.typeFilter,
this.priorityFilter,
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 95a93f2..f2658be 100644
--- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts
@@ -594,6 +594,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>
Release
+
+ Sprint
+
({ value: r.id, label: r.name })),
],
);
+ /** Open sprints, plus the ticket's own even if it closed since. */
+ protected readonly sprintOptions = computed[]>(
+ () => {
+ const current = this.facade.issue()?.sprint;
+ const open = this.project.openSprints();
+ return [
+ { value: NONE, label: 'Backlog (sin sprint)' },
+ ...open.map((s) => ({
+ value: s.id,
+ label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
+ })),
+ ...(current && !open.some((s) => s.id === current.id)
+ ? [{ value: current.id, label: `${current.name} · cerrado` }]
+ : []),
+ ];
+ },
+ );
+ /** A closed sprint keeps its tickets. */
+ protected readonly sprintLocked = computed(() => {
+ const current = this.facade.issue()?.sprint;
+ return (
+ !!current && !this.project.openSprints().some((s) => s.id === current.id)
+ );
+ });
protected readonly priorityOptions = options(PRIORITY);
/** Active values, plus the ticket's own even if archived since. */
protected readonly typeOptions = computed[]>(
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 58258ef..0ae5382 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
@@ -86,6 +86,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
}
@if (tip.total !== null) {
@@ -211,7 +215,7 @@ function niceTicks(max: number): number[] {
| {{ label }} |
@for (s of series(); track s.label) {
- {{ s.values[i] }} |
+ {{ s.values[i] ?? '—' }} |
}
}
@@ -282,6 +286,10 @@ function niceTicks(max: number): number[] {
stroke-linejoin: round;
stroke-linecap: round;
}
+ .ts-chart__line--dashed {
+ stroke-width: 1.5;
+ stroke-dasharray: 5 4;
+ }
.ts-chart__wash {
fill-opacity: 0.1;
}
@@ -431,13 +439,25 @@ export class SeriesChartComponent {
let base = new Array(this.count()).fill(0) as number[];
return this.series().map((s) => {
const lower = this.stacked() ? base : base.map(() => 0);
- const upper = s.values.map((v, i) => (this.stacked() ? lower[i] : 0) + v);
- if (this.stacked()) base = upper;
+ // Stacked bands have no gaps: a missing value adds nothing.
+ const upper: (number | null)[] = s.values.map((v, i) =>
+ v === null && !this.stacked()
+ ? null
+ : (this.stacked() ? lower[i] : 0) + (v ?? 0),
+ );
+ if (this.stacked()) base = upper as number[];
return { ...s, lower, upper };
});
});
protected readonly ticks = computed(() =>
- niceTicks(Math.max(0, ...this.bands().flatMap((b) => b.upper))),
+ niceTicks(
+ Math.max(
+ 0,
+ ...this.bands().flatMap((b) =>
+ b.upper.filter((v): v is number => v !== null),
+ ),
+ ),
+ ),
);
private readonly max = computed(() => last(this.ticks()) || 1);
@@ -482,10 +502,19 @@ export class SeriesChartComponent {
protected readonly shapes = computed(() =>
this.bands().map((band) => {
- const points = band.upper.map((v, i) => `${this.x(i)},${this.y(v)}`);
- const line = points.length ? `M${points.join('L')}` : '';
+ const gaps = band.upper.some((v) => v === null);
+ // A gap starts a new stroke ("M") where the values come back.
+ const line = band.upper
+ .map((v, i) =>
+ v === null
+ ? null
+ : `${i === 0 || band.upper[i - 1] === null ? 'M' : 'L'}${this.x(i)},${this.y(v)}`,
+ )
+ .filter(Boolean)
+ .join('');
+ const points = band.upper.map((v, i) => `${this.x(i)},${this.y(v ?? 0)}`);
let fill: string | null = null;
- if (this.stacked() || band.area) {
+ if (!gaps && (this.stacked() || band.area)) {
const floor = band.lower
.map((v, i) => `${this.x(i)},${this.y(v)}`)
.reverse();
@@ -493,7 +522,13 @@ export class SeriesChartComponent {
? `M${points.join('L')}L${floor.join('L')}Z`
: null;
}
- return { label: band.label, color: band.color, line, fill };
+ return {
+ label: band.label,
+ color: band.color,
+ dashed: !!band.dashed,
+ line,
+ fill,
+ };
}),
);
@@ -501,10 +536,10 @@ export class SeriesChartComponent {
protected readonly endLabels = computed(() => {
if (this.stacked() || this.count() === 0) return [];
const labels = this.bands()
- .map((band) => ({
- text: String(last(band.upper) ?? 0),
- y: this.y(last(band.upper) ?? 0),
- }))
+ .map((band) => {
+ const value = last(band.upper.filter((v) => v !== null)) ?? 0;
+ return { text: String(value), y: this.y(value) };
+ })
.sort((a, b) => a.y - b.y);
const apart = labels.every(
(l, i) => i === 0 || l.y - labels[i - 1].y >= 14,
@@ -515,11 +550,13 @@ export class SeriesChartComponent {
protected readonly dots = computed(() => {
const index = this.hover();
if (index === null) return [];
- return this.bands().map((band) => ({
- label: band.label,
- color: band.color,
- y: this.y(band.upper[index] ?? 0),
- }));
+ return this.bands()
+ .filter((band) => band.upper[index] !== null)
+ .map((band) => ({
+ label: band.label,
+ color: band.color,
+ y: this.y(band.upper[index] ?? 0),
+ }));
});
protected readonly tooltip = computed(() => {
@@ -534,7 +571,9 @@ export class SeriesChartComponent {
return {
title: this.fullLabels()[index],
rows,
- total: this.stacked() ? rows.reduce((sum, r) => sum + r.value, 0) : null,
+ total: this.stacked()
+ ? rows.reduce((sum, r) => sum + (r.value ?? 0), 0)
+ : null,
x,
left: x > this.width() * 0.6,
};