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": {
|
||||
"get": {
|
||||
"operationId": "IssuesController_myReminders",
|
||||
@@ -16339,6 +16627,53 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -957,6 +957,9 @@ type Mutation {
|
||||
"""Add a Pireon user to the project"""
|
||||
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
|
||||
"""
|
||||
@@ -1073,6 +1076,9 @@ type Mutation {
|
||||
removeAttachment(attachmentId: ID!, issueId: 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"""
|
||||
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
|
||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||
@@ -1210,6 +1216,22 @@ type Mutation {
|
||||
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 {
|
||||
items: [Notification!]!
|
||||
|
||||
@@ -1625,6 +1647,14 @@ type Query {
|
||||
"""My calendar URL, if I made one"""
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -502,6 +502,44 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2951,6 +2989,16 @@ export interface components {
|
||||
*/
|
||||
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: {
|
||||
/** Format: date-time */
|
||||
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: {
|
||||
parameters: {
|
||||
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({
|
||||
name: 'remind_me',
|
||||
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 |
|
||||
| 📋 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 |
|
||||
| ☀ 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 |
|
||||
| 🎲 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 |
|
||||
|
||||
@@ -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,
|
||||
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 })`,
|
||||
`?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
|
||||
/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
|
||||
@@ -550,9 +554,9 @@ que funcionan también sin token.
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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)` |
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||
@@ -758,10 +762,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
|
||||
| Recurso | Rutas |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/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` |
|
||||
| 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}` |
|
||||
|
||||
@@ -301,14 +301,14 @@ visualización de la plataforma:
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/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 |
|
||||
| `/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/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/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 |
|
||||
|
||||
@@ -69,6 +69,7 @@ flowchart LR
|
||||
| | `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 |
|
||||
| | `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 |
|
||||
| | `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 |
|
||||
|
||||
@@ -46,6 +46,9 @@ export * from './lib/tree-follows.service';
|
||||
export * from './lib/reminders/issue-reminders';
|
||||
export * from './lib/ports/issue-reminders.repository';
|
||||
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/ports/presence.store';
|
||||
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 { TREE_FOLLOWS } from './follows/tree-follows';
|
||||
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 { TreeFollowsService } from './tree-follows.service';
|
||||
import { AlertsService } from './alerts.service';
|
||||
@@ -90,6 +92,7 @@ const SERVICES = [
|
||||
AlertsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
@@ -124,6 +127,7 @@ const SERVICES = [
|
||||
...ALERT_HANDLERS,
|
||||
...TREE_FOLLOWS,
|
||||
...ISSUE_REMINDERS,
|
||||
...MY_DAY,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_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',
|
||||
TreeFollow: 'TreeFollow',
|
||||
IssueReminder: 'IssueReminder',
|
||||
MyDay: 'MyDay',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
@@ -385,6 +386,15 @@ export const IssueReminderSchema = SchemaFactory.createForClass(
|
||||
IssueReminderSchema.index({ sentAt: 1, at: 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' })
|
||||
class GitIntegrationDocument {
|
||||
/** The project's id. */
|
||||
@@ -492,6 +502,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
||||
{ name: ReadModels.MyDay, schema: MyDaySchema },
|
||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AlertIntegrationsRepository,
|
||||
TreeFollowsRepository,
|
||||
IssueRemindersRepository,
|
||||
MyDaysRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
AutomationsRepository,
|
||||
@@ -86,6 +87,7 @@ import { MongoPinsRepository } from './mongo/pins.repository';
|
||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
||||
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
|
||||
import { MongoMyDaysRepository } from './mongo/my-days.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
@@ -172,6 +174,7 @@ const PORTS = [
|
||||
provide: IssueRemindersRepository,
|
||||
useClass: MongoIssueRemindersRepository,
|
||||
},
|
||||
{ provide: MyDaysRepository, useClass: MongoMyDaysRepository },
|
||||
{
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
IssuePageGql,
|
||||
IssueParentGql,
|
||||
IssueReminderGql,
|
||||
MyDayGql,
|
||||
ReminderOnIssueGql,
|
||||
PresenceGql,
|
||||
} from './types';
|
||||
@@ -69,6 +71,7 @@ export class IssueResolver {
|
||||
private readonly pins: PinsService,
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
@@ -442,6 +445,40 @@ export class IssueResolver {
|
||||
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], {
|
||||
description: 'My waiting reminders on tickets, soonest first',
|
||||
})
|
||||
|
||||
@@ -1393,3 +1393,22 @@ export class ReminderOnIssueGql {
|
||||
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
|
||||
@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;
|
||||
}
|
||||
|
||||
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 {
|
||||
@ApiProperty({
|
||||
format: 'date-time',
|
||||
|
||||
@@ -1427,3 +1427,15 @@ export class ReminderOnIssueDto {
|
||||
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
|
||||
@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,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
ProjectsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
CreateIssueDto,
|
||||
DecisionsQueryDto,
|
||||
SetReminderDto,
|
||||
MyDayQueryDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
LinkIssueDto,
|
||||
@@ -69,6 +71,7 @@ import {
|
||||
CommentPageDto,
|
||||
DecisionDto,
|
||||
IssueReminderDto,
|
||||
MyDayDto,
|
||||
ReminderOnIssueDto,
|
||||
EpicDto,
|
||||
IssueDto,
|
||||
@@ -106,6 +109,7 @@ export class IssuesController {
|
||||
private readonly pins: PinsService,
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
) {}
|
||||
|
||||
@Get('me/home')
|
||||
@@ -210,6 +214,59 @@ export class IssuesController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
|
||||
@ApiOkResponse({ type: [ReminderOnIssueDto] })
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './lib/models/enums';
|
||||
export * from './lib/models/models';
|
||||
export * from './lib/models/bulk';
|
||||
export * from './lib/models/reminders';
|
||||
export * from './lib/models/my-day';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
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 { MyDay } from '../models/my-day';
|
||||
import { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
import {
|
||||
@@ -157,6 +158,10 @@ export abstract class IssuesGateway {
|
||||
note: string | null,
|
||||
): 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,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IssueListFilter,
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
MyDay,
|
||||
MyIssuesFilter,
|
||||
mergeBulkResults,
|
||||
UpdateIssueInput,
|
||||
@@ -53,6 +54,9 @@ import {
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
SetReminderDocument,
|
||||
MyDayDocument,
|
||||
AddToMyDayDocument,
|
||||
RemoveFromMyDayDocument,
|
||||
ClearReminderDocument,
|
||||
UnfollowTreeDocument,
|
||||
SearchDecisionsDocument,
|
||||
@@ -232,6 +236,31 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.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> {
|
||||
return this.gql
|
||||
.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<{
|
||||
issueId: string;
|
||||
at: string;
|
||||
@@ -7507,6 +7592,346 @@ export const MyPinsDocument = {
|
||||
},
|
||||
],
|
||||
} 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 = {
|
||||
kind: 'Document',
|
||||
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) {
|
||||
setReminder(issueId: $issueId, at: $at, note: $note) {
|
||||
at
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
IssuePriority,
|
||||
isTyping,
|
||||
POINT_SCALE,
|
||||
IssuesGateway,
|
||||
localDay,
|
||||
ReleaseStatus,
|
||||
ReminderChoice,
|
||||
reminderChoices,
|
||||
@@ -209,6 +211,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(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()) {
|
||||
<pi-icon-button
|
||||
icon="bell"
|
||||
@@ -1669,6 +1686,18 @@ export class IssuePage {
|
||||
if (id && this.facade.comments().some((c) => c.id === 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.
|
||||
effect(() => {
|
||||
if (this.replyBody().trim())
|
||||
@@ -1735,6 +1764,34 @@ export class IssuePage {
|
||||
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 {
|
||||
const current = this.facade.issue()?.myReminder;
|
||||
this.reminderChoices.set(reminderChoices());
|
||||
|
||||
@@ -54,6 +54,7 @@ import { WeekAgendaComponent } from '../ui/home/week-agenda';
|
||||
import { HomeKpisComponent } from '../ui/home/kpis';
|
||||
import { MyViewsComponent } from '../ui/my-views';
|
||||
import { PinnedComponent } from '../ui/home/pinned';
|
||||
import { MyDayComponent } from '../ui/home/my-day';
|
||||
|
||||
/** How often the home page refreshes its reading while open. */
|
||||
const REFRESH_MS = 60_000;
|
||||
@@ -77,6 +78,7 @@ const REFRESH_MS = 60_000;
|
||||
HomeKpisComponent,
|
||||
MyViewsComponent,
|
||||
PinnedComponent,
|
||||
MyDayComponent,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiSkeletonComponent,
|
||||
@@ -158,6 +160,7 @@ const REFRESH_MS = 60_000;
|
||||
|
||||
@if (facade.projects().length) {
|
||||
<ts-pinned />
|
||||
<ts-my-day />
|
||||
@if (myHome.home(); as h) {
|
||||
<ts-home-kpis
|
||||
[mine]="home.mineTotal()"
|
||||
@@ -275,6 +278,7 @@ const REFRESH_MS = 60_000;
|
||||
}
|
||||
ts-first-steps,
|
||||
ts-pinned,
|
||||
ts-my-day,
|
||||
ts-home-kpis,
|
||||
ts-week-agenda,
|
||||
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