feat: «Mi día», the few tickets I pick for today
A home panel with the tickets picked for today (10 at most), crossed out
as they get done and counted («1 de 3 terminados»), my open tickets to
add with one click, and what was left unfinished on my last day to carry
over. «☀ Mi día» on a ticket adds it or takes it out. The day is the
person's own (YYYY-MM-DD from the browser); private, no history (Mongo
`my_days`).
API: myDay(day), addToMyDay, removeFromMyDay; GET /api/me/day,
PUT/DELETE /api/me/day/{issue}. MCP: my_day.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2776,6 +2776,294 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/me/day": {
|
||||||
|
"get": {
|
||||||
|
"description": "And what was left unfinished on my latest earlier day.",
|
||||||
|
"operationId": "IssuesController_myDay",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "day",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Today, in my time zone",
|
||||||
|
"schema": {
|
||||||
|
"example": "2026-10-20",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MyDayDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "«Mi día»: the tickets I picked for today",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/me/day/{issue}": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "IssuesController_addToMyDay",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "issue",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Ticket id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB-12",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "day",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Today, in my time zone",
|
||||||
|
"schema": {
|
||||||
|
"example": "2026-10-20",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"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": "Add a ticket to «Mi día» (10 at most)",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "IssuesController_removeFromMyDay",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "issue",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Ticket id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB-12",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "day",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Today, in my time zone",
|
||||||
|
"schema": {
|
||||||
|
"example": "2026-10-20",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"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": "Take a ticket out of «Mi día»",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/me/reminders": {
|
"/api/me/reminders": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "IssuesController_myReminders",
|
"operationId": "IssuesController_myReminders",
|
||||||
@@ -16339,6 +16627,53 @@
|
|||||||
"at"
|
"at"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"MyDayLeftoverDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"day": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/IssueDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"day",
|
||||||
|
"issues"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MyDayDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"day": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "YYYY-MM-DD, in my time zone"
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/IssueDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"leftover": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MyDayLeftoverDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"day",
|
||||||
|
"issues",
|
||||||
|
"leftover"
|
||||||
|
]
|
||||||
|
},
|
||||||
"IssueReminderDto": {
|
"IssueReminderDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -957,6 +957,9 @@ type Mutation {
|
|||||||
"""Add a Pireon user to the project"""
|
"""Add a Pireon user to the project"""
|
||||||
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
||||||
|
|
||||||
|
"""Add tickets to «Mi día» (10 at most)"""
|
||||||
|
addToMyDay(day: String!, issueIds: [ID!]!): Boolean!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Archive the project (owner only): everything stays readable, nothing changes until it is unarchived
|
Archive the project (owner only): everything stays readable, nothing changes until it is unarchived
|
||||||
"""
|
"""
|
||||||
@@ -1073,6 +1076,9 @@ type Mutation {
|
|||||||
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
||||||
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Take a ticket out of «Mi día»"""
|
||||||
|
removeFromMyDay(day: String!, issueId: ID!): Boolean!
|
||||||
|
|
||||||
"""Remove a repository; what it linked stays on the tickets"""
|
"""Remove a repository; what it linked stays on the tickets"""
|
||||||
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
|
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
|
||||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||||
@@ -1210,6 +1216,22 @@ type Mutation {
|
|||||||
writeReleaseNotes(notes: String, releaseId: ID!): CommandResult!
|
writeReleaseNotes(notes: String, releaseId: ID!): CommandResult!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""«Mi día»: the tickets I picked for today"""
|
||||||
|
type MyDay {
|
||||||
|
"""YYYY-MM-DD, in my time zone"""
|
||||||
|
day: String!
|
||||||
|
|
||||||
|
"""In order, done ones included"""
|
||||||
|
issues: [Issue!]!
|
||||||
|
leftover: MyDayLeftover
|
||||||
|
}
|
||||||
|
|
||||||
|
"""What was left unfinished on the latest earlier day"""
|
||||||
|
type MyDayLeftover {
|
||||||
|
day: String!
|
||||||
|
issues: [Issue!]!
|
||||||
|
}
|
||||||
|
|
||||||
type MyNotifications {
|
type MyNotifications {
|
||||||
items: [Notification!]!
|
items: [Notification!]!
|
||||||
|
|
||||||
@@ -1625,6 +1647,14 @@ type Query {
|
|||||||
"""My calendar URL, if I made one"""
|
"""My calendar URL, if I made one"""
|
||||||
myCalendarFeed: CalendarFeed
|
myCalendarFeed: CalendarFeed
|
||||||
|
|
||||||
|
"""
|
||||||
|
«Mi día»: the tickets I picked for today, and what was left on my last day
|
||||||
|
"""
|
||||||
|
myDay(
|
||||||
|
"""Today, YYYY-MM-DD in my time zone"""
|
||||||
|
day: String!
|
||||||
|
): MyDay!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now
|
The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -502,6 +502,44 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/me/day": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* «Mi día»: the tickets I picked for today
|
||||||
|
* @description And what was left unfinished on my latest earlier day.
|
||||||
|
*/
|
||||||
|
get: operations["IssuesController_myDay"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/me/day/{issue}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/** Add a ticket to «Mi día» (10 at most) */
|
||||||
|
put: operations["IssuesController_addToMyDay"];
|
||||||
|
post?: never;
|
||||||
|
/** Take a ticket out of «Mi día» */
|
||||||
|
delete: operations["IssuesController_removeFromMyDay"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/me/reminders": {
|
"/api/me/reminders": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2951,6 +2989,16 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
at: string;
|
at: string;
|
||||||
};
|
};
|
||||||
|
MyDayLeftoverDto: {
|
||||||
|
day: string;
|
||||||
|
issues: components["schemas"]["IssueDto"][];
|
||||||
|
};
|
||||||
|
MyDayDto: {
|
||||||
|
/** @description YYYY-MM-DD, in my time zone */
|
||||||
|
day: string;
|
||||||
|
issues: components["schemas"]["IssueDto"][];
|
||||||
|
leftover: components["schemas"]["MyDayLeftoverDto"] | null;
|
||||||
|
};
|
||||||
IssueReminderDto: {
|
IssueReminderDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
at: string;
|
at: string;
|
||||||
@@ -5809,6 +5857,218 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
IssuesController_myDay: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description Today, in my time zone */
|
||||||
|
day: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MyDayDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
IssuesController_addToMyDay: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description Today, in my time zone */
|
||||||
|
day: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
IssuesController_removeFromMyDay: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description Today, in my time zone */
|
||||||
|
day: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
IssuesController_myReminders: {
|
IssuesController_myReminders: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -783,6 +783,50 @@ export class IssuesResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'my_day',
|
||||||
|
description:
|
||||||
|
'«Mi día»: the tickets I picked for today (done ones marked), and what was left unfinished on my last day. Optionally add or remove one first. day is today in my time zone, YYYY-MM-DD.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
add: issueRef.optional(),
|
||||||
|
remove: issueRef.optional(),
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
myDay(
|
||||||
|
params: { day: string; add?: string; remove?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const client = this.api.as(ctx);
|
||||||
|
const query = { day: params.day };
|
||||||
|
return toolResult(async () => {
|
||||||
|
if (params.add)
|
||||||
|
await this.api.data(
|
||||||
|
client.PUT('/api/me/day/{issue}', {
|
||||||
|
params: { path: { issue: params.add }, query },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (params.remove)
|
||||||
|
await this.api.data(
|
||||||
|
client.DELETE('/api/me/day/{issue}', {
|
||||||
|
params: { path: { issue: params.remove }, query },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const myDay = await this.api.data(
|
||||||
|
client.GET('/api/me/day', { params: { query } }),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
day: myDay.day,
|
||||||
|
issues: myDay.issues.map(issueLine),
|
||||||
|
leftover: myDay.leftover && {
|
||||||
|
day: myDay.leftover.day,
|
||||||
|
issues: myDay.leftover.issues.map(issueLine),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Tool({
|
@Tool({
|
||||||
name: 'remind_me',
|
name: 'remind_me',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
| 🔎 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 |
|
| 🔎 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 |
|
||||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||||
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||||
|
| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
|
||||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
|
|||||||
@@ -425,7 +425,11 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
|
|||||||
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
||||||
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
||||||
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||||
`?watching=true`; sin sesión, ninguno). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional
|
`?watching=true`; sin sesión, ninguno). **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`,
|
||||||
|
`removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es
|
||||||
|
hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae
|
||||||
|
también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin
|
||||||
|
historial (`my_days`). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional
|
||||||
(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE
|
(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE
|
||||||
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
|
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
|
||||||
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
|
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
|
||||||
@@ -549,17 +553,17 @@ que funcionan también sin token.
|
|||||||
|
|
||||||
Todas devuelven `CommandResult { id, key, version }`.
|
Todas devuelven `CommandResult { id, key, version }`.
|
||||||
|
|
||||||
| Ámbito | Mutations |
|
| Ámbito | Mutations |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||||
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
|
||||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
@@ -757,25 +761,25 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
|||||||
|
|
||||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||||
| 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` |
|
| 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}` |
|
| 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` |
|
| 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}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
|
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
|
||||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
|
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
|
||||||
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||||
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||||
|
|||||||
@@ -300,25 +300,25 @@ visualización de la plataforma:
|
|||||||
|
|
||||||
## 4.6 Rutas
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||||
| `/explore` | Proyectos públicos (sin sesión) |
|
| `/explore` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional |
|
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/releases` | Releases |
|
||||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||||
| `/p/:key/activity` | Actividad en vivo |
|
| `/p/:key/activity` | Actividad en vivo |
|
||||||
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
||||||
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
||||||
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
||||||
|
|
||||||
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||||
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ flowchart LR
|
|||||||
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
||||||
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
|
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
|
||||||
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
|
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
|
||||||
|
| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno |
|
||||||
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export * from './lib/tree-follows.service';
|
|||||||
export * from './lib/reminders/issue-reminders';
|
export * from './lib/reminders/issue-reminders';
|
||||||
export * from './lib/ports/issue-reminders.repository';
|
export * from './lib/ports/issue-reminders.repository';
|
||||||
export * from './lib/issue-reminders.service';
|
export * from './lib/issue-reminders.service';
|
||||||
|
export * from './lib/my-day/my-day';
|
||||||
|
export * from './lib/ports/my-days.repository';
|
||||||
|
export * from './lib/my-day.service';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
export * from './lib/ports/presence.store';
|
export * from './lib/ports/presence.store';
|
||||||
export * from './lib/presence.service';
|
export * from './lib/presence.service';
|
||||||
|
|||||||
32
libs/api/tracker/application/src/lib/my-day.service.ts
Normal file
32
libs/api/tracker/application/src/lib/my-day.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { QueryBus } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
AddToMyDayCommand,
|
||||||
|
GetMyDayQuery,
|
||||||
|
MyDay,
|
||||||
|
RemoveFromMyDayCommand,
|
||||||
|
} from './my-day/my-day';
|
||||||
|
|
||||||
|
/** «Mi día»: the tickets I picked for today. */
|
||||||
|
@Injectable()
|
||||||
|
export class MyDayService {
|
||||||
|
constructor(
|
||||||
|
private readonly commands: CommandRunner,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get(userId: string, day: string): Promise<MyDay> {
|
||||||
|
return this.queryBus.execute(new GetMyDayQuery(userId, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
add(userId: string, day: string, issueIds: string[]): Promise<void> {
|
||||||
|
return this.commands.execute(new AddToMyDayCommand(userId, day, issueIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(userId: string, day: string, issueId: string): Promise<void> {
|
||||||
|
return this.commands.execute(
|
||||||
|
new RemoveFromMyDayCommand(userId, day, issueId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
libs/api/tracker/application/src/lib/my-day/my-day.spec.ts
Normal file
145
libs/api/tracker/application/src/lib/my-day/my-day.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
CommandBus,
|
||||||
|
CqrsModule,
|
||||||
|
IQueryHandler,
|
||||||
|
QueryBus,
|
||||||
|
QueryHandler,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
StatusCategory,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository';
|
||||||
|
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||||
|
import {
|
||||||
|
AddToMyDayCommand,
|
||||||
|
GetMyDayQuery,
|
||||||
|
MAX_MY_DAY,
|
||||||
|
MY_DAY,
|
||||||
|
MyDay,
|
||||||
|
RemoveFromMyDayCommand,
|
||||||
|
} from './my-day';
|
||||||
|
|
||||||
|
class InMemoryDays extends MyDaysRepository {
|
||||||
|
rows: MyDayRecord[] = [];
|
||||||
|
async find(userId: string, day: string) {
|
||||||
|
return this.rows.find((r) => r.userId === userId && r.day === day) ?? null;
|
||||||
|
}
|
||||||
|
async latestBefore(userId: string, day: string) {
|
||||||
|
return (
|
||||||
|
this.rows
|
||||||
|
.filter((r) => r.userId === userId && r.day < day)
|
||||||
|
.sort((a, b) => b.day.localeCompare(a.day))[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async save(record: MyDayRecord) {
|
||||||
|
this.rows = [
|
||||||
|
...this.rows.filter(
|
||||||
|
(r) => !(r.userId === record.userId && r.day === record.day),
|
||||||
|
),
|
||||||
|
record,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ana reads i0…i11; i3 is done. */
|
||||||
|
const issues = new Map<string, IssueReadModel>();
|
||||||
|
|
||||||
|
@QueryHandler(GetIssuesByIdsQuery)
|
||||||
|
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
|
||||||
|
async execute(query: GetIssuesByIdsQuery) {
|
||||||
|
return query.viewerId === 'ana'
|
||||||
|
? query.ids.flatMap((id) => issues.get(id) ?? [])
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = (offset: number) =>
|
||||||
|
new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
issues.clear();
|
||||||
|
for (let i = 0; i < 12; i++)
|
||||||
|
issues.set(`i${i}`, {
|
||||||
|
id: `i${i}`,
|
||||||
|
key: `WEB-${i}`,
|
||||||
|
statusCategory: i === 3 ? StatusCategory.DONE : StatusCategory.TODO,
|
||||||
|
trashedAt: null,
|
||||||
|
} as IssueReadModel);
|
||||||
|
const rows = new InMemoryDays();
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [CqrsModule.forRoot()],
|
||||||
|
providers: [
|
||||||
|
...MY_DAY,
|
||||||
|
FakeIssuesByIds,
|
||||||
|
{ provide: MyDaysRepository, useValue: rows },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
await moduleRef.init();
|
||||||
|
const commands = moduleRef.get(CommandBus);
|
||||||
|
const queries = moduleRef.get(QueryBus);
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
commands,
|
||||||
|
keys: async (d: string) => {
|
||||||
|
const myDay: MyDay = await queries.execute(new GetMyDayQuery('ana', d));
|
||||||
|
return {
|
||||||
|
issues: myDay.issues.map((i) => i.key),
|
||||||
|
leftover: myDay.leftover?.issues.map((i) => i.key) ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Mi día', () => {
|
||||||
|
it('keeps the tickets picked for today, in order, once each', async () => {
|
||||||
|
const { commands, keys } = await setup();
|
||||||
|
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i2']));
|
||||||
|
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i2', 'i3']));
|
||||||
|
await commands.execute(new RemoveFromMyDayCommand('ana', day(0), 'i1'));
|
||||||
|
expect((await keys(day(0))).issues).toEqual(['WEB-2', 'WEB-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`takes ${MAX_MY_DAY} at most, only readable tickets, only around today`, async () => {
|
||||||
|
const { commands } = await setup();
|
||||||
|
const many = Array.from({ length: MAX_MY_DAY + 1 }, (_, i) => `i${i}`);
|
||||||
|
await expect(
|
||||||
|
commands.execute(new AddToMyDayCommand('ana', day(0), many)),
|
||||||
|
).rejects.toBeInstanceOf(BusinessRuleError);
|
||||||
|
await expect(
|
||||||
|
commands.execute(new AddToMyDayCommand('ana', day(0), ['nope'])),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
await expect(
|
||||||
|
commands.execute(new AddToMyDayCommand('ana', day(5), ['i1'])),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
await expect(
|
||||||
|
commands.execute(new AddToMyDayCommand('ana', '20-10-2026', ['i1'])),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers what was left unfinished on the last day, but not the done ones', async () => {
|
||||||
|
const { commands, rows, keys } = await setup();
|
||||||
|
rows.rows.push({
|
||||||
|
userId: 'ana',
|
||||||
|
day: day(-1),
|
||||||
|
issueIds: ['i1', 'i3', 'i4'],
|
||||||
|
updatedAt: '',
|
||||||
|
});
|
||||||
|
expect(await keys(day(0))).toEqual({
|
||||||
|
issues: [],
|
||||||
|
leftover: ['WEB-1', 'WEB-4'],
|
||||||
|
});
|
||||||
|
// Carried over: no longer offered.
|
||||||
|
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i4']));
|
||||||
|
expect(await keys(day(0))).toEqual({
|
||||||
|
issues: ['WEB-1', 'WEB-4'],
|
||||||
|
leftover: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
194
libs/api/tracker/application/src/lib/my-day/my-day.ts
Normal file
194
libs/api/tracker/application/src/lib/my-day/my-day.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandHandler,
|
||||||
|
ICommandHandler,
|
||||||
|
IQueryHandler,
|
||||||
|
Query,
|
||||||
|
QueryBus,
|
||||||
|
QueryHandler,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
NotFoundError,
|
||||||
|
assertThat,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
StatusCategory,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository';
|
||||||
|
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* «Mi día»: the few tickets someone picks each morning for today, and at
|
||||||
|
* the end what got done. The day is the person's own (`YYYY-MM-DD` from
|
||||||
|
* their browser). Private, no history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_MY_DAY = 10;
|
||||||
|
/** How far back what was left counts as «from before». */
|
||||||
|
const CARRY_OVER_DAYS = 7;
|
||||||
|
|
||||||
|
const DAY_FORMAT = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
const finished = (issue: IssueReadModel) =>
|
||||||
|
issue.statusCategory === StatusCategory.DONE ||
|
||||||
|
issue.statusCategory === StatusCategory.CANCELLED;
|
||||||
|
|
||||||
|
/** A day of the person's: well formed and near today (time zones apart). */
|
||||||
|
export function assertDay(day: string, now = Date.now()): void {
|
||||||
|
const at = Date.parse(`${day}T12:00:00Z`);
|
||||||
|
assertThat(DAY_FORMAT.test(day) && !Number.isNaN(at), 'A day is YYYY-MM-DD');
|
||||||
|
assertThat(
|
||||||
|
Math.abs(at - now) <= 2 * DAY_MS,
|
||||||
|
'Only today can be planned (in your time zone)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyDay {
|
||||||
|
day: string;
|
||||||
|
/** Picked for the day, in order (done ones included). */
|
||||||
|
issues: IssueReadModel[];
|
||||||
|
/** What was left unfinished on the latest earlier day, if recent. */
|
||||||
|
leftover: { day: string; issues: IssueReadModel[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddToMyDayCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly day: string,
|
||||||
|
/** Several at once: carrying over what was left. */
|
||||||
|
public readonly issueIds: string[],
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoveFromMyDayCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly day: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetMyDayQuery extends Query<MyDay> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly day: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(AddToMyDayCommand)
|
||||||
|
export class AddToMyDayCommandHandler
|
||||||
|
implements ICommandHandler<AddToMyDayCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly days: MyDaysRepository,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: AddToMyDayCommand): Promise<void> {
|
||||||
|
assertDay(command.day);
|
||||||
|
const wanted = [...new Set(command.issueIds)];
|
||||||
|
const readable: IssueReadModel[] = await this.queryBus.execute(
|
||||||
|
new GetIssuesByIdsQuery(command.userId, wanted),
|
||||||
|
);
|
||||||
|
const ok = new Set(readable.filter((i) => !i.trashedAt).map((i) => i.id));
|
||||||
|
// One I cannot read does not exist, as far as I know.
|
||||||
|
const missing = wanted.find((id) => !ok.has(id));
|
||||||
|
if (missing) throw new NotFoundError('Issue', missing);
|
||||||
|
const current = await this.days.find(command.userId, command.day);
|
||||||
|
const issueIds = [
|
||||||
|
...(current?.issueIds ?? []),
|
||||||
|
...wanted.filter((id) => !current?.issueIds.includes(id)),
|
||||||
|
];
|
||||||
|
if (issueIds.length > MAX_MY_DAY)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`Mi día takes ${MAX_MY_DAY} tickets at most: finish or take some out first`,
|
||||||
|
);
|
||||||
|
await this.days.save({
|
||||||
|
userId: command.userId,
|
||||||
|
day: command.day,
|
||||||
|
issueIds,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(RemoveFromMyDayCommand)
|
||||||
|
export class RemoveFromMyDayCommandHandler
|
||||||
|
implements ICommandHandler<RemoveFromMyDayCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly days: MyDaysRepository) {}
|
||||||
|
|
||||||
|
async execute(command: RemoveFromMyDayCommand): Promise<void> {
|
||||||
|
assertDay(command.day);
|
||||||
|
const current = await this.days.find(command.userId, command.day);
|
||||||
|
if (!current?.issueIds.includes(command.issueId)) return;
|
||||||
|
await this.days.save({
|
||||||
|
...current,
|
||||||
|
issueIds: current.issueIds.filter((id) => id !== command.issueId),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetMyDayQuery)
|
||||||
|
export class GetMyDayQueryHandler
|
||||||
|
implements IQueryHandler<GetMyDayQuery, MyDay>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly days: MyDaysRepository,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetMyDayQuery): Promise<MyDay> {
|
||||||
|
assertDay(query.day);
|
||||||
|
const [today, before] = await Promise.all([
|
||||||
|
this.days.find(query.userId, query.day),
|
||||||
|
this.days.latestBefore(query.userId, query.day),
|
||||||
|
]);
|
||||||
|
const recent =
|
||||||
|
before &&
|
||||||
|
Date.parse(`${query.day}T00:00:00Z`) -
|
||||||
|
Date.parse(`${before.day}T00:00:00Z`) <=
|
||||||
|
CARRY_OVER_DAYS * DAY_MS
|
||||||
|
? before
|
||||||
|
: null;
|
||||||
|
const ids = [
|
||||||
|
...new Set([...(today?.issueIds ?? []), ...(recent?.issueIds ?? [])]),
|
||||||
|
];
|
||||||
|
const found: IssueReadModel[] = ids.length
|
||||||
|
? await this.queryBus.execute(new GetIssuesByIdsQuery(query.userId, ids))
|
||||||
|
: [];
|
||||||
|
const byId = new Map(
|
||||||
|
found.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
|
||||||
|
);
|
||||||
|
const pick = (record: MyDayRecord | null) =>
|
||||||
|
(record?.issueIds ?? []).flatMap((id) => byId.get(id) ?? []);
|
||||||
|
const issues = pick(today);
|
||||||
|
const leftover = pick(recent).filter(
|
||||||
|
(i) => !finished(i) && !today?.issueIds.includes(i.id),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
day: query.day,
|
||||||
|
issues,
|
||||||
|
leftover:
|
||||||
|
recent && leftover.length
|
||||||
|
? { day: recent.day, issues: leftover }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MY_DAY = [
|
||||||
|
AddToMyDayCommandHandler,
|
||||||
|
RemoveFromMyDayCommandHandler,
|
||||||
|
GetMyDayQueryHandler,
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** The tickets someone picked for one day of theirs. */
|
||||||
|
export interface MyDayRecord {
|
||||||
|
userId: string;
|
||||||
|
/** `YYYY-MM-DD` in the person's own time zone. */
|
||||||
|
day: string;
|
||||||
|
/** In the order they were picked. */
|
||||||
|
issueIds: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class MyDaysRepository {
|
||||||
|
abstract find(userId: string, day: string): Promise<MyDayRecord | null>;
|
||||||
|
/** The latest day before `day`, if any (to carry over what was left). */
|
||||||
|
abstract latestBefore(
|
||||||
|
userId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<MyDayRecord | null>;
|
||||||
|
abstract save(record: MyDayRecord): Promise<void>;
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ import { PINS } from './saved-filters/pins';
|
|||||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||||
import { TREE_FOLLOWS } from './follows/tree-follows';
|
import { TREE_FOLLOWS } from './follows/tree-follows';
|
||||||
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
|
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
|
||||||
|
import { MY_DAY } from './my-day/my-day';
|
||||||
|
import { MyDayService } from './my-day.service';
|
||||||
import { IssueRemindersService } from './issue-reminders.service';
|
import { IssueRemindersService } from './issue-reminders.service';
|
||||||
import { TreeFollowsService } from './tree-follows.service';
|
import { TreeFollowsService } from './tree-follows.service';
|
||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
@@ -90,6 +92,7 @@ const SERVICES = [
|
|||||||
AlertsService,
|
AlertsService,
|
||||||
TreeFollowsService,
|
TreeFollowsService,
|
||||||
IssueRemindersService,
|
IssueRemindersService,
|
||||||
|
MyDayService,
|
||||||
SprintsService,
|
SprintsService,
|
||||||
GitService,
|
GitService,
|
||||||
WebhooksService,
|
WebhooksService,
|
||||||
@@ -124,6 +127,7 @@ const SERVICES = [
|
|||||||
...ALERT_HANDLERS,
|
...ALERT_HANDLERS,
|
||||||
...TREE_FOLLOWS,
|
...TREE_FOLLOWS,
|
||||||
...ISSUE_REMINDERS,
|
...ISSUE_REMINDERS,
|
||||||
|
...MY_DAY,
|
||||||
...GIT_HANDLERS,
|
...GIT_HANDLERS,
|
||||||
...WEBHOOK_HANDLERS,
|
...WEBHOOK_HANDLERS,
|
||||||
...TRANSFER_HANDLERS,
|
...TRANSFER_HANDLERS,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
MyDayRecord,
|
||||||
|
MyDaysRepository,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { ReadModels } from './read-model.schemas';
|
||||||
|
|
||||||
|
type Stored = MyDayRecord & { _id?: unknown };
|
||||||
|
|
||||||
|
const strip = ({ _id: _ignored, ...record }: Stored): MyDayRecord => ({
|
||||||
|
...record,
|
||||||
|
// Mongo leaves an emptied list out.
|
||||||
|
issueIds: record.issueIds ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoMyDaysRepository extends MyDaysRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ReadModels.MyDay) private readonly days: Model<Stored>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(userId: string, day: string): Promise<MyDayRecord | null> {
|
||||||
|
const doc = await this.days.findOne({ userId, day }).lean<Stored>();
|
||||||
|
return doc ? strip(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async latestBefore(userId: string, day: string): Promise<MyDayRecord | null> {
|
||||||
|
const doc = await this.days
|
||||||
|
.findOne({ userId, day: { $lt: day } })
|
||||||
|
.sort({ day: -1 })
|
||||||
|
.lean<Stored>();
|
||||||
|
return doc ? strip(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(record: MyDayRecord): Promise<void> {
|
||||||
|
await this.days.updateOne(
|
||||||
|
{ userId: record.userId, day: record.day },
|
||||||
|
{ $set: record },
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ export const ReadModels = {
|
|||||||
AlertTrace: 'AlertTrace',
|
AlertTrace: 'AlertTrace',
|
||||||
TreeFollow: 'TreeFollow',
|
TreeFollow: 'TreeFollow',
|
||||||
IssueReminder: 'IssueReminder',
|
IssueReminder: 'IssueReminder',
|
||||||
|
MyDay: 'MyDay',
|
||||||
Sprint: 'SprintReadModel',
|
Sprint: 'SprintReadModel',
|
||||||
GitIntegration: 'GitIntegration',
|
GitIntegration: 'GitIntegration',
|
||||||
OutgoingWebhook: 'OutgoingWebhook',
|
OutgoingWebhook: 'OutgoingWebhook',
|
||||||
@@ -385,6 +386,15 @@ export const IssueReminderSchema = SchemaFactory.createForClass(
|
|||||||
IssueReminderSchema.index({ sentAt: 1, at: 1 });
|
IssueReminderSchema.index({ sentAt: 1, at: 1 });
|
||||||
IssueReminderSchema.index({ userId: 1, issueId: 1 });
|
IssueReminderSchema.index({ userId: 1, issueId: 1 });
|
||||||
|
|
||||||
|
@Schema({ ...LOOSE, collection: 'my_days' })
|
||||||
|
class MyDayDocument {
|
||||||
|
@Prop({ type: String }) userId!: string;
|
||||||
|
@Prop({ type: String }) day!: string;
|
||||||
|
@Prop({ type: [String] }) issueIds!: string[];
|
||||||
|
}
|
||||||
|
export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument);
|
||||||
|
MyDaySchema.index({ userId: 1, day: -1 }, { unique: true });
|
||||||
|
|
||||||
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||||
class GitIntegrationDocument {
|
class GitIntegrationDocument {
|
||||||
/** The project's id. */
|
/** The project's id. */
|
||||||
@@ -492,6 +502,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||||
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||||
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
||||||
|
{ name: ReadModels.MyDay, schema: MyDaySchema },
|
||||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
AlertIntegrationsRepository,
|
AlertIntegrationsRepository,
|
||||||
TreeFollowsRepository,
|
TreeFollowsRepository,
|
||||||
IssueRemindersRepository,
|
IssueRemindersRepository,
|
||||||
|
MyDaysRepository,
|
||||||
GitIntegrationsRepository,
|
GitIntegrationsRepository,
|
||||||
OutgoingWebhooksRepository,
|
OutgoingWebhooksRepository,
|
||||||
AutomationsRepository,
|
AutomationsRepository,
|
||||||
@@ -86,6 +87,7 @@ import { MongoPinsRepository } from './mongo/pins.repository';
|
|||||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||||
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
||||||
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
|
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
|
||||||
|
import { MongoMyDaysRepository } from './mongo/my-days.repository';
|
||||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||||
@@ -172,6 +174,7 @@ const PORTS = [
|
|||||||
provide: IssueRemindersRepository,
|
provide: IssueRemindersRepository,
|
||||||
useClass: MongoIssueRemindersRepository,
|
useClass: MongoIssueRemindersRepository,
|
||||||
},
|
},
|
||||||
|
{ provide: MyDaysRepository, useClass: MongoMyDaysRepository },
|
||||||
{
|
{
|
||||||
provide: GitIntegrationsRepository,
|
provide: GitIntegrationsRepository,
|
||||||
useClass: MongoGitIntegrationsRepository,
|
useClass: MongoGitIntegrationsRepository,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PinsService,
|
PinsService,
|
||||||
TreeFollowsService,
|
TreeFollowsService,
|
||||||
IssueRemindersService,
|
IssueRemindersService,
|
||||||
|
MyDayService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
WorkflowsService,
|
WorkflowsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
IssuePageGql,
|
IssuePageGql,
|
||||||
IssueParentGql,
|
IssueParentGql,
|
||||||
IssueReminderGql,
|
IssueReminderGql,
|
||||||
|
MyDayGql,
|
||||||
ReminderOnIssueGql,
|
ReminderOnIssueGql,
|
||||||
PresenceGql,
|
PresenceGql,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -69,6 +71,7 @@ export class IssueResolver {
|
|||||||
private readonly pins: PinsService,
|
private readonly pins: PinsService,
|
||||||
private readonly treeFollows: TreeFollowsService,
|
private readonly treeFollows: TreeFollowsService,
|
||||||
private readonly reminders: IssueRemindersService,
|
private readonly reminders: IssueRemindersService,
|
||||||
|
private readonly myDayService: MyDayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Mutation(() => [PresenceGql], {
|
@Mutation(() => [PresenceGql], {
|
||||||
@@ -442,6 +445,40 @@ export class IssueResolver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Query(() => MyDayGql, {
|
||||||
|
description:
|
||||||
|
'«Mi día»: the tickets I picked for today, and what was left on my last day',
|
||||||
|
})
|
||||||
|
myDay(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('day', { description: 'Today, YYYY-MM-DD in my time zone' })
|
||||||
|
day: string,
|
||||||
|
) {
|
||||||
|
return this.myDayService.get(me.id, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Add tickets to «Mi día» (10 at most)',
|
||||||
|
})
|
||||||
|
async addToMyDay(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('day') day: string,
|
||||||
|
@Args('issueIds', { type: () => [ID] }) issueIds: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
await this.myDayService.add(me.id, day, issueIds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, { description: 'Take a ticket out of «Mi día»' })
|
||||||
|
async removeFromMyDay(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('day') day: string,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await this.myDayService.remove(me.id, day, issueId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Query(() => [ReminderOnIssueGql], {
|
@Query(() => [ReminderOnIssueGql], {
|
||||||
description: 'My waiting reminders on tickets, soonest first',
|
description: 'My waiting reminders on tickets, soonest first',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1393,3 +1393,22 @@ export class ReminderOnIssueGql {
|
|||||||
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
|
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
|
||||||
@Field(() => IssueGql) issue!: IssueGql;
|
@Field(() => IssueGql) issue!: IssueGql;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('MyDayLeftover', {
|
||||||
|
description: 'What was left unfinished on the latest earlier day',
|
||||||
|
})
|
||||||
|
export class MyDayLeftoverGql {
|
||||||
|
@Field() day!: string;
|
||||||
|
@Field(() => [IssueGql]) issues!: IssueGql[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('MyDay', {
|
||||||
|
description: '«Mi día»: the tickets I picked for today',
|
||||||
|
})
|
||||||
|
export class MyDayGql {
|
||||||
|
@Field({ description: 'YYYY-MM-DD, in my time zone' }) day!: string;
|
||||||
|
@Field(() => [IssueGql], { description: 'In order, done ones included' })
|
||||||
|
issues!: IssueGql[];
|
||||||
|
@Field(() => MyDayLeftoverGql, { nullable: true })
|
||||||
|
leftover!: MyDayLeftoverGql | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1552,6 +1552,12 @@ export class PushEndpointDto {
|
|||||||
endpoint!: string;
|
endpoint!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MyDayQueryDto {
|
||||||
|
@ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' })
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
day!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SetReminderDto {
|
export class SetReminderDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
|
|||||||
@@ -1427,3 +1427,15 @@ export class ReminderOnIssueDto {
|
|||||||
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
|
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
|
||||||
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
|
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MyDayLeftoverDto {
|
||||||
|
@ApiProperty() day!: string;
|
||||||
|
@ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MyDayDto {
|
||||||
|
@ApiProperty({ description: 'YYYY-MM-DD, in my time zone' }) day!: string;
|
||||||
|
@ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[];
|
||||||
|
@ApiProperty({ type: MyDayLeftoverDto, nullable: true })
|
||||||
|
leftover!: MyDayLeftoverDto | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
PinsService,
|
PinsService,
|
||||||
TreeFollowsService,
|
TreeFollowsService,
|
||||||
IssueRemindersService,
|
IssueRemindersService,
|
||||||
|
MyDayService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
CreateIssueDto,
|
CreateIssueDto,
|
||||||
DecisionsQueryDto,
|
DecisionsQueryDto,
|
||||||
SetReminderDto,
|
SetReminderDto,
|
||||||
|
MyDayQueryDto,
|
||||||
IssueSearchQueryDto,
|
IssueSearchQueryDto,
|
||||||
PresenceBodyDto,
|
PresenceBodyDto,
|
||||||
LinkIssueDto,
|
LinkIssueDto,
|
||||||
@@ -69,6 +71,7 @@ import {
|
|||||||
CommentPageDto,
|
CommentPageDto,
|
||||||
DecisionDto,
|
DecisionDto,
|
||||||
IssueReminderDto,
|
IssueReminderDto,
|
||||||
|
MyDayDto,
|
||||||
ReminderOnIssueDto,
|
ReminderOnIssueDto,
|
||||||
EpicDto,
|
EpicDto,
|
||||||
IssueDto,
|
IssueDto,
|
||||||
@@ -106,6 +109,7 @@ export class IssuesController {
|
|||||||
private readonly pins: PinsService,
|
private readonly pins: PinsService,
|
||||||
private readonly treeFollows: TreeFollowsService,
|
private readonly treeFollows: TreeFollowsService,
|
||||||
private readonly reminders: IssueRemindersService,
|
private readonly reminders: IssueRemindersService,
|
||||||
|
private readonly myDayService: MyDayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('me/home')
|
@Get('me/home')
|
||||||
@@ -210,6 +214,59 @@ export class IssuesController {
|
|||||||
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
|
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('me/day')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '«Mi día»: the tickets I picked for today',
|
||||||
|
description: 'And what was left unfinished on my latest earlier day.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: MyDayDto })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async myDay(@CurrentUser() me: User, @Query() query: MyDayQueryDto) {
|
||||||
|
const myDay = await this.myDayService.get(me.id, query.day);
|
||||||
|
return {
|
||||||
|
day: myDay.day,
|
||||||
|
issues: await this.refs.issueDtos(myDay.issues, viewerOf(me)),
|
||||||
|
leftover: myDay.leftover && {
|
||||||
|
day: myDay.leftover.day,
|
||||||
|
issues: await this.refs.issueDtos(myDay.leftover.issues, viewerOf(me)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('me/day/:issue')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ISSUE
|
||||||
|
@ApiOperation({ summary: 'Add a ticket to «Mi día» (10 at most)' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async addToMyDay(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('issue') issue: string,
|
||||||
|
@Query() query: MyDayQueryDto,
|
||||||
|
) {
|
||||||
|
await this.myDayService.add(me.id, query.day, [
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('me/day/:issue')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ISSUE
|
||||||
|
@ApiOperation({ summary: 'Take a ticket out of «Mi día»' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async removeFromMyDay(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('issue') issue: string,
|
||||||
|
@Query() query: MyDayQueryDto,
|
||||||
|
) {
|
||||||
|
await this.myDayService.remove(
|
||||||
|
me.id,
|
||||||
|
query.day,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('me/reminders')
|
@Get('me/reminders')
|
||||||
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
|
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
|
||||||
@ApiOkResponse({ type: [ReminderOnIssueDto] })
|
@ApiOkResponse({ type: [ReminderOnIssueDto] })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './lib/models/enums';
|
|||||||
export * from './lib/models/models';
|
export * from './lib/models/models';
|
||||||
export * from './lib/models/bulk';
|
export * from './lib/models/bulk';
|
||||||
export * from './lib/models/reminders';
|
export * from './lib/models/reminders';
|
||||||
|
export * from './lib/models/my-day';
|
||||||
export * from './lib/models/inputs';
|
export * from './lib/models/inputs';
|
||||||
export * from './lib/models/workflow';
|
export * from './lib/models/workflow';
|
||||||
export * from './lib/ports/gateways';
|
export * from './lib/ports/gateways';
|
||||||
|
|||||||
11
libs/web/tracker/domain/src/lib/models/my-day.ts
Normal file
11
libs/web/tracker/domain/src/lib/models/my-day.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IssueSummary } from './models';
|
||||||
|
|
||||||
|
/** «Mi día»: the tickets I picked for today. */
|
||||||
|
export interface MyDay {
|
||||||
|
/** `YYYY-MM-DD` in my time zone. */
|
||||||
|
day: string;
|
||||||
|
/** In order, done ones included. */
|
||||||
|
issues: IssueSummary[];
|
||||||
|
/** What was left unfinished on my latest earlier day. */
|
||||||
|
leftover: { day: string; issues: IssueSummary[] } | null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IssuePriority } from '../models/enums';
|
import { IssuePriority } from '../models/enums';
|
||||||
|
import { MyDay } from '../models/my-day';
|
||||||
import { Home } from '../models/home';
|
import { Home } from '../models/home';
|
||||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||||
import {
|
import {
|
||||||
@@ -157,6 +158,10 @@ export abstract class IssuesGateway {
|
|||||||
note: string | null,
|
note: string | null,
|
||||||
): Observable<void>;
|
): Observable<void>;
|
||||||
abstract clearReminder(issueId: string): Observable<void>;
|
abstract clearReminder(issueId: string): Observable<void>;
|
||||||
|
/** «Mi día» on `day` (today, `YYYY-MM-DD` in my time zone). */
|
||||||
|
abstract myDay(day: string): Observable<MyDay>;
|
||||||
|
abstract addToMyDay(day: string, issueIds: string[]): Observable<void>;
|
||||||
|
abstract removeFromMyDay(day: string, issueId: string): Observable<void>;
|
||||||
/**
|
/**
|
||||||
* Decisions (comments marked as such) of all my projects by their text,
|
* Decisions (comments marked as such) of all my projects by their text,
|
||||||
* most recent first: here and not in CommentsGateway, for the palette.
|
* most recent first: here and not in CommentsGateway, for the palette.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
IssueListFilter,
|
IssueListFilter,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
LinkIssuesInput,
|
LinkIssuesInput,
|
||||||
|
MyDay,
|
||||||
MyIssuesFilter,
|
MyIssuesFilter,
|
||||||
mergeBulkResults,
|
mergeBulkResults,
|
||||||
UpdateIssueInput,
|
UpdateIssueInput,
|
||||||
@@ -53,6 +54,9 @@ import {
|
|||||||
MyPinsDocument,
|
MyPinsDocument,
|
||||||
FollowTreeDocument,
|
FollowTreeDocument,
|
||||||
SetReminderDocument,
|
SetReminderDocument,
|
||||||
|
MyDayDocument,
|
||||||
|
AddToMyDayDocument,
|
||||||
|
RemoveFromMyDayDocument,
|
||||||
ClearReminderDocument,
|
ClearReminderDocument,
|
||||||
UnfollowTreeDocument,
|
UnfollowTreeDocument,
|
||||||
SearchDecisionsDocument,
|
SearchDecisionsDocument,
|
||||||
@@ -232,6 +236,31 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
.pipe(map(() => undefined));
|
.pipe(map(() => undefined));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
myDay(day: string): Observable<MyDay> {
|
||||||
|
return this.gql.query(MyDayDocument, { day }).pipe(
|
||||||
|
map(({ myDay }) => ({
|
||||||
|
day: myDay.day,
|
||||||
|
issues: myDay.issues.map(toIssueSummary),
|
||||||
|
leftover: myDay.leftover && {
|
||||||
|
day: myDay.leftover.day,
|
||||||
|
issues: myDay.leftover.issues.map(toIssueSummary),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addToMyDay(day: string, issueIds: string[]): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(AddToMyDayDocument, { day, issueIds })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFromMyDay(day: string, issueId: string): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(RemoveFromMyDayDocument, { day, issueId })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
clearReminder(issueId: string): Observable<void> {
|
clearReminder(issueId: string): Observable<void> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(ClearReminderDocument, { issueId })
|
.mutate(ClearReminderDocument, { issueId })
|
||||||
|
|||||||
@@ -1155,6 +1155,91 @@ export type MyPinsQuery = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MyDayQueryVariables = Exact<{
|
||||||
|
day: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MyDayQuery = {
|
||||||
|
myDay: {
|
||||||
|
day: string;
|
||||||
|
issues: Array<{
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
area: string | null;
|
||||||
|
team: string | null;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
statusCategory: StatusCategory;
|
||||||
|
priority: IssuePriority;
|
||||||
|
labels: Array<string>;
|
||||||
|
commentCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
version: number;
|
||||||
|
votes: number;
|
||||||
|
voted: boolean;
|
||||||
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
|
startDate: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
trashedAt: string | null;
|
||||||
|
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;
|
||||||
|
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||||
|
customFields: Array<{ fieldId: string; value: string }>;
|
||||||
|
}>;
|
||||||
|
leftover: {
|
||||||
|
day: string;
|
||||||
|
issues: Array<{
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
area: string | null;
|
||||||
|
team: string | null;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
statusCategory: StatusCategory;
|
||||||
|
priority: IssuePriority;
|
||||||
|
labels: Array<string>;
|
||||||
|
commentCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
version: number;
|
||||||
|
votes: number;
|
||||||
|
voted: boolean;
|
||||||
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
|
startDate: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
trashedAt: string | null;
|
||||||
|
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;
|
||||||
|
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||||
|
customFields: Array<{ fieldId: string; value: string }>;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddToMyDayMutationVariables = Exact<{
|
||||||
|
day: string;
|
||||||
|
issueIds: Array<string> | string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AddToMyDayMutation = { addToMyDay: boolean };
|
||||||
|
|
||||||
|
export type RemoveFromMyDayMutationVariables = Exact<{
|
||||||
|
day: string;
|
||||||
|
issueId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RemoveFromMyDayMutation = { removeFromMyDay: boolean };
|
||||||
|
|
||||||
export type SetReminderMutationVariables = Exact<{
|
export type SetReminderMutationVariables = Exact<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -7507,6 +7592,346 @@ export const MyPinsDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
||||||
|
export const MyDayDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'MyDay' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } },
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'myDay' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'day' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'day' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issues' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'leftover' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issues' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: {
|
||||||
|
kind: 'Name',
|
||||||
|
value: 'IssueSummaryFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Issue' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'assignee' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'release' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'blockedBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'votes' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'voted' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'customFields' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<MyDayQuery, MyDayQueryVariables>;
|
||||||
|
export const AddToMyDayDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'AddToMyDay' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } },
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueIds' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'ListType',
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'ID' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'addToMyDay' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'day' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'day' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueIds' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueIds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<AddToMyDayMutation, AddToMyDayMutationVariables>;
|
||||||
|
export const RemoveFromMyDayDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'RemoveFromMyDay' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } },
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'removeFromMyDay' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'day' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'day' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
RemoveFromMyDayMutation,
|
||||||
|
RemoveFromMyDayMutationVariables
|
||||||
|
>;
|
||||||
export const SetReminderDocument = {
|
export const SetReminderDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ query MyPins {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query MyDay($day: String!) {
|
||||||
|
myDay(day: $day) {
|
||||||
|
day
|
||||||
|
issues {
|
||||||
|
...IssueSummaryFields
|
||||||
|
}
|
||||||
|
leftover {
|
||||||
|
day
|
||||||
|
issues {
|
||||||
|
...IssueSummaryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation AddToMyDay($day: String!, $issueIds: [ID!]!) {
|
||||||
|
addToMyDay(day: $day, issueIds: $issueIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation RemoveFromMyDay($day: String!, $issueId: ID!) {
|
||||||
|
removeFromMyDay(day: $day, issueId: $issueId)
|
||||||
|
}
|
||||||
|
|
||||||
mutation SetReminder($issueId: ID!, $at: String!, $note: String) {
|
mutation SetReminder($issueId: ID!, $at: String!, $note: String) {
|
||||||
setReminder(issueId: $issueId, at: $at, note: $note) {
|
setReminder(issueId: $issueId, at: $at, note: $note) {
|
||||||
at
|
at
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import {
|
|||||||
IssuePriority,
|
IssuePriority,
|
||||||
isTyping,
|
isTyping,
|
||||||
POINT_SCALE,
|
POINT_SCALE,
|
||||||
|
IssuesGateway,
|
||||||
|
localDay,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
ReminderChoice,
|
ReminderChoice,
|
||||||
reminderChoices,
|
reminderChoices,
|
||||||
@@ -209,6 +211,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
(execute)="facade.setWatching(!facade.watching())"
|
(execute)="facade.setWatching(!facade.watching())"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@if (project.myRole() && inMyDay() !== null) {
|
||||||
|
<pi-icon-button
|
||||||
|
icon="sun"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
[text]="inMyDay() ? 'En mi día' : 'Mi día'"
|
||||||
|
[attr.aria-pressed]="inMyDay()"
|
||||||
|
[attr.title]="
|
||||||
|
inMyDay()
|
||||||
|
? 'Quitar de lo que quiero sacar hoy'
|
||||||
|
: 'Añadir a lo que quiero sacar hoy (en el inicio)'
|
||||||
|
"
|
||||||
|
(execute)="toggleMyDay(issue)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
@if (project.myRole()) {
|
@if (project.myRole()) {
|
||||||
<pi-icon-button
|
<pi-icon-button
|
||||||
icon="bell"
|
icon="bell"
|
||||||
@@ -1669,6 +1686,18 @@ export class IssuePage {
|
|||||||
if (id && this.facade.comments().some((c) => c.id === id))
|
if (id && this.facade.comments().some((c) => c.id === id))
|
||||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||||
});
|
});
|
||||||
|
// Whether it is in today's «Mi día», once per ticket.
|
||||||
|
effect(() => {
|
||||||
|
const issueId = this.openIssueId();
|
||||||
|
if (!issueId) return;
|
||||||
|
untracked(() =>
|
||||||
|
this.issuesGateway.myDay(localDay(new Date())).subscribe({
|
||||||
|
next: (myDay) =>
|
||||||
|
this.inMyDay.set(myDay.issues.some((i) => i.id === issueId)),
|
||||||
|
error: () => this.inMyDay.set(null),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
// Writing an answer or a comment: the others see it as I type.
|
// Writing an answer or a comment: the others see it as I type.
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.replyBody().trim())
|
if (this.replyBody().trim())
|
||||||
@@ -1735,6 +1764,34 @@ export class IssuePage {
|
|||||||
this.parentKey.set(null);
|
this.parentKey.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether it is in today's «Mi día» (null: not known yet). */
|
||||||
|
protected readonly inMyDay = signal<boolean | null>(null);
|
||||||
|
/** Changes with the ticket, not with each reload of it. */
|
||||||
|
private readonly openIssueId = computed(() => this.facade.issue()?.id);
|
||||||
|
private readonly issuesGateway = inject(IssuesGateway);
|
||||||
|
|
||||||
|
protected toggleMyDay(issue: Issue): void {
|
||||||
|
const day = localDay(new Date());
|
||||||
|
const on = !this.inMyDay();
|
||||||
|
this.inMyDay.set(on);
|
||||||
|
(on
|
||||||
|
? this.issuesGateway.addToMyDay(day, [issue.id])
|
||||||
|
: this.issuesGateway.removeFromMyDay(day, issue.id)
|
||||||
|
).subscribe({
|
||||||
|
next: () =>
|
||||||
|
this.toast.success(
|
||||||
|
on ? `${issue.key} está en tu día` : `${issue.key} fuera de tu día`,
|
||||||
|
),
|
||||||
|
error: (error) => {
|
||||||
|
this.inMyDay.set(!on);
|
||||||
|
this.toast.error(
|
||||||
|
(error as { message?: string })?.message?.split('\n')[0] ||
|
||||||
|
'No se pudo cambiar',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected openReminder(): void {
|
protected openReminder(): void {
|
||||||
const current = this.facade.issue()?.myReminder;
|
const current = this.facade.issue()?.myReminder;
|
||||||
this.reminderChoices.set(reminderChoices());
|
this.reminderChoices.set(reminderChoices());
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { WeekAgendaComponent } from '../ui/home/week-agenda';
|
|||||||
import { HomeKpisComponent } from '../ui/home/kpis';
|
import { HomeKpisComponent } from '../ui/home/kpis';
|
||||||
import { MyViewsComponent } from '../ui/my-views';
|
import { MyViewsComponent } from '../ui/my-views';
|
||||||
import { PinnedComponent } from '../ui/home/pinned';
|
import { PinnedComponent } from '../ui/home/pinned';
|
||||||
|
import { MyDayComponent } from '../ui/home/my-day';
|
||||||
|
|
||||||
/** How often the home page refreshes its reading while open. */
|
/** How often the home page refreshes its reading while open. */
|
||||||
const REFRESH_MS = 60_000;
|
const REFRESH_MS = 60_000;
|
||||||
@@ -77,6 +78,7 @@ const REFRESH_MS = 60_000;
|
|||||||
HomeKpisComponent,
|
HomeKpisComponent,
|
||||||
MyViewsComponent,
|
MyViewsComponent,
|
||||||
PinnedComponent,
|
PinnedComponent,
|
||||||
|
MyDayComponent,
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiEmptyStateComponent,
|
PiEmptyStateComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
@@ -158,6 +160,7 @@ const REFRESH_MS = 60_000;
|
|||||||
|
|
||||||
@if (facade.projects().length) {
|
@if (facade.projects().length) {
|
||||||
<ts-pinned />
|
<ts-pinned />
|
||||||
|
<ts-my-day />
|
||||||
@if (myHome.home(); as h) {
|
@if (myHome.home(); as h) {
|
||||||
<ts-home-kpis
|
<ts-home-kpis
|
||||||
[mine]="home.mineTotal()"
|
[mine]="home.mineTotal()"
|
||||||
@@ -275,6 +278,7 @@ const REFRESH_MS = 60_000;
|
|||||||
}
|
}
|
||||||
ts-first-steps,
|
ts-first-steps,
|
||||||
ts-pinned,
|
ts-pinned,
|
||||||
|
ts-my-day,
|
||||||
ts-home-kpis,
|
ts-home-kpis,
|
||||||
ts-week-agenda,
|
ts-week-agenda,
|
||||||
ts-my-views {
|
ts-my-views {
|
||||||
|
|||||||
315
libs/web/tracker/presenters/src/lib/ui/home/my-day.ts
Normal file
315
libs/web/tracker/presenters/src/lib/ui/home/my-day.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
DestroyRef,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ToastService } from '@task-stream/web/shared/ui';
|
||||||
|
import {
|
||||||
|
IssuesGateway,
|
||||||
|
IssueSummary,
|
||||||
|
localDay,
|
||||||
|
MyDay,
|
||||||
|
StatusCategory,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { catchError, Observable, of } from 'rxjs';
|
||||||
|
|
||||||
|
const OPEN = [
|
||||||
|
StatusCategory.TODO,
|
||||||
|
StatusCategory.IN_PROGRESS,
|
||||||
|
StatusCategory.QA,
|
||||||
|
];
|
||||||
|
|
||||||
|
const finished = (issue: IssueSummary) =>
|
||||||
|
issue.statusCategory === StatusCategory.DONE ||
|
||||||
|
issue.statusCategory === StatusCategory.CANCELLED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Mi día»: the few tickets I pick for today, crossed out as they get done,
|
||||||
|
* with my open ones to add and what was left from my last day.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-my-day',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
role: 'region',
|
||||||
|
'aria-labelledby': 'my-day-title',
|
||||||
|
},
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="my-day-title">☀ Mi día</h2>
|
||||||
|
@if (picked().length) {
|
||||||
|
<span class="ts-muted"
|
||||||
|
>{{ done() }} de {{ picked().length }} terminados</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
@if (myDay(); as day) {
|
||||||
|
@if (day.leftover; as left) {
|
||||||
|
<p class="ts-my-day__leftover">
|
||||||
|
{{
|
||||||
|
left.issues.length === 1
|
||||||
|
? 'Quedó 1 ticket sin terminar'
|
||||||
|
: 'Quedaron ' + left.issues.length + ' tickets sin terminar'
|
||||||
|
}}
|
||||||
|
{{ dayLabel(left.day) }}.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-my-day__link"
|
||||||
|
(click)="carryOver(left.issues)"
|
||||||
|
>
|
||||||
|
{{ left.issues.length === 1 ? 'Pasarlo a hoy' : 'Pasarlos a hoy' }}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (picked().length) {
|
||||||
|
<ul class="ts-issues ts-my-day__list">
|
||||||
|
@for (issue of picked(); track issue.id) {
|
||||||
|
<li [class.is-done]="isDone(issue)">
|
||||||
|
<span class="ts-my-day__check" aria-hidden="true">{{
|
||||||
|
isDone(issue) ? '✓' : '○'
|
||||||
|
}}</span>
|
||||||
|
<a
|
||||||
|
class="ts-title"
|
||||||
|
[routerLink]="['/p', projectOf(issue.key), 'issues', issue.key]"
|
||||||
|
><span class="ts-key">{{ issue.key }}</span>
|
||||||
|
{{ issue.title }}</a
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-my-day__remove"
|
||||||
|
[attr.aria-label]="'Quitar ' + issue.key + ' de mi día'"
|
||||||
|
title="Quitar de mi día"
|
||||||
|
(click)="remove(issue)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (allDone()) {
|
||||||
|
<p class="ts-empty">Todo hecho por hoy. 🎉</p>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<p class="ts-empty">
|
||||||
|
Elige lo que quieres sacar hoy: tres o cuatro tickets mejor que diez.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (suggestions().length && picked().length < 10) {
|
||||||
|
<p class="ts-my-day__subtitle">Tus tickets abiertos</p>
|
||||||
|
<ul class="ts-my-day__suggestions">
|
||||||
|
@for (issue of suggestions(); track issue.id) {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-my-day__add"
|
||||||
|
[attr.title]="'Añadir ' + issue.key + ' a mi día'"
|
||||||
|
(click)="add([issue])"
|
||||||
|
>
|
||||||
|
+ <span class="ts-key">{{ issue.key }}</span>
|
||||||
|
{{ issue.title }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-my-day__list li {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
&.is-done .ts-title {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-my-day__check {
|
||||||
|
width: 1.25rem;
|
||||||
|
color: var(--ts-c-green);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ts-key {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.ts-my-day__leftover {
|
||||||
|
margin: 0 0 0.875rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.ts-my-day__subtitle {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.ts-my-day__suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
/* Plain buttons, not @pi/ui's sized ones. */
|
||||||
|
.ts-my-day__add,
|
||||||
|
.ts-my-day__remove,
|
||||||
|
.ts-my-day__link {
|
||||||
|
height: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ts-my-day__add {
|
||||||
|
max-width: 20rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.25rem 0.75rem !important;
|
||||||
|
border: 1px dashed var(--ts-line-strong) !important;
|
||||||
|
border-radius: 999px !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: var(--ts-brand);
|
||||||
|
color: var(--ts-ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-my-day__remove {
|
||||||
|
width: 1.75rem !important;
|
||||||
|
height: 1.75rem !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background: var(--ts-sunken);
|
||||||
|
color: var(--ts-ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-my-day__link {
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0;
|
||||||
|
background: none !important;
|
||||||
|
color: var(--ts-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class MyDayComponent {
|
||||||
|
private readonly issues = inject(IssuesGateway);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly day = localDay(new Date());
|
||||||
|
protected readonly myDay = signal<MyDay | null>(null);
|
||||||
|
private readonly mine = signal<IssueSummary[]>([]);
|
||||||
|
|
||||||
|
protected readonly picked = computed(() => this.myDay()?.issues ?? []);
|
||||||
|
protected readonly done = computed(
|
||||||
|
() => this.picked().filter(finished).length,
|
||||||
|
);
|
||||||
|
protected readonly allDone = computed(
|
||||||
|
() => this.picked().length > 0 && this.done() === this.picked().length,
|
||||||
|
);
|
||||||
|
/** My open tickets not picked yet (nor offered as left over). */
|
||||||
|
protected readonly suggestions = computed(() => {
|
||||||
|
const taken = new Set([
|
||||||
|
...this.picked().map((i) => i.id),
|
||||||
|
...(this.myDay()?.leftover?.issues ?? []).map((i) => i.id),
|
||||||
|
]);
|
||||||
|
return this.mine()
|
||||||
|
.filter((i) => !taken.has(i.id))
|
||||||
|
.slice(0, 6);
|
||||||
|
});
|
||||||
|
protected readonly isDone = finished;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.reload();
|
||||||
|
this.issues
|
||||||
|
.mine({ assignedToMe: true, categories: OPEN, limit: 20 })
|
||||||
|
.pipe(
|
||||||
|
catchError(() => of({ items: [], total: 0 })),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((page) => this.mine.set(page.items));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected projectOf(key: string): string {
|
||||||
|
return key.slice(0, key.lastIndexOf('-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected dayLabel(day: string): string {
|
||||||
|
const yesterday = localDay(new Date(Date.now() - 86_400_000));
|
||||||
|
if (day === yesterday) return 'ayer';
|
||||||
|
return (
|
||||||
|
'el ' +
|
||||||
|
new Date(`${day}T12:00:00`).toLocaleDateString('es', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected add(issues: IssueSummary[]): void {
|
||||||
|
this.run(
|
||||||
|
this.issues.addToMyDay(
|
||||||
|
this.day,
|
||||||
|
issues.map((i) => i.id),
|
||||||
|
),
|
||||||
|
'No se pudo añadir',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected carryOver(issues: IssueSummary[]): void {
|
||||||
|
this.add(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected remove(issue: IssueSummary): void {
|
||||||
|
this.run(
|
||||||
|
this.issues.removeFromMyDay(this.day, issue.id),
|
||||||
|
'No se pudo quitar',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(command$: Observable<void>, failure: string): void {
|
||||||
|
command$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
|
next: () => this.reload(),
|
||||||
|
error: (error) =>
|
||||||
|
this.toast.error(
|
||||||
|
(error as { message?: string })?.message?.split('\n')[0] || failure,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload(): void {
|
||||||
|
this.issues
|
||||||
|
.myDay(this.day)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: (myDay) => this.myDay.set(myDay),
|
||||||
|
error: () =>
|
||||||
|
this.myDay.set({ day: this.day, issues: [], leftover: null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user