diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 17ceccf..62acbaa 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1904,4 +1904,71 @@ describe('task-stream (e2e)', () => { (d) => !d['portfolio'].some((p: { key: string }) => p.key === key), ); }); + + it('fills the home page: agenda, activity, the week and who is here', async () => { + const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase(); + await rest('token-ana', 'POST', '/projects', { key, name: key }); + await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + await rest('token-ana', 'POST', `/projects/${key}/members`, { + userId: '102', + }); + const today = new Date().toISOString().slice(0, 10); + const ticket = ( + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Para hoy', + assigneeId: '101', + dueDate: today, + }) + ).body['key']; + const issue = await eventually( + () => rest('token-bob', 'GET', `/issues/${ticket}`), + (r) => r.status === 200, + ); + // Bob is on it right now. + await rest('token-bob', 'PUT', `/issues/${issue.body['id']}/presence`, { + editing: true, + }); + + const home = await eventually( + () => rest('token-ana', 'GET', '/me/home'), + (r) => + r.body?.agenda?.some( + (a: { issueKey: string }) => a.issueKey === ticket, + ) && + r.body.activity.some( + (a: { issueKey: string }) => a.issueKey === ticket, + ), + ); + expect(home.body.agenda).toContainEqual( + expect.objectContaining({ day: today, kind: 'ISSUE', issueKey: ticket }), + ); + expect(home.body.online).toContainEqual( + expect.objectContaining({ + user: { id: '102', name: 'Bob (frontend)' }, + projectKey: key, + issueKey: ticket, + editing: true, + }), + ); + expect(home.body.team.perDay).toHaveLength(14); + expect(home.body.firstSteps).toMatchObject({ + project: true, + invited: true, + ticket: true, + }); + const card = home.body.projects.find( + (p: { projectId: string }) => p.projectId === issue.body['projectId'], + ); + expect(card).toMatchObject({ open: 1 }); + expect(card.activity).toHaveLength(14); + const { myHome } = await gql( + 'token-ana', + `query { myHome { me { thisWeek streak } suggestions { kind } } }`, + ); + expect(myHome.me.streak).toBeGreaterThanOrEqual(0); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c0ad9da..92cae59 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2185,6 +2185,77 @@ ] } }, + "/api/me/home": { + "get": { + "description": "Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now.", + "operationId": "IssuesController_home", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HomeDto" + } + } + } + }, + "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": "The home page beyond my work", + "tags": [ + "Issues" + ] + } + }, "/api/me/portfolio": { "get": { "description": "Every active project I am a member of (not archived, not as a guest): open and at-risk tickets, the sprint under way, release forecasts and open epics.", @@ -13490,6 +13561,300 @@ "role" ] }, + "AgendaItemDto": { + "type": "object", + "properties": { + "day": { + "type": "string", + "format": "date" + }, + "kind": { + "type": "string", + "enum": [ + "ISSUE", + "SPRINT_END", + "RELEASE" + ] + }, + "projectKey": { + "type": "string" + }, + "title": { + "type": "string" + }, + "issueKey": { + "type": "string", + "nullable": true + } + }, + "required": [ + "day", + "kind", + "projectKey", + "title", + "issueKey" + ] + }, + "TeamWeekDto": { + "type": "object", + "properties": { + "perDay": { + "description": "Finished per day, last 14, oldest first", + "type": "array", + "items": { + "type": "number" + } + }, + "thisWeek": { + "type": "number" + }, + "lastWeek": { + "type": "number" + } + }, + "required": [ + "perDay", + "thisWeek", + "lastWeek" + ] + }, + "MyWeekDto": { + "type": "object", + "properties": { + "thisWeek": { + "type": "number" + }, + "lastWeek": { + "type": "number" + }, + "streak": { + "type": "number", + "description": "Days in a row with something finished" + } + }, + "required": [ + "thisWeek", + "lastWeek", + "streak" + ] + }, + "HomeSprintDto": { + "type": "object", + "properties": { + "projectKey": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "sprint": { + "$ref": "#/components/schemas/SprintPulseDto" + } + }, + "required": [ + "projectKey", + "projectName", + "sprint" + ] + }, + "HomeSprintBriefDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "percent": { + "type": "number" + }, + "behind": { + "type": "boolean" + } + }, + "required": [ + "name", + "percent", + "behind" + ] + }, + "HomeProjectDto": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "format": "uuid" + }, + "open": { + "type": "number" + }, + "overdue": { + "type": "number" + }, + "activity": { + "description": "Activity per day, last 14, oldest first", + "type": "array", + "items": { + "type": "number" + } + }, + "sprint": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/HomeSprintBriefDto" + } + ] + } + }, + "required": [ + "projectId", + "open", + "overdue", + "activity", + "sprint" + ] + }, + "SuggestionDto": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "UNASSIGNED", + "PROPOSALS", + "QA_WAITING" + ] + }, + "projectKey": { + "type": "string" + }, + "count": { + "type": "number" + } + }, + "required": [ + "kind", + "projectKey", + "count" + ] + }, + "FirstStepsDto": { + "type": "object", + "properties": { + "project": { + "type": "boolean" + }, + "invited": { + "type": "boolean" + }, + "ticket": { + "type": "boolean" + }, + "calendar": { + "type": "boolean" + } + }, + "required": [ + "project", + "invited", + "ticket", + "calendar" + ] + }, + "OnlineDto": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserRefDto" + }, + "projectKey": { + "type": "string" + }, + "issueKey": { + "type": "string", + "nullable": true + }, + "issueTitle": { + "type": "string", + "nullable": true + }, + "editing": { + "type": "boolean" + }, + "at": { + "type": "string" + } + }, + "required": [ + "user", + "projectKey", + "issueKey", + "issueTitle", + "editing", + "at" + ] + }, + "HomeDto": { + "type": "object", + "properties": { + "agenda": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgendaItemDto" + } + }, + "activity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityDto" + } + }, + "team": { + "$ref": "#/components/schemas/TeamWeekDto" + }, + "me": { + "$ref": "#/components/schemas/MyWeekDto" + }, + "sprints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HomeSprintDto" + } + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HomeProjectDto" + } + }, + "suggestions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SuggestionDto" + } + }, + "firstSteps": { + "$ref": "#/components/schemas/FirstStepsDto" + }, + "online": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OnlineDto" + } + } + }, + "required": [ + "agenda", + "activity", + "team", + "me", + "sprints", + "projects", + "suggestions", + "firstSteps", + "online" + ] + }, "ChildProgressDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 1060278..0b62c9d 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -18,6 +18,18 @@ input AddChecklistItemInput { text: String! } +"""Something on a day of my week""" +type AgendaItem { + """YYYY-MM-DD""" + day: String! + issueKey: String + + """ISSUE, SPRINT_END or RELEASE""" + kind: String! + projectKey: String! + title: String! +} + type Aging { issues: [InsightIssue!]! @@ -507,6 +519,13 @@ type FieldBreakdown { none: Int! } +type FirstSteps { + calendar: Boolean! + invited: Boolean! + project: Boolean! + ticket: Boolean! +} + type FlowSeries { counts: [Int!]! status: String! @@ -536,6 +555,40 @@ type GitRepository { secret: String } +"""The home page beyond my work""" +type Home { + activity: [Activity!]! + agenda: [AgendaItem!]! + firstSteps: FirstSteps! + me: MyWeek! + online: [Online!]! + projects: [HomeProject!]! + sprints: [HomeSprint!]! + suggestions: [Suggestion!]! + team: TeamWeek! +} + +type HomeProject { + """Activity per day, last 14, oldest first""" + activity: [Int!]! + open: Int! + overdue: Int! + projectId: ID! + sprint: HomeSprintBrief +} + +type HomeSprint { + projectKey: String! + projectName: String! + sprint: SprintPulse! +} + +type HomeSprintBrief { + behind: Boolean! + name: String! + percent: Int! +} + """A ticket the dashboard names, with the number that put it there""" type InsightIssue { assignee: UserRef @@ -1035,6 +1088,14 @@ input MyViewCriteriaInput { watching: Boolean } +type MyWeek { + lastWeek: Int! + + """Days in a row with something finished""" + streak: Int! + thisWeek: Int! +} + type Notification { actor: UserRef! createdAt: String! @@ -1077,6 +1138,16 @@ type NotificationSettings { pushPublicKey: String } +"""Someone of my projects on a ticket now""" +type Online { + at: String! + editing: Boolean! + issueKey: String + issueTitle: String + projectKey: String! + user: UserRef! +} + type OptionCount { count: Int! value: String! @@ -1348,6 +1419,11 @@ type Query { """My calendar URL, if I made one""" myCalendarFeed: CalendarFeed + """ + 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 + """ + myHome: Home! + """ Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA]) """ @@ -1712,6 +1788,14 @@ type Subscription { sprintChanged(projectId: ID!): SprintChange! } +type Suggestion { + count: Int! + + """UNASSIGNED, PROPOSALS or QA_WAITING""" + kind: String! + projectKey: String! +} + """A group of members: tickets go to it; @team mentions tell them all""" type Team { archived: Boolean! @@ -1738,6 +1822,14 @@ type TeamLoad { user: UserRef! } +type TeamWeek { + lastWeek: Int! + + """Finished per day, last 14, oldest first""" + perDay: [Int!]! + thisWeek: Int! +} + type Throughput { count: Int! user: UserRef diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 7bc5a91..0fd9df1 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -397,6 +397,26 @@ export interface paths { patch: operations["ProjectsController_changeMemberRole"]; trace?: never; }; + "/api/me/home": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * The home page beyond my work + * @description Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now. + */ + get: operations["IssuesController_home"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/me/portfolio": { parameters: { query?: never; @@ -2460,6 +2480,77 @@ export interface components { ChangeMemberRoleDto: { role: components["schemas"]["ProjectRole"]; }; + AgendaItemDto: { + /** Format: date */ + day: string; + /** @enum {string} */ + kind: "ISSUE" | "SPRINT_END" | "RELEASE"; + projectKey: string; + title: string; + issueKey: string | null; + }; + TeamWeekDto: { + /** @description Finished per day, last 14, oldest first */ + perDay: number[]; + thisWeek: number; + lastWeek: number; + }; + MyWeekDto: { + thisWeek: number; + lastWeek: number; + /** @description Days in a row with something finished */ + streak: number; + }; + HomeSprintDto: { + projectKey: string; + projectName: string; + sprint: components["schemas"]["SprintPulseDto"]; + }; + HomeSprintBriefDto: { + name: string; + percent: number; + behind: boolean; + }; + HomeProjectDto: { + /** Format: uuid */ + projectId: string; + open: number; + overdue: number; + /** @description Activity per day, last 14, oldest first */ + activity: number[]; + sprint: components["schemas"]["HomeSprintBriefDto"] | null; + }; + SuggestionDto: { + /** @enum {string} */ + kind: "UNASSIGNED" | "PROPOSALS" | "QA_WAITING"; + projectKey: string; + count: number; + }; + FirstStepsDto: { + project: boolean; + invited: boolean; + ticket: boolean; + calendar: boolean; + }; + OnlineDto: { + user: components["schemas"]["UserRefDto"]; + projectKey: string; + issueKey: string | null; + issueTitle: string | null; + editing: boolean; + at: string; + }; + HomeDto: { + agenda: components["schemas"]["AgendaItemDto"][]; + activity: components["schemas"]["ActivityDto"][]; + team: components["schemas"]["TeamWeekDto"]; + me: components["schemas"]["MyWeekDto"]; + sprints: components["schemas"]["HomeSprintDto"][]; + projects: components["schemas"]["HomeProjectDto"][]; + suggestions: components["schemas"]["SuggestionDto"][]; + firstSteps: components["schemas"]["FirstStepsDto"]; + online: components["schemas"]["OnlineDto"][]; + }; ChildProgressDto: { total: number; /** @description Done or cancelled */ @@ -4816,6 +4907,61 @@ export interface operations { }; }; }; + IssuesController_home: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HomeDto"]; + }; + }; + /** @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_portfolio: { parameters: { query?: never; diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index 09fd3f9..0f2dfff 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -204,6 +204,27 @@ export class ProjectsResolver { ); } + @Tool({ + name: 'get_my_week', + description: + "The user's coming week and how the last one went: tickets of theirs (assigned or watched) due in the next 7 days, sprint ends and planned releases; tickets finished by them and by their teams this week and last, and their streak; sprints under way; and, when nothing is theirs, what they could pick up (unassigned, waiting for QA, proposals to review).", + paramsSchema: z.object({}), + annotations: READ, + }) + myWeek(_params: Record, ctx: McpContext) { + return toolResult(async () => { + const home = await this.api.data(this.api.as(ctx).GET('/api/me/home')); + // Compact: the page's activity feed, presence and first steps stay out. + return { + agenda: home.agenda, + me: home.me, + team: { thisWeek: home.team.thisWeek, lastWeek: home.team.lastWeek }, + sprints: home.sprints, + suggestions: home.suggestions, + }; + }); + } + @Tool({ name: 'get_portfolio', description: diff --git a/docs/03-api.md b/docs/03-api.md index 156091f..4bc39c9 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -423,6 +423,20 @@ La **cartera** (`portfolio` en GraphQL, `GET /api/me/portfolio`) junta, para cad la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el panel. +La **portada** (`myHome` en GraphQL, `GET /api/me/home`) junta lo demás que se ve en la página de inicio +(`analytics/home.ts`): + +| Bloque | Qué da | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `agenda` | Los próximos 7 días: mis tickets abiertos (asignados o que sigo) que vencen, fin de sprints en curso y releases planificadas | +| `activity` | Lo último en mis proyectos (30 entradas; sin los proyectos en los que soy invitado) | +| `team` / `me` | Tickets terminados por día en los últimos 14 días; esta semana y la anterior, de mis equipos y míos (por quien lo tenía asignado), y mi racha | +| `sprints` | Los sprints en curso, con el mismo pulso que el panel (¿llega?) | +| `projects` | Por proyecto: abiertos, vencidos, actividad por día (14 días) y el sprint en curso con su porcentaje | +| `suggestions` | Para cuando no tengo nada: sin asignar, esperando QA y propuestas por revisar (estas, solo a quien administra); las 5 mayores | +| `firstSteps` | Si ya creé un proyecto, invité a alguien, abrí un ticket y conecté el calendario | +| `online` | Quién está ahora en un ticket de mis proyectos (por la presencia, una entrada por persona) | + El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO): @@ -678,23 +692,23 @@ 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/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 …/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` | -| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | -| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | -| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | -| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | -| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | -| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | -| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | -| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| 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 …/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` | +| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | +| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | +| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | +| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | +| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 3b9e1b3..14000a9 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -114,6 +114,7 @@ Qué gana la página: | `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | | `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | | `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña | +| `MyHomeFacade` | `MyHomeStore` | raíz | El resto de la portada (`myHome`): agenda, actividad, cifras de la semana, sprints, tarjetas, sugerencias, primeros pasos y quién está; se recarga cada minuto y al volver a la pestaña | | `MyViewsFacade` | `MyViewsStore` | raíz | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida | | `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión | | `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) | @@ -285,7 +286,7 @@ visualización de la plataforma: | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/login` | Inicio de sesión (solo sin sesión) | | `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Inicio: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos | +| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, la semana (`ts-week-agenda`), mis vistas guardadas, mi trabajo (`ts-my-work`), lo último en mis proyectos, la semana en cifras, sprints en curso, quién está ahora, «Retomar» y mis proyectos con su actividad | | `/cartera` | 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 | | `/explorar` | Proyectos públicos (sin sesión) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | diff --git a/docs/09-mcp.md b/docs/09-mcp.md index df8a05e..5745e98 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -32,7 +32,7 @@ flowchart LR ## 9.2 Herramientas -57 herramientas, agrupadas por ámbito: +60 herramientas, agrupadas por ámbito: | Ámbito | Herramienta | Qué hace | | ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -49,6 +49,7 @@ flowchart LR | | `get_project_stats` | Cifras de seguimiento | | | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | | | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas | +| | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger | | | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios | | | `get_project_activity` | Actividad reciente | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos | diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 50503a4..3520aec 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -122,3 +122,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa, abre tickets y comenta en los suyos. - Vista de cartera (`/cartera`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas. +- Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora, + la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad, + qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`). diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index eced2e9..330db39 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 5cdef6f..4442c42 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/analytics/home.spec.ts b/libs/api/tracker/application/src/lib/analytics/home.spec.ts new file mode 100644 index 0000000..4cec27e --- /dev/null +++ b/libs/api/tracker/application/src/lib/analytics/home.spec.ts @@ -0,0 +1,215 @@ +import { + IssueReadModel, + IssueTimelineEntry, + ProjectReadModel, + ProjectRole, + ReleaseReadModel, + ReleaseStatus, + SprintReadModel, + SprintStatus, + StatusCategory as C, +} from '@task-stream/api/tracker/domain'; +import { computeHome, HomeProjectInput, lastDays } from './home'; + +const NOW = new Date('2026-10-15T12:00:00Z'); +const ana = { id: 'ana', name: 'Ana' }; +const bob = { id: 'bob', name: 'Bob' }; + +const project = ( + key: string, + role: ProjectRole, + extra: Partial = {}, +): ProjectReadModel => + ({ + id: key.toLowerCase(), + key, + name: `Proyecto ${key}`, + isPublic: false, + archivedAt: null, + members: [ + { user: ana, role }, + { user: bob, role: ProjectRole.MEMBER }, + ], + ...extra, + }) as ProjectReadModel; + +const issue = ( + id: string, + category: C, + extra: Partial = {}, +): IssueReadModel => + ({ + id, + key: id, + projectId: 'web', + title: `Ticket ${id}`, + status: category, + statusCategory: category, + reporter: bob, + assignee: null, + watchers: [], + labels: [], + dueDate: null, + trashedAt: null, + storyPoints: null, + sprint: null, + createdAt: '2026-09-01T09:00:00Z', + updatedAt: '2026-10-01T09:00:00Z', + ...extra, + }) as IssueReadModel; + +let seq = 0; +const done = ( + issueId: string, + at: string, + assigneeId: string, +): IssueTimelineEntry[] => [ + { + id: `${issueId}:${++seq}`, + issueId, + projectId: 'web', + at: '2026-09-01T09:00:00Z', + kind: 'status', + status: 'IN_PROGRESS', + category: C.IN_PROGRESS, + assigneeId, + }, + { + id: `${issueId}:${++seq}`, + issueId, + projectId: 'web', + at, + kind: 'status', + status: 'DONE', + category: C.DONE, + assigneeId, + }, +]; + +const input = (projects: HomeProjectInput[]) => ({ + viewerId: 'ana', + projects, + activity: [], + hasCalendar: false, + online: [], + now: NOW, +}); + +const web = (extra: Partial = {}): HomeProjectInput => ({ + project: project('WEB', ProjectRole.OWNER), + issues: [], + timeline: [], + sprints: [], + releases: [], + activity: new Array(14).fill(0), + ...extra, +}); + +describe('computeHome', () => { + it('puts my coming week on the agenda: tickets, sprint ends, releases', () => { + const home = computeHome( + input([ + web({ + issues: [ + issue('MINE', C.TODO, { assignee: ana, dueDate: '2026-10-17' }), + issue('FOLLOWED', C.TODO, { + watchers: [ana], + dueDate: '2026-10-15', + }), + issue('NOT_MINE', C.TODO, { assignee: bob, dueDate: '2026-10-16' }), + issue('LATER', C.TODO, { assignee: ana, dueDate: '2026-10-30' }), + issue('DONE', C.DONE, { assignee: ana, dueDate: '2026-10-16' }), + ], + sprints: [ + { + id: 's', + name: 'Sprint 7', + status: SprintStatus.ACTIVE, + startDate: '2026-10-08', + endDate: '2026-10-21', + } as SprintReadModel, + ], + releases: [ + { + id: 'r', + name: '2.4', + status: ReleaseStatus.PLANNED, + targetDate: '2026-10-20', + } as ReleaseReadModel, + ], + }), + ]), + ); + expect(home.agenda.map((a) => [a.day, a.kind, a.title])).toEqual([ + ['2026-10-15', 'ISSUE', 'Ticket FOLLOWED'], + ['2026-10-17', 'ISSUE', 'Ticket MINE'], + ['2026-10-20', 'RELEASE', '2.4'], + ['2026-10-21', 'SPRINT_END', 'Sprint 7'], + ]); + }); + + it("counts the team's week and mine, and my streak", () => { + const closed = [ + ['A', '2026-10-15T10:00:00Z', 'ana'], + ['B', '2026-10-14T10:00:00Z', 'ana'], + ['C', '2026-10-13T10:00:00Z', 'ana'], + ['D', '2026-10-11T10:00:00Z', 'ana'], + ['E', '2026-10-05T10:00:00Z', 'bob'], + ] as const; + const home = computeHome( + input([ + web({ + issues: closed.map(([id]) => issue(id, C.DONE)), + timeline: closed.flatMap(([id, at, who]) => done(id, at, who)), + }), + ]), + ); + expect(home.team.perDay).toHaveLength(14); + expect(home.team.perDay[13]).toBe(1); + expect(home.team).toMatchObject({ thisWeek: 4, lastWeek: 1 }); + // Today, yesterday and the day before: three in a row (the 12th breaks it). + expect(home.me).toEqual({ thisWeek: 4, lastWeek: 0, streak: 3 }); + }); + + it('suggests what to do, and keeps a guest out of project-wide views', () => { + const home = computeHome( + input([ + web({ + issues: [ + issue('U1', C.TODO), + issue('U2', C.TODO, { labels: ['propuesta'] }), + issue('Q1', C.QA, { assignee: bob }), + ], + }), + { + ...web(), + project: project('CLI', ProjectRole.GUEST), + issues: [issue('X', C.TODO)], + }, + ]), + ); + expect( + home.suggestions.map((s) => [s.kind, s.projectKey, s.count]), + ).toEqual([ + ['UNASSIGNED', 'WEB', 2], + ['QA_WAITING', 'WEB', 1], + ['PROPOSALS', 'WEB', 1], + ]); + // Only the project seen whole has a card. + expect(home.projects.map((p) => p.projectId)).toEqual(['web']); + expect(home.projects[0]).toMatchObject({ open: 3, overdue: 0 }); + expect(home.firstSteps).toEqual({ + project: true, + invited: true, + ticket: false, + calendar: false, + }); + }); + + it('lists the last 14 days, oldest first', () => { + const days = lastDays(NOW); + expect(days).toHaveLength(14); + expect(days[0]).toBe('2026-10-02'); + expect(days[13]).toBe('2026-10-15'); + }); +}); diff --git a/libs/api/tracker/application/src/lib/analytics/home.ts b/libs/api/tracker/application/src/lib/analytics/home.ts new file mode 100644 index 0000000..c6a9b06 --- /dev/null +++ b/libs/api/tracker/application/src/lib/analytics/home.ts @@ -0,0 +1,274 @@ +import { + ActivityReadModel, + AgendaItemReadModel, + HomeProjectReadModel, + HomeReadModel, + HomeSprintReadModel, + IssueReadModel, + IssueTimelineEntry, + OnlineReadModel, + ProjectReadModel, + ProjectRole, + ReleaseReadModel, + ReleaseStatus, + SprintReadModel, + SprintStatus, + StatusCategory, + SuggestionReadModel, +} from '@task-stream/api/tracker/domain'; +import { PROPOSAL_LABEL } from '../proposals'; +import { guestOnly } from '../queries/read-access'; +import { stepsByIssue } from './analytics'; +import { sprintPulse } from './insights'; + +/* + * The home page beyond «my work»: what comes this week, what moves in my + * projects, how the week goes, and what to do when nothing is mine. Pure, + * like the statistics. + */ + +const DAY = 86_400_000; +const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED]; +const OPEN = [ + StatusCategory.TODO, + StatusCategory.IN_PROGRESS, + StatusCategory.QA, +]; + +export interface HomeProjectInput { + project: ProjectReadModel; + issues: IssueReadModel[]; + timeline: IssueTimelineEntry[]; + sprints: SprintReadModel[]; + releases: ReleaseReadModel[]; + /** Activity per day of the last 14 days, oldest first. */ + activity: number[]; +} + +export interface HomeInput { + viewerId: string; + projects: HomeProjectInput[]; + /** Latest activity of the projects I see whole, newest first. */ + activity: ActivityReadModel[]; + hasCalendar: boolean; + online: OnlineReadModel[]; + now: Date; +} + +const utcDay = (time: number) => + Date.UTC( + new Date(time).getUTCFullYear(), + new Date(time).getUTCMonth(), + new Date(time).getUTCDate(), + ); +const dayString = (time: number) => new Date(time).toISOString().slice(0, 10); + +export function computeHome(input: HomeInput): HomeReadModel { + const now = input.now.getTime(); + const today = utcDay(now); + const todayStr = dayString(today); + const weekEnd = dayString(today + 6 * DAY); + const me = input.viewerId; + const whole = input.projects.filter((p) => !guestOnly(p.project, me)); + + // --- Agenda: the next 7 days --- + const agenda: AgendaItemReadModel[] = []; + for (const { project, issues, sprints, releases } of input.projects) { + for (const issue of issues) { + if (issue.trashedAt || CLOSED.includes(issue.statusCategory)) continue; + const due = issue.dueDate?.slice(0, 10); + if (!due || due < todayStr || due > weekEnd) continue; + if ( + issue.assignee?.id !== me && + !(issue.watchers ?? []).some((w) => w.id === me) + ) + continue; + agenda.push({ + day: due, + kind: 'ISSUE', + projectKey: project.key, + title: issue.title, + issueKey: issue.key, + }); + } + if (guestOnly(project, me)) continue; + for (const sprint of sprints) + if ( + sprint.status === SprintStatus.ACTIVE && + sprint.endDate >= todayStr && + sprint.endDate <= weekEnd + ) + agenda.push({ + day: sprint.endDate, + kind: 'SPRINT_END', + projectKey: project.key, + title: sprint.name, + issueKey: null, + }); + for (const release of releases) { + const target = release.targetDate?.slice(0, 10); + if ( + release.status === ReleaseStatus.PLANNED && + target && + target >= todayStr && + target <= weekEnd + ) + agenda.push({ + day: target, + kind: 'RELEASE', + projectKey: project.key, + title: release.name, + issueKey: null, + }); + } + } + agenda.sort( + (a, b) => a.day.localeCompare(b.day) || a.kind.localeCompare(b.kind), + ); + + // --- Finished per day: the team's and mine --- + const teamPerDay = new Array(14).fill(0); + const minePerDay = new Map(); + for (const { issues, timeline } of whole) { + const steps = stepsByIssue(issues, timeline); + for (const list of steps.values()) + list.forEach((step, i) => { + if (step.category !== StatusCategory.DONE) return; + if (i > 0 && list[i - 1].category === StatusCategory.DONE) return; + if (i === 0) return; // created already done: nobody finished it + const day = utcDay(step.at); + const ago = Math.round((today - day) / DAY); + if (ago >= 0 && ago < 14) teamPerDay[13 - ago]++; + if (step.assigneeId === me) + minePerDay.set(day, (minePerDay.get(day) ?? 0) + 1); + }); + } + const sum = (from: number, to: number) => + teamPerDay.slice(from, to).reduce((a, b) => a + b, 0); + const mine = (daysAgoFrom: number, daysAgoTo: number) => { + let total = 0; + for (let ago = daysAgoFrom; ago < daysAgoTo; ago++) + total += minePerDay.get(today - ago * DAY) ?? 0; + return total; + }; + // A streak counts up to today, or up to yesterday if today is still empty. + let streak = 0; + for ( + let ago = minePerDay.get(today) ? 0 : 1; + minePerDay.get(today - ago * DAY); + ago++ + ) + streak++; + + // --- Sprints and project cards --- + const sprints: HomeSprintReadModel[] = []; + const projects: HomeProjectReadModel[] = []; + for (const { project, issues, sprints: all, activity } of whole) { + const live = issues.filter((i) => !i.trashedAt); + const active = all.find((s) => s.status === SprintStatus.ACTIVE); + const pulse = active ? sprintPulse(active, live, now) : null; + if (pulse) + sprints.push({ + projectKey: project.key, + projectName: project.name, + sprint: pulse, + }); + projects.push({ + projectId: project.id, + open: live.filter((i) => !CLOSED.includes(i.statusCategory)).length, + overdue: live.filter( + (i) => + !CLOSED.includes(i.statusCategory) && + !!i.dueDate && + i.dueDate.slice(0, 10) < todayStr, + ).length, + activity, + sprint: pulse + ? { + name: pulse.name, + percent: pulse.points + ? Math.round((pulse.donePoints / pulse.points) * 100) + : pulse.tickets + ? Math.round((pulse.doneTickets / pulse.tickets) * 100) + : 0, + behind: pulse.behind, + } + : null, + }); + } + + // --- Suggestions, for when nothing is mine --- + const suggestions: SuggestionReadModel[] = []; + for (const { project, issues } of whole) { + const role = project.members.find((m) => m.user.id === me)?.role; + if (!role || role === ProjectRole.VIEWER) continue; + const open = issues.filter( + (i) => !i.trashedAt && OPEN.includes(i.statusCategory), + ); + const unassigned = open.filter((i) => !i.assignee).length; + if (unassigned) + suggestions.push({ + kind: 'UNASSIGNED', + projectKey: project.key, + count: unassigned, + }); + const qa = open.filter( + (i) => i.statusCategory === StatusCategory.QA, + ).length; + if (qa) + suggestions.push({ + kind: 'QA_WAITING', + projectKey: project.key, + count: qa, + }); + if (role === ProjectRole.OWNER || role === ProjectRole.ADMIN) { + const proposals = open.filter( + (i) => + i.statusCategory === StatusCategory.TODO && + i.labels.includes(PROPOSAL_LABEL), + ).length; + if (proposals) + suggestions.push({ + kind: 'PROPOSALS', + projectKey: project.key, + count: proposals, + }); + } + } + suggestions.sort((a, b) => b.count - a.count); + + return { + agenda, + activity: input.activity, + team: { perDay: teamPerDay, thisWeek: sum(7, 14), lastWeek: sum(0, 7) }, + me: { thisWeek: mine(0, 7), lastWeek: mine(7, 14), streak }, + sprints, + projects, + suggestions: suggestions.slice(0, 5), + firstSteps: { + project: input.projects.length > 0, + invited: input.projects.some( + (p) => + p.project.members.length > 1 && + p.project.members.some( + (m) => + m.user.id === me && + (m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN), + ), + ), + ticket: input.projects.some((p) => + p.issues.some((i) => i.reporter?.id === me), + ), + calendar: input.hasCalendar, + }, + online: input.online, + }; +} + +/** The last 14 UTC days (oldest first), as `YYYY-MM-DD`. */ +export function lastDays(now: Date, count = 14): string[] { + const today = utcDay(now.getTime()); + return Array.from({ length: count }, (_, n) => + dayString(today - (count - 1 - n) * DAY), + ); +} diff --git a/libs/api/tracker/application/src/lib/analytics/insights.ts b/libs/api/tracker/application/src/lib/analytics/insights.ts index a67243d..f7cc4ec 100644 --- a/libs/api/tracker/application/src/lib/analytics/insights.ts +++ b/libs/api/tracker/application/src/lib/analytics/insights.ts @@ -523,7 +523,7 @@ function backlogHealth( }; } -function sprintPulse( +export function sprintPulse( sprint: SprintReadModel, issues: IssueReadModel[], now: number, diff --git a/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts b/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts index e0a519c..2b08588 100644 --- a/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts @@ -8,4 +8,14 @@ export abstract class FindActivityRepository { /** ISO date cursor: only activity strictly older than this. */ before?: string; }): Promise; + /** The latest of several projects, newest first. */ + abstract findAcross( + projectIds: string[], + limit: number, + ): Promise; + /** How much happened per project and day (`YYYY-MM-DD`) since `fromDay`. */ + abstract dailyCounts( + projectIds: string[], + fromDay: string, + ): Promise<{ projectId: string; day: string; count: number }[]>; } diff --git a/libs/api/tracker/application/src/lib/ports/presence.store.ts b/libs/api/tracker/application/src/lib/ports/presence.store.ts index 356fe09..78eb19b 100644 --- a/libs/api/tracker/application/src/lib/ports/presence.store.ts +++ b/libs/api/tracker/application/src/lib/ports/presence.store.ts @@ -5,6 +5,8 @@ export interface PresenceEntry { editing: boolean; /** Last heartbeat (ISO). */ at: string; + /** The ticket, in the project-wide list (who is on what). */ + issue?: { key: string; title: string }; } /** @@ -21,4 +23,14 @@ export abstract class PresenceStore { /** Everyone whose heartbeat is newer than `since` (ms epoch). */ abstract list(issueId: string, since: number): Promise; abstract leave(issueId: string, userId: string): Promise; + /** The same, per project: who is on which of its tickets. */ + abstract touchProject( + projectId: string, + entry: PresenceEntry, + ttlMs: number, + ): Promise; + abstract listProject( + projectId: string, + since: number, + ): Promise; } diff --git a/libs/api/tracker/application/src/lib/presence.service.spec.ts b/libs/api/tracker/application/src/lib/presence.service.spec.ts index 0595100..a6f6e71 100644 --- a/libs/api/tracker/application/src/lib/presence.service.spec.ts +++ b/libs/api/tracker/application/src/lib/presence.service.spec.ts @@ -20,6 +20,12 @@ class MemoryPresence extends PresenceStore { async leave(issueId: string, userId: string) { this.entries.get(issueId)?.delete(userId); } + async touchProject() { + /* not needed here */ + } + async listProject() { + return []; + } } const issues = { diff --git a/libs/api/tracker/application/src/lib/presence.service.ts b/libs/api/tracker/application/src/lib/presence.service.ts index 64ae21a..62292dc 100644 --- a/libs/api/tracker/application/src/lib/presence.service.ts +++ b/libs/api/tracker/application/src/lib/presence.service.ts @@ -28,17 +28,22 @@ export class PresenceService { issueId: string, editing: boolean, ): Promise { - await this.assertCanView(viewer.id, issueId); + const issue = await this.assertCanView(viewer.id, issueId); const now = Date.now(); - await this.store.touch( - issueId, - { - user: { id: viewer.id, name: viewer.name }, - editing, - at: new Date(now).toISOString(), - }, - PRESENCE_TTL_MS, - ); + const entry = { + user: { id: viewer.id, name: viewer.name }, + editing, + at: new Date(now).toISOString(), + }; + await Promise.all([ + this.store.touch(issueId, entry, PRESENCE_TTL_MS), + // The project-wide list says on which ticket (home's «who is here»). + this.store.touchProject( + issue.projectId, + { ...entry, issue: { key: issue.key, title: issue.title } }, + PRESENCE_TTL_MS, + ), + ]); const here = await this.store.list(issueId, now - PRESENCE_TTL_MS); return here .filter((entry) => entry.user.id !== viewer.id) @@ -58,5 +63,6 @@ export class PresenceService { const issue = await this.findIssues.findById(issueId); if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId); await this.access.assertCanViewIssue(issue, viewerId); + return issue; } } diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index abf7523..b5c691b 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -14,6 +14,7 @@ import { ProjectAnalyticsReadModel, ProjectInsightsReadModel, PortfolioProjectReadModel, + HomeReadModel, ProjectReadModel, ProjectRole, ProjectSettings, @@ -40,6 +41,7 @@ import { GetMyProjectsQuery } from './queries/get-my-projects.query'; import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query'; import { GetProjectInsightsQuery } from './queries/get-project-insights.query'; import { GetPortfolioQuery } from './queries/get-portfolio.query'; +import { GetMyHomeQuery } from './queries/get-my-home.query'; import { GetProjectStatsQuery } from './queries/get-project-stats.query'; import { GetPublicProjectsQuery } from './queries/get-public-projects.query'; import { GetProjectQuery } from './queries/get-project.query'; @@ -339,6 +341,11 @@ export class ProjectsService { } /** Statistics over the last `days` days (created vs resolved, flow, times, QA). */ + /** The home page beyond «my work». */ + home(viewerId: string): Promise { + return this.queryBus.execute(new GetMyHomeQuery(viewerId)); + } + /** All my active projects at a glance (not archived, not as a guest). */ portfolio(viewerId: string): Promise { return this.queryBus.execute(new GetPortfolioQuery(viewerId)); diff --git a/libs/api/tracker/application/src/lib/queries/get-my-home.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-my-home.query-handler.ts new file mode 100644 index 0000000..0f0c58c --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-my-home.query-handler.ts @@ -0,0 +1,113 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { + HomeReadModel, + OnlineReadModel, +} from '@task-stream/api/tracker/domain'; +import { computeHome, HomeProjectInput, lastDays } from '../analytics/home'; +import { CalendarFeedsRepository } from '../ports/calendar-feeds.repository'; +import { FindActivityRepository } from '../ports/find-activity.repository'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { FindReleasesRepository } from '../ports/find-releases.repository'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; +import { FindTimelineRepository } from '../ports/find-timeline.repository'; +import { PresenceStore } from '../ports/presence.store'; +import { PRESENCE_TTL_MS } from '../presence.service'; +import { GetMyHomeQuery } from './get-my-home.query'; +import { guestOnly } from './read-access'; + +/** How much activity the home page lists. */ +const ACTIVITY = 30; + +@QueryHandler(GetMyHomeQuery) +export class GetMyHomeQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + private readonly findTimeline: FindTimelineRepository, + private readonly findSprints: FindSprintsRepository, + private readonly findReleases: FindReleasesRepository, + private readonly findActivity: FindActivityRepository, + private readonly feeds: CalendarFeedsRepository, + private readonly presence: PresenceStore, + ) {} + + async execute(query: GetMyHomeQuery): Promise { + const me = query.viewerId; + const now = new Date(); + const projects = (await this.findProjects.findByMember(me)).filter( + (p) => !p.archivedAt, + ); + // Project-wide views only of the projects I see whole (not as a guest). + const whole = projects.filter((p) => !guestOnly(p, me)); + const days = lastDays(now); + const [inputs, activity, counts, feed, online] = await Promise.all([ + Promise.all( + projects.map(async (project) => { + const [issues, timeline, sprints, releases] = await Promise.all([ + this.findIssues.findByProject(project.id), + this.findTimeline.findByProject(project.id), + this.findSprints.findByProject(project.id), + this.findReleases.findByProject(project.id), + ]); + return { project, issues, timeline, sprints, releases }; + }), + ), + this.findActivity.findAcross( + whole.map((p) => p.id), + ACTIVITY, + ), + this.findActivity.dailyCounts( + whole.map((p) => p.id), + days[0], + ), + this.feeds.findByUser(me), + Promise.all( + whole.map(async (project) => + ( + await this.presence.listProject( + project.id, + now.getTime() - PRESENCE_TTL_MS, + ) + ) + .filter((entry) => entry.user.id !== me) + .map( + (entry): OnlineReadModel => ({ + user: entry.user, + projectKey: project.key, + issueKey: entry.issue?.key ?? null, + issueTitle: entry.issue?.title ?? null, + editing: entry.editing, + at: entry.at, + }), + ), + ), + ), + ]); + const perDay = (projectId: string) => + days.map( + (day) => + counts.find((c) => c.projectId === projectId && c.day === day) + ?.count ?? 0, + ); + return computeHome({ + viewerId: me, + projects: inputs.map( + (i): HomeProjectInput => ({ ...i, activity: perDay(i.project.id) }), + ), + activity, + hasCalendar: !!feed, + // One line per person: the latest ticket they are on. + online: online + .flat() + .sort((a, b) => b.at.localeCompare(a.at)) + .filter( + (entry, i, all) => + all.findIndex((e) => e.user.id === entry.user.id) === i, + ), + now, + }); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-my-home.query.ts b/libs/api/tracker/application/src/lib/queries/get-my-home.query.ts new file mode 100644 index 0000000..35fa503 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-my-home.query.ts @@ -0,0 +1,12 @@ +import { Query } from '@nestjs/cqrs'; +import { HomeReadModel } from '@task-stream/api/tracker/domain'; + +/** + * The home page beyond «my work»: agenda, activity, the week, sprints, + * project cards, suggestions, first steps and who is on what now. + */ +export class GetMyHomeQuery extends Query { + constructor(public readonly viewerId: string) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index 5e08236..364bbc0 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -9,6 +9,7 @@ export * from './get-project-analytics.query'; export * from './get-project-insights.query'; export * from './get-ticket-scope.query'; export * from './get-portfolio.query'; +export * from './get-my-home.query'; export * from './get-project-stats.query'; export * from './get-release-burnup.query'; export * from './get-project.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index cd07236..1816f67 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -12,6 +12,7 @@ import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-h import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler'; import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler'; import { GetPortfolioQueryHandler } from './get-portfolio.query-handler'; +import { GetMyHomeQueryHandler } from './get-my-home.query-handler'; import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler'; import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler'; import { GetReleaseQueryHandler } from './get-release.query-handler'; @@ -40,6 +41,7 @@ export const QUERY_HANDLERS = [ GetProjectInsightsQueryHandler, GetTicketScopeQueryHandler, GetPortfolioQueryHandler, + GetMyHomeQueryHandler, GetProjectStatsQueryHandler, GetReleaseBurnupQueryHandler, GetReleaseQueryHandler, diff --git a/libs/api/tracker/domain/src/lib/read-models/home.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/home.read-model.ts new file mode 100644 index 0000000..4320308 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/read-models/home.read-model.ts @@ -0,0 +1,89 @@ +import { ActivityReadModel } from './activity.read-model'; +import { SprintPulseReadModel } from './insights.read-model'; +import { UserRefReadModel } from './user-ref.read-model'; + +/** Something on a day of my coming week. */ +export class AgendaItemReadModel { + /** `YYYY-MM-DD`. */ + day!: string; + /** A ticket of mine due, a sprint ending or a release planned. */ + kind!: 'ISSUE' | 'SPRINT_END' | 'RELEASE'; + projectKey!: string; + title!: string; + /** For tickets. */ + issueKey!: string | null; +} + +/** The last 7 days against the 7 before. */ +export class WeekCountReadModel { + thisWeek!: number; + lastWeek!: number; +} + +/** Tickets finished by the teams of my projects. */ +export class TeamWeekReadModel extends WeekCountReadModel { + /** The last 14 days, oldest first. */ + perDay!: number[]; +} + +/** Tickets I finished (they were mine when they reached DONE). */ +export class MyWeekReadModel extends WeekCountReadModel { + /** Days in a row, up to today (or yesterday), with at least one. */ + streak!: number; +} + +/** A sprint under way in one of my projects. */ +export class HomeSprintReadModel { + projectKey!: string; + projectName!: string; + sprint!: SprintPulseReadModel; +} + +/** What a project's card on the home page shows besides its name. */ +export class HomeProjectReadModel { + projectId!: string; + open!: number; + overdue!: number; + /** Activity per day, the last 14 days, oldest first. */ + activity!: number[]; + sprint!: { name: string; percent: number; behind: boolean } | null; +} + +/** Something worth doing when nothing is assigned to me. */ +export class SuggestionReadModel { + kind!: 'UNASSIGNED' | 'PROPOSALS' | 'QA_WAITING'; + projectKey!: string; + count!: number; +} + +/** The first things to do in TaskStream, done or not. */ +export class FirstStepsReadModel { + project!: boolean; + invited!: boolean; + ticket!: boolean; + calendar!: boolean; +} + +/** Someone of my projects on a ticket right now. */ +export class OnlineReadModel { + user!: UserRefReadModel; + projectKey!: string; + issueKey!: string | null; + issueTitle!: string | null; + editing!: boolean; + at!: string; +} + +/** Everything the home page shows beyond «my work». */ +export class HomeReadModel { + agenda!: AgendaItemReadModel[]; + /** Newest first, across my projects. */ + activity!: ActivityReadModel[]; + team!: TeamWeekReadModel; + me!: MyWeekReadModel; + sprints!: HomeSprintReadModel[]; + projects!: HomeProjectReadModel[]; + suggestions!: SuggestionReadModel[]; + firstSteps!: FirstStepsReadModel; + online!: OnlineReadModel[]; +} diff --git a/libs/api/tracker/domain/src/lib/read-models/index.ts b/libs/api/tracker/domain/src/lib/read-models/index.ts index ec48c63..b8498f4 100644 --- a/libs/api/tracker/domain/src/lib/read-models/index.ts +++ b/libs/api/tracker/domain/src/lib/read-models/index.ts @@ -10,3 +10,4 @@ export * from './notification.read-model'; export * from './saved-filter.read-model'; export * from './sprint.read-model'; export * from './insights.read-model'; +export * from './home.read-model'; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 4df56fc..4f34c7c 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -614,6 +614,47 @@ export class MongoFindActivityRepository extends FindActivityRepository { .lean(); return docs.map(fromDoc); } + + async findAcross( + projectIds: string[], + limit: number, + ): Promise { + if (!projectIds.length) return []; + const docs = await this.activity + .find({ projectId: { $in: projectIds } }) + .sort({ occurredAt: -1 }) + .limit(limit) + .lean(); + return docs.map(fromDoc); + } + + async dailyCounts( + projectIds: string[], + fromDay: string, + ): Promise<{ projectId: string; day: string; count: number }[]> { + if (!projectIds.length) return []; + const rows = await this.activity.aggregate<{ + _id: { projectId: string; day: string }; + count: number; + }>([ + { + $match: { + projectId: { $in: projectIds }, + occurredAt: { $gte: fromDay }, + }, + }, + { + $group: { + _id: { + projectId: '$projectId', + day: { $substrCP: ['$occurredAt', 0, 10] }, + }, + count: { $sum: 1 }, + }, + }, + ]); + return rows.map((r) => ({ ...r._id, count: r.count })); + } } @Injectable() diff --git a/libs/api/tracker/infrastructure/src/lib/redis/redis-presence.store.ts b/libs/api/tracker/infrastructure/src/lib/redis/redis-presence.store.ts index 00d1205..de3b581 100644 --- a/libs/api/tracker/infrastructure/src/lib/redis/redis-presence.store.ts +++ b/libs/api/tracker/infrastructure/src/lib/redis/redis-presence.store.ts @@ -20,12 +20,35 @@ export class RedisPresenceStore extends PresenceStore { return `task-stream:presence:${issueId}`; } - async touch( - issueId: string, + private projectKey(projectId: string): string { + return `task-stream:presence:project:${projectId}`; + } + + touchProject( + projectId: string, + entry: PresenceEntry, + ttlMs: number, + ): Promise { + return this.write(this.projectKey(projectId), entry, ttlMs); + } + + listProject(projectId: string, since: number): Promise { + return this.read(this.projectKey(projectId), since); + } + + touch(issueId: string, entry: PresenceEntry, ttlMs: number): Promise { + return this.write(this.key(issueId), entry, ttlMs); + } + + list(issueId: string, since: number): Promise { + return this.read(this.key(issueId), since); + } + + private async write( + key: string, entry: PresenceEntry, ttlMs: number, ): Promise { - const key = this.key(issueId); await this.redis .multi() .hset(key, entry.user.id, JSON.stringify(entry)) @@ -33,8 +56,7 @@ export class RedisPresenceStore extends PresenceStore { .exec(); } - async list(issueId: string, since: number): Promise { - const key = this.key(issueId); + private async read(key: string, since: number): Promise { const all = await this.redis.hgetall(key); const fresh: PresenceEntry[] = []; const stale: string[] = []; diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index 14fa29a..91cb9eb 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -33,6 +33,7 @@ import { ProjectAnalyticsGql, ProjectInsightsGql, PortfolioProjectGql, + HomeGql, ProjectGql, ProjectSettingsGql, ProjectStatsGql, @@ -48,6 +49,14 @@ export class ProjectResolver { return this.projects.findMine(me.id); } + @Query(() => HomeGql, { + description: + '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', + }) + myHome(@CurrentUser() me: User) { + return this.projects.home(me.id); + } + @Query(() => [PortfolioProjectGql], { description: 'My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics', diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 88435e9..a3c04c8 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -1097,3 +1097,95 @@ export class PortfolioProjectGql { epics!: EpicGql[]; @Field() updatedAt!: string; } + +@ObjectType('AgendaItem', { description: 'Something on a day of my week' }) +export class AgendaItemGql { + @Field({ description: 'YYYY-MM-DD' }) day!: string; + @Field({ description: 'ISSUE, SPRINT_END or RELEASE' }) kind!: string; + @Field() projectKey!: string; + @Field() title!: string; + @Field(() => String, { nullable: true }) issueKey!: string | null; +} + +@ObjectType('TeamWeek') +export class TeamWeekGql { + @Field(() => [Int], { + description: 'Finished per day, last 14, oldest first', + }) + perDay!: number[]; + @Field(() => Int) thisWeek!: number; + @Field(() => Int) lastWeek!: number; +} + +@ObjectType('MyWeek') +export class MyWeekGql { + @Field(() => Int) thisWeek!: number; + @Field(() => Int) lastWeek!: number; + @Field(() => Int, { description: 'Days in a row with something finished' }) + streak!: number; +} + +@ObjectType('HomeSprint') +export class HomeSprintGql { + @Field() projectKey!: string; + @Field() projectName!: string; + @Field(() => SprintPulseGql) sprint!: SprintPulseGql; +} + +@ObjectType('HomeSprintBrief') +export class HomeSprintBriefGql { + @Field() name!: string; + @Field(() => Int) percent!: number; + @Field() behind!: boolean; +} + +@ObjectType('HomeProject') +export class HomeProjectGql { + @Field(() => ID) projectId!: string; + @Field(() => Int) open!: number; + @Field(() => Int) overdue!: number; + @Field(() => [Int], { + description: 'Activity per day, last 14, oldest first', + }) + activity!: number[]; + @Field(() => HomeSprintBriefGql, { nullable: true }) + sprint!: HomeSprintBriefGql | null; +} + +@ObjectType('Suggestion') +export class SuggestionGql { + @Field({ description: 'UNASSIGNED, PROPOSALS or QA_WAITING' }) kind!: string; + @Field() projectKey!: string; + @Field(() => Int) count!: number; +} + +@ObjectType('FirstSteps') +export class FirstStepsGql { + @Field() project!: boolean; + @Field() invited!: boolean; + @Field() ticket!: boolean; + @Field() calendar!: boolean; +} + +@ObjectType('Online', { description: 'Someone of my projects on a ticket now' }) +export class OnlineGql { + @Field(() => UserRefGql) user!: UserRefGql; + @Field() projectKey!: string; + @Field(() => String, { nullable: true }) issueKey!: string | null; + @Field(() => String, { nullable: true }) issueTitle!: string | null; + @Field() editing!: boolean; + @Field() at!: string; +} + +@ObjectType('Home', { description: 'The home page beyond my work' }) +export class HomeGql { + @Field(() => [AgendaItemGql]) agenda!: AgendaItemGql[]; + @Field(() => [ActivityGql]) activity!: ActivityGql[]; + @Field(() => TeamWeekGql) team!: TeamWeekGql; + @Field(() => MyWeekGql) me!: MyWeekGql; + @Field(() => [HomeSprintGql]) sprints!: HomeSprintGql[]; + @Field(() => [HomeProjectGql]) projects!: HomeProjectGql[]; + @Field(() => [SuggestionGql]) suggestions!: SuggestionGql[]; + @Field(() => FirstStepsGql) firstSteps!: FirstStepsGql; + @Field(() => [OnlineGql]) online!: OnlineGql[]; +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 2b19853..9d3358c 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -1161,3 +1161,88 @@ export class PortfolioProjectDto { epics!: EpicDto[]; @ApiProperty() updatedAt!: string; } + +export class AgendaItemDto { + @ApiProperty({ format: 'date' }) day!: string; + @ApiProperty({ enum: ['ISSUE', 'SPRINT_END', 'RELEASE'] }) kind!: string; + @ApiProperty() projectKey!: string; + @ApiProperty() title!: string; + @ApiProperty({ type: String, nullable: true }) issueKey!: string | null; +} + +export class TeamWeekDto { + @ApiProperty({ + type: [Number], + description: 'Finished per day, last 14, oldest first', + }) + perDay!: number[]; + @ApiProperty() thisWeek!: number; + @ApiProperty() lastWeek!: number; +} + +export class MyWeekDto { + @ApiProperty() thisWeek!: number; + @ApiProperty() lastWeek!: number; + @ApiProperty({ description: 'Days in a row with something finished' }) + streak!: number; +} + +export class HomeSprintDto { + @ApiProperty() projectKey!: string; + @ApiProperty() projectName!: string; + @ApiProperty({ type: SprintPulseDto }) sprint!: SprintPulseDto; +} + +export class HomeSprintBriefDto { + @ApiProperty() name!: string; + @ApiProperty() percent!: number; + @ApiProperty() behind!: boolean; +} + +export class HomeProjectDto { + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty() open!: number; + @ApiProperty() overdue!: number; + @ApiProperty({ + type: [Number], + description: 'Activity per day, last 14, oldest first', + }) + activity!: number[]; + @ApiProperty({ type: HomeSprintBriefDto, nullable: true }) + sprint!: HomeSprintBriefDto | null; +} + +export class SuggestionDto { + @ApiProperty({ enum: ['UNASSIGNED', 'PROPOSALS', 'QA_WAITING'] }) + kind!: string; + @ApiProperty() projectKey!: string; + @ApiProperty() count!: number; +} + +export class FirstStepsDto { + @ApiProperty() project!: boolean; + @ApiProperty() invited!: boolean; + @ApiProperty() ticket!: boolean; + @ApiProperty() calendar!: boolean; +} + +export class OnlineDto { + @ApiProperty({ type: UserRefDto }) user!: UserRefDto; + @ApiProperty() projectKey!: string; + @ApiProperty({ type: String, nullable: true }) issueKey!: string | null; + @ApiProperty({ type: String, nullable: true }) issueTitle!: string | null; + @ApiProperty() editing!: boolean; + @ApiProperty() at!: string; +} + +export class HomeDto { + @ApiProperty({ type: [AgendaItemDto] }) agenda!: AgendaItemDto[]; + @ApiProperty({ type: [ActivityDto] }) activity!: ActivityDto[]; + @ApiProperty({ type: TeamWeekDto }) team!: TeamWeekDto; + @ApiProperty({ type: MyWeekDto }) me!: MyWeekDto; + @ApiProperty({ type: [HomeSprintDto] }) sprints!: HomeSprintDto[]; + @ApiProperty({ type: [HomeProjectDto] }) projects!: HomeProjectDto[]; + @ApiProperty({ type: [SuggestionDto] }) suggestions!: SuggestionDto[]; + @ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto; + @ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[]; +} diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index e1bb8c3..bce1d4a 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -65,6 +65,7 @@ import { IssueDto, IssuePageDto, PortfolioProjectDto, + HomeDto, PresenceDto, } from './dto/responses.dto'; import { viewerOf } from '../common/viewer'; @@ -95,6 +96,18 @@ export class IssuesController { private readonly projects: ProjectsService, ) {} + @Get('me/home') + @ApiOperation({ + summary: 'The home page beyond my work', + description: + 'Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now.', + }) + @ApiOkResponse({ type: HomeDto }) + @ApiReadErrors() + home(@CurrentUser() me: User) { + return this.projects.home(me.id); + } + @Get('me/portfolio') @ApiOperation({ summary: 'My projects at a glance', diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index 2f678db..1ebaf4a 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -13,6 +13,7 @@ export * from './lib/facades/insights.facade'; export * from './lib/facades/my-views.facade'; export * from './lib/facades/presence.facade'; export * from './lib/facades/portfolio.facade'; +export * from './lib/facades/my-home.facade'; export * from './lib/facades/backlog.facade'; export * from './lib/facades/list.facade'; export * from './lib/facades/propose.facade'; diff --git a/libs/web/tracker/application/src/lib/events/my-home.events.ts b/libs/web/tracker/application/src/lib/events/my-home.events.ts new file mode 100644 index 0000000..ff08a21 --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/my-home.events.ts @@ -0,0 +1,18 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import { Home } from '@task-stream/web/tracker/domain'; + +export const myHomePageEvents = eventGroup({ + source: 'My Home Page', + events: { + refreshed: type(), + }, +}); + +export const myHomeApiEvents = eventGroup({ + source: 'My Home API', + events: { + loaded: type(), + loadFailed: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/facades/my-home.facade.ts b/libs/web/tracker/application/src/lib/facades/my-home.facade.ts new file mode 100644 index 0000000..ede87b1 --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/my-home.facade.ts @@ -0,0 +1,23 @@ +import { inject, Injectable } from '@angular/core'; +import { injectDispatch } from '@ngrx/signals/events'; +import { myHomePageEvents } from '../events/my-home.events'; +import { MyHomeStore } from '../stores/my-home.store'; + +/** + * The home page beyond «my work»: agenda, activity, the week, sprints, + * project cards, suggestions, first steps and who is on what now. + */ +@Injectable({ providedIn: 'root' }) +export class MyHomeFacade { + private readonly store = inject(MyHomeStore); + private readonly dispatch = injectDispatch(myHomePageEvents); + + /** Null until the first load. */ + readonly home = this.store.home; + readonly error = this.store.error; + + /** Loads it (again, to refresh). */ + refresh(): void { + this.dispatch.refreshed(); + } +} diff --git a/libs/web/tracker/application/src/lib/stores/my-home.store.ts b/libs/web/tracker/application/src/lib/stores/my-home.store.ts new file mode 100644 index 0000000..10e5e85 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/my-home.store.ts @@ -0,0 +1,50 @@ +import { inject } from '@angular/core'; +import { mapResponse } from '@ngrx/operators'; +import { signalStore, withState } from '@ngrx/signals'; +import { + Events, + on, + withEventHandlers, + withReducer, +} from '@ngrx/signals/events'; +import { Home, ProjectsGateway } from '@task-stream/web/tracker/domain'; +import { exhaustMap } from 'rxjs'; +import { myHomeApiEvents, myHomePageEvents } from '../events/my-home.events'; +import { describeError } from '../notifications'; + +interface MyHomeState { + home: Home | null; + error: string | null; +} + +/** + * The home page beyond «my work». Root-scoped, so coming back shows the + * last reading at once while it refreshes. + */ +export const MyHomeStore = signalStore( + { providedIn: 'root' }, + withState({ home: null, error: null }), + withReducer( + on(myHomeApiEvents.loaded, ({ payload }) => ({ + home: payload, + error: null, + })), + on(myHomeApiEvents.loadFailed, ({ payload }) => ({ error: payload })), + ), + withEventHandlers( + (_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({ + // A refresh while one is under way waits for it instead of piling up. + load$: events.on(myHomePageEvents.refreshed).pipe( + exhaustMap(() => + projects.myHome().pipe( + mapResponse({ + next: (home) => myHomeApiEvents.loaded(home), + error: (error) => + myHomeApiEvents.loadFailed(describeError(error)), + }), + ), + ), + ), + }), + ), +); diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index c7ec65c..5a0ade4 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -13,3 +13,4 @@ export * from './lib/models/shortcuts'; export * from './lib/models/due'; export * from './lib/models/calendar'; export * from './lib/models/insights'; +export * from './lib/models/home'; diff --git a/libs/web/tracker/domain/src/lib/models/home.ts b/libs/web/tracker/domain/src/lib/models/home.ts new file mode 100644 index 0000000..f3aa0bc --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/home.ts @@ -0,0 +1,58 @@ +import { SprintPulse } from './insights'; +import { Activity, UserRef } from './models'; + +/** Something on a day of my coming week. */ +export interface AgendaItem { + /** `YYYY-MM-DD`. */ + day: string; + kind: 'ISSUE' | 'SPRINT_END' | 'RELEASE'; + projectKey: string; + title: string; + issueKey: string | null; +} + +/** What a project's card shows besides its name. */ +export interface HomeProject { + projectId: string; + open: number; + overdue: number; + /** Activity per day, the last 14, oldest first. */ + activity: number[]; + sprint: { name: string; percent: number; behind: boolean } | null; +} + +/** Something worth doing when nothing is mine. */ +export interface Suggestion { + kind: 'UNASSIGNED' | 'PROPOSALS' | 'QA_WAITING'; + projectKey: string; + count: number; +} + +/** Someone of my projects on a ticket right now. */ +export interface Online { + user: UserRef; + projectKey: string; + issueKey: string | null; + issueTitle: string | null; + editing: boolean; + at: string; +} + +/** The home page beyond «my work». */ +export interface Home { + agenda: AgendaItem[]; + /** Newest first, across my projects. */ + activity: Activity[]; + team: { perDay: number[]; thisWeek: number; lastWeek: number }; + me: { thisWeek: number; lastWeek: number; streak: number }; + sprints: { projectKey: string; projectName: string; sprint: SprintPulse }[]; + projects: HomeProject[]; + suggestions: Suggestion[]; + firstSteps: { + project: boolean; + invited: boolean; + ticket: boolean; + calendar: boolean; + }; + online: Online[]; +} diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 675a70f..8c7e163 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -378,6 +378,8 @@ export interface Release { export interface Activity { id: string; type: string; + /** Absent in activity read before it carried the project. */ + projectId?: string; issueId: string | null; issueKey: string | null; actor: UserRef; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 77160af..73bd737 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -1,3 +1,4 @@ +import { Home } from '../models/home'; import { PortfolioProject, ProjectInsights } from '../models/insights'; import { Observable } from 'rxjs'; import { @@ -69,6 +70,8 @@ export abstract class ProjectsGateway { abstract stats(projectId: string): Observable; /** My active projects at a glance. */ abstract portfolio(): Observable; + /** The home page beyond «my work». */ + abstract myHome(): Observable; /** The dashboard; with `since`, also what changed after it. */ abstract insights( projectId: string, diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts index 340c8ee..848415a 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts @@ -7,6 +7,7 @@ import { ProjectAnalytics, ProjectInsights, PortfolioProject, + Home, ProjectSettings, ProjectsGateway, ProjectStats, @@ -24,6 +25,7 @@ import { ProjectAnalyticsDocument, ProjectInsightsDocument, PortfolioDocument, + MyHomeDocument, ProjectDocument, ProjectStatsDocument, PublicProjectsDocument, @@ -68,6 +70,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway { .pipe(map((d) => toStats(d.project.stats))); } + myHome(): Observable { + return this.gql + .query(MyHomeDocument, {}) + .pipe(map((d) => d.myHome as Home)); + } + portfolio(): Observable { return this.gql.query(PortfolioDocument, {}).pipe( map((d) => diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 31a965f..a711e0f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -198,6 +198,7 @@ fragment ReleaseFields on Release { fragment ActivityFields on Activity { id type + projectId issueId issueKey summary diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 0b2d3bd..8c461a5 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -706,6 +706,7 @@ export type ReleaseFieldsFragment = { export type ActivityFieldsFragment = { id: string; type: string; + projectId: string; issueId: string | null; issueKey: string | null; summary: string; @@ -2142,6 +2143,73 @@ export type PortfolioQuery = { }>; }; +export type MyHomeQueryVariables = Exact<{ [key: string]: never }>; + +export type MyHomeQuery = { + myHome: { + agenda: Array<{ + day: string; + kind: string; + projectKey: string; + title: string; + issueKey: string | null; + }>; + activity: Array<{ + id: string; + type: string; + projectId: string; + issueId: string | null; + issueKey: string | null; + summary: string; + occurredAt: string; + actor: { id: string; name: string }; + }>; + team: { perDay: Array; thisWeek: number; lastWeek: number }; + me: { thisWeek: number; lastWeek: number; streak: number }; + sprints: Array<{ + projectKey: string; + projectName: string; + sprint: { + sprintId: string; + name: string; + startDate: string; + endDate: string; + totalDays: number; + daysLeft: number; + tickets: number; + doneTickets: number; + points: number; + donePoints: number; + pacePerDay: number | null; + neededPerDay: number | null; + behind: boolean; + }; + }>; + projects: Array<{ + projectId: string; + open: number; + overdue: number; + activity: Array; + sprint: { name: string; percent: number; behind: boolean } | null; + }>; + suggestions: Array<{ kind: string; projectKey: string; count: number }>; + firstSteps: { + project: boolean; + invited: boolean; + ticket: boolean; + calendar: boolean; + }; + online: Array<{ + projectKey: string; + issueKey: string | null; + issueTitle: string | null; + editing: boolean; + at: string; + user: { id: string; name: string }; + }>; + }; +}; + export type ReleasesQueryVariables = Exact<{ idOrKey: string; }>; @@ -2198,6 +2266,7 @@ export type ActivityQuery = { activity: Array<{ id: string; type: string; + projectId: string; issueId: string | null; issueKey: string | null; summary: string; @@ -2540,6 +2609,7 @@ export type ActivityAddedSubscription = { activityAdded: { id: string; type: string; + projectId: string; issueId: string | null; issueKey: string | null; summary: string; @@ -3600,6 +3670,7 @@ export const ActivityFieldsFragmentDoc = { selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, @@ -12690,6 +12761,353 @@ export const PortfolioDocument = { }, ], } as unknown as DocumentNode; +export const MyHomeDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'MyHome' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'myHome' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'agenda' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'day' } }, + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'projectKey' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueKey' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'activity' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'ActivityFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'team' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'perDay' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'thisWeek' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'lastWeek' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'me' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'thisWeek' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'lastWeek' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'streak' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprints' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'projectKey' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'projectName' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'sprintId' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'startDate' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'endDate' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'totalDays' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'daysLeft' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'tickets' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'doneTickets' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'points' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'donePoints' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'pacePerDay' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'neededPerDay' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'behind' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'projects' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'projectId' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'open' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'overdue' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'activity' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'percent' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'behind' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'suggestions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'projectKey' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'count' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'firstSteps' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'project' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'invited' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'ticket' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'calendar' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'online' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'projectKey' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueKey' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issueTitle' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'editing' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + 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: 'ActivityFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Activity' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, + { kind: 'Field', name: { kind: 'Name', value: 'occurredAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'actor' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const ReleasesDocument = { kind: 'Document', definitions: [ @@ -13144,6 +13562,7 @@ export const ActivityDocument = { selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, @@ -14875,6 +15294,7 @@ export const ActivityAddedDocument = { selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql index 5b9788d..e533a67 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql @@ -351,3 +351,79 @@ query Portfolio { } } } + +query MyHome { + myHome { + agenda { + day + kind + projectKey + title + issueKey + } + activity { + ...ActivityFields + } + team { + perDay + thisWeek + lastWeek + } + me { + thisWeek + lastWeek + streak + } + sprints { + projectKey + projectName + sprint { + sprintId + name + startDate + endDate + totalDays + daysLeft + tickets + doneTickets + points + donePoints + pacePerDay + neededPerDay + behind + } + } + projects { + projectId + open + overdue + activity + sprint { + name + percent + behind + } + } + suggestions { + kind + projectKey + count + } + firstSteps { + project + invited + ticket + calendar + } + online { + user { + ...UserRefFields + } + projectKey + issueKey + issueTitle + editing + at + } + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 96544e0..9916bfd 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -75,6 +75,7 @@ import { } from '../ui/markdown'; import { RichEditorComponent } from '../ui/rich-editor'; import { CustomFieldComponent } from '../ui/custom-field'; +import { rememberIssue } from '../ui/home/recent-issues'; const NONE = ''; /** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */ @@ -1306,8 +1307,17 @@ export class IssuePage { constructor() { effect(() => this.facade.open(this.issueKey())); effect(() => { - const id = this.facade.issue()?.id; - if (id) untracked(() => this.presence.open(id)); + const issue = this.facade.issue(); + if (!issue) return; + untracked(() => { + this.presence.open(issue.id); + // «Retomar» on the home page. + rememberIssue({ + key: issue.key, + title: issue.title, + projectKey: issue.key.slice(0, issue.key.lastIndexOf('-')), + }); + }); }); effect(() => { const editing = this.editingInDrawer() || !!this.commentBody().trim(); diff --git a/libs/web/tracker/presenters/src/lib/pages/my-work.ts b/libs/web/tracker/presenters/src/lib/pages/my-work.ts index 80e5fd0..7688bce 100644 --- a/libs/web/tracker/presenters/src/lib/pages/my-work.ts +++ b/libs/web/tracker/presenters/src/lib/pages/my-work.ts @@ -14,12 +14,14 @@ import { PiIconComponent, PiSkeletonComponent } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; import { HomeFacade, + MyHomeFacade, ProjectsFacade, } from '@task-stream/web/tracker/application'; import { IssueSummary, Release, StatusCategory, + Suggestion, Workflow, } from '@task-stream/web/tracker/domain'; import { @@ -126,10 +128,20 @@ const VISIBLE = 12; } } @empty { -

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

+

No tienes tickets abiertos asignados.

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

@@ -436,6 +448,17 @@ const VISIBLE = 12; color: var(--ts-muted); font-size: 0.875rem; } + .ts-work__ideas { + display: grid; + gap: 0.375rem; + margin: 0.5rem 0 0; + padding: 0 0.5rem; + list-style: none; + font-size: 0.875rem; + a { + color: var(--ts-brand); + } + } .ts-work__more { margin: 0.75rem 0 0; font-size: 0.75rem; @@ -444,6 +467,31 @@ const VISIBLE = 12; }) export class MyWorkComponent implements OnInit { protected readonly home = inject(HomeFacade); + private readonly myHome = inject(MyHomeFacade); + /** What to do when nothing is mine (from the home reading). */ + protected readonly suggestions = computed( + () => this.myHome.home()?.suggestions ?? [], + ); + + protected suggestionText(s: Suggestion): string { + const n = s.count; + switch (s.kind) { + case 'UNASSIGNED': + return `${n} ${n === 1 ? 'ticket sin dueño' : 'tickets sin dueño'} en ${s.projectKey}`; + case 'QA_WAITING': + return `${n} ${n === 1 ? 'ticket espera' : 'tickets esperan'} QA en ${s.projectKey}`; + default: + return `${n} ${n === 1 ? 'propuesta del público espera' : 'propuestas del público esperan'} respuesta en ${s.projectKey}`; + } + } + + protected suggestionLink(s: Suggestion): unknown[] { + return ['/p', s.projectKey, s.kind === 'UNASSIGNED' ? 'list' : 'board']; + } + + protected suggestionQuery(s: Suggestion): Record | null { + return s.kind === 'UNASSIGNED' ? { missing: 'assignee' } : null; + } private readonly drawer = inject(DrawerService); private readonly calendarPanel = viewChild.required>('calendarPanel'); diff --git a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts index 6a20b41..fc8abc4 100644 --- a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts @@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, + DOCUMENT, inject, OnInit, signal, @@ -27,12 +29,32 @@ import { type PiSelectFieldOption, } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; -import { ProjectsFacade } from '@task-stream/web/tracker/application'; -import { ProjectRole } from '@task-stream/web/tracker/domain'; +import { + HomeFacade, + InboxFacade, + MyHomeFacade, + ProjectsFacade, +} from '@task-stream/web/tracker/application'; +import { + NotificationKind, + ProjectRole, + StatusCategory, +} from '@task-stream/web/tracker/domain'; import { AuthFacade } from '@task-stream/web/identity/application'; import { GoToComponent } from '../ui/go-to'; import { ProjectCardComponent } from '../ui/project-card'; import { MyWorkComponent } from './my-work'; +import { ActivityFeedComponent } from '../ui/home/activity-feed'; +import { DaySummaryComponent } from '../ui/home/day-summary'; +import { FirstStepsComponent } from '../ui/home/first-steps'; +import { HomeSprintsComponent } from '../ui/home/home-sprints'; +import { OnlineNowComponent } from '../ui/home/online-now'; +import { ResumeComponent } from '../ui/home/resume'; +import { WeekAgendaComponent } from '../ui/home/week-agenda'; +import { WeekStatsComponent } from '../ui/home/week-stats'; + +/** How often the home page refreshes its reading while open. */ +const REFRESH_MS = 60_000; @Component({ selector: 'ts-projects-page', @@ -43,6 +65,14 @@ import { MyWorkComponent } from './my-work'; GoToComponent, ProjectCardComponent, MyWorkComponent, + ActivityFeedComponent, + DaySummaryComponent, + FirstStepsComponent, + HomeSprintsComponent, + OnlineNowComponent, + ResumeComponent, + WeekAgendaComponent, + WeekStatsComponent, PiActionButtonComponent, PiEmptyStateComponent, PiSkeletonComponent, @@ -53,14 +83,13 @@ import { MyWorkComponent } from './my-work'; ], template: `

-
-

Pireon · TaskStream

-

- Hola, - {{ firstName() }} -

-

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

-
+
+ @if (myHome.home(); as h) { + + @if (facade.projects().length) { + + } + } + @if (facade.projects().length) { + @if (myHome.home(); as h) { +
+ +
+ + @if (h.sprints.length) { + + } + + +
+
+ }

Proyectos @@ -104,7 +160,10 @@ import { MyWorkComponent } from './my-work'; } @else {
@for (project of facade.sorted(); track project.id) { - + }
} @@ -185,6 +244,24 @@ import { MyWorkComponent } from './my-work'; color: var(--ts-muted); font-size: 0.8125rem; } + ts-first-steps, + ts-week-agenda { + margin-bottom: 1.25rem; + } + .ts-home { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: 1.25rem; + align-items: start; + margin: 1.25rem 0 2rem; + @media (max-width: 960px) { + grid-template-columns: minmax(0, 1fr); + } + } + .ts-home__side { + display: grid; + gap: 1.25rem; + } .ts-public-check { p { /* Under the checkbox's text, past the box. */ @@ -248,8 +325,63 @@ export class ProjectsPage implements OnInit { protected readonly pad = (n: number) => String(n).padStart(2, '0'); + /* The home reading: agenda, activity, the week… refreshed while open. */ + protected readonly myHome = inject(MyHomeFacade); + protected readonly home = inject(HomeFacade); + private readonly inbox = inject(InboxFacade); + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + + private readonly todayStr = new Date().toISOString().slice(0, 10); + private readonly openMine = computed(() => + this.home + .mine() + .filter( + (i) => + i.statusCategory !== StatusCategory.DONE && + i.statusCategory !== StatusCategory.CANCELLED, + ), + ); + protected readonly dueToday = computed( + () => + this.openMine().filter((i) => i.dueDate?.slice(0, 10) === this.todayStr) + .length, + ); + protected readonly overdue = computed( + () => + this.openMine().filter( + (i) => !!i.dueDate && i.dueDate.slice(0, 10) < this.todayStr, + ).length, + ); + protected readonly unreadMentions = computed( + () => + this.inbox + .items() + .filter((n) => n.kind === NotificationKind.MENTIONED && !n.readAt) + .length, + ); + protected readonly projectKeys = computed( + () => new Map(this.facade.projects().map((p) => [p.id, p.key])), + ); + protected readonly statsOf = computed( + () => + new Map( + (this.myHome.home()?.projects ?? []).map((p) => [p.projectId, p]), + ), + ); + ngOnInit(): void { this.facade.load(); + this.myHome.refresh(); + const timer = setInterval(() => this.myHome.refresh(), REFRESH_MS); + const onVisible = () => { + if (this.document.visibilityState === 'visible') this.myHome.refresh(); + }; + this.document.addEventListener('visibilitychange', onVisible); + this.destroyRef.onDestroy(() => { + clearInterval(timer); + this.document.removeEventListener('visibilitychange', onVisible); + }); } protected openCreate(): void { diff --git a/libs/web/tracker/presenters/src/lib/ui/home/activity-feed.ts b/libs/web/tracker/presenters/src/lib/ui/home/activity-feed.ts new file mode 100644 index 0000000..f382a66 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/activity-feed.ts @@ -0,0 +1,150 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Activity } from '@task-stream/web/tracker/domain'; +import { initials, projectTone, timeAgo } from '../copy'; + +/** What moved lately in all my projects. */ +@Component({ + selector: 'ts-activity-feed', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + host: { + class: 'ts-panel', + role: 'region', + 'aria-labelledby': 'feed-title', + }, + styleUrl: './home-panel.scss', + template: ` +
+

Lo último en tus proyectos

+
+ @if (activity().length) { +
    + @for (entry of shown(); track entry.id) { +
  1. + + + @if (entry.issueKey) { + {{ entry.summary }} + } @else { + {{ entry.summary }} + } + + {{ keyOf(entry) }} + {{ ago(entry.occurredAt) }} +
  2. + } +
+ @if (activity().length > SHORT) { + + } + } @else { +

Aún no ha pasado nada en tus proyectos.

+ } + `, + styles: ` + .ts-feed { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + li { + display: grid; + grid-template-columns: 1.75rem minmax(0, 1fr) auto; + grid-template-areas: 'who what project' 'who when when'; + column-gap: 0.625rem; + align-items: center; + font-size: 0.875rem; + } + a { + color: inherit; + text-decoration: none; + &:hover { + color: var(--ts-brand); + } + } + } + .ts-feed__who { + grid-area: who; + display: inline-grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface)); + color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink)); + font-family: var(--ts-font-mono); + font-size: 0.625rem; + font-weight: 600; + } + .ts-feed__what { + grid-area: what; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .ts-feed__project { + grid-area: project; + color: var(--tone); + font-family: var(--ts-font-mono); + font-size: 0.6875rem; + font-weight: 600; + } + .ts-feed__more { + margin-top: 0.75rem; + } + .ts-feed__when { + grid-area: when; + color: var(--ts-muted); + font-size: 0.6875rem; + } + `, +}) +export class ActivityFeedComponent { + readonly activity = input.required(); + /** Project id → key. */ + readonly projectKeys = input.required>(); + + protected readonly SHORT = 10; + protected readonly all = signal(false); + protected readonly shown = computed(() => + this.all() ? this.activity() : this.activity().slice(0, this.SHORT), + ); + protected readonly initialsOf = initials; + protected readonly toneOf = projectTone; + protected readonly ago = (iso: string) => timeAgo(iso); + + protected keyOf(entry: Activity): string { + return ( + this.projectKeys().get(entry.projectId ?? '') ?? + entry.issueKey?.slice(0, entry.issueKey.lastIndexOf('-')) ?? + '' + ); + } +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/day-summary.ts b/libs/web/tracker/presenters/src/lib/ui/home/day-summary.ts new file mode 100644 index 0000000..d0b5ccb --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/day-summary.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; + +const plural = (n: number, one: string, many: string) => + `${n} ${n === 1 ? one : many}`; + +/** «Buenos días, Ana», today's date and what today holds, in one sentence. */ +@Component({ + selector: 'ts-day-summary', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

{{ today() }}

+

+ {{ greeting() }}, + {{ firstName() }} +

+

{{ summary() }}

+ `, + styles: ` + :host { + display: block; + } + h1 { + margin: 0; + } + .ts-day { + margin: 0.375rem 0 0; + color: var(--ts-ink-soft); + } + `, +}) +export class DaySummaryComponent { + readonly firstName = input(''); + readonly dueToday = input(0); + readonly overdue = input(0); + readonly qaFailed = input(0); + readonly mentions = input(0); + /** Injected in tests; the clock otherwise. */ + readonly now = input(new Date()); + + protected readonly greeting = computed(() => { + const hour = this.now().getHours(); + return hour < 6 + ? 'Buenas noches' + : hour < 14 + ? 'Buenos días' + : hour < 21 + ? 'Buenas tardes' + : 'Buenas noches'; + }); + + protected readonly today = computed(() => + this.now().toLocaleDateString('es', { + weekday: 'long', + day: 'numeric', + month: 'long', + }), + ); + + protected readonly summary = computed(() => { + const parts = [ + this.dueToday() + ? `hoy vence ${this.dueToday() === 1 ? '1 ticket tuyo' : `${this.dueToday()} tickets tuyos`}` + : '', + this.overdue() + ? plural(this.overdue(), 'ticket vencido', 'tickets vencidos') + : '', + this.qaFailed() + ? plural(this.qaFailed(), 'vuelto de QA', 'vueltos de QA') + : '', + this.mentions() + ? plural(this.mentions(), 'mención sin leer', 'menciones sin leer') + : '', + ].filter(Boolean); + if (!parts.length) return 'Nada urgente hoy: buen momento para avanzar.'; + const text = + parts.length > 1 + ? `${parts.slice(0, -1).join(', ')} y ${parts[parts.length - 1]}` + : parts[0]; + return text.charAt(0).toUpperCase() + text.slice(1) + '.'; + }); +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/first-steps.ts b/libs/web/tracker/presenters/src/lib/ui/home/first-steps.ts new file mode 100644 index 0000000..906cf2c --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/first-steps.ts @@ -0,0 +1,149 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Home } from '@task-stream/web/tracker/domain'; + +const HIDDEN = 'task-stream.firstSteps.hidden'; + +/** The first things to do in TaskStream; gone once done (or dismissed). */ +@Component({ + selector: 'ts-first-steps', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + host: { class: 'ts-panel', role: 'region', 'aria-labelledby': 'steps-title' }, + styleUrl: './home-panel.scss', + template: ` + @if (shown()) { +
+

Primeros pasos

+ +
+

+ {{ doneCount() }} de 4 hechos + +

+
    +
  1. + @if (steps().project) { + Crear un proyecto + } @else { + + } +
  2. +
  3. + @if (firstKey(); as key) { + Invitar a alguien + } @else { + Invitar a alguien + } +
  4. +
  5. + @if (firstKey(); as key) { + Crear tu primer ticket + } @else { + Crear tu primer ticket + } +
  6. +
  7. + Conectar tu calendario (botón «Calendario», en Mis tickets) +
  8. +
+ } + `, + styles: ` + :host:has(.ts-steps) { + display: block; + } + :host:not(:has(.ts-steps)) { + display: none; + } + .ts-steps__progress { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 0.75rem; + margin: 0 0 0.75rem; + color: var(--ts-ink-soft); + font-size: 0.8125rem; + } + .ts-steps { + display: grid; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; + li { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + &::before { + content: ''; + width: 0.875rem; + height: 0.875rem; + border: 1.5px solid var(--ts-line-strong, var(--ts-muted)); + border-radius: 50%; + flex: none; + } + &.is-done { + color: var(--ts-muted); + text-decoration: line-through; + &::before { + border-color: var(--ts-c-green, #2e9e6a); + background: var(--ts-c-green, #2e9e6a); + } + } + } + a { + color: var(--ts-brand); + } + } + `, +}) +export class FirstStepsComponent { + readonly steps = input.required(); + /** A project to point the links at, if any. */ + readonly firstKey = input(null); + readonly createProject = output(); + + private readonly hidden = signal(readHidden()); + protected readonly doneCount = computed( + () => Object.values(this.steps()).filter(Boolean).length, + ); + protected readonly shown = computed( + () => !this.hidden() && this.doneCount() < 4, + ); + + protected dismiss(): void { + this.hidden.set(true); + try { + localStorage.setItem(HIDDEN, '1'); + } catch { + /* a convenience */ + } + } +} + +function readHidden(): boolean { + try { + return localStorage.getItem(HIDDEN) === '1'; + } catch { + return false; + } +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/home-panel.scss b/libs/web/tracker/presenters/src/lib/ui/home/home-panel.scss new file mode 100644 index 0000000..8ba7689 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/home-panel.scss @@ -0,0 +1,94 @@ +// Shared by the dashboard's insight panels (each host is a .ts-panel). +:host { + display: block; + padding: 1.5rem; +} + +.ts-panel__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; + + h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 700; + } + + .ts-muted { + font-size: 0.8125rem; + } +} + +.ts-issues { + display: grid; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; + + li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: baseline; + gap: 0.5rem; + margin: 0; + font-size: 0.875rem; + } + + a { + color: inherit; + text-decoration: none; + + &:hover { + color: var(--ts-brand); + } + } +} + +.ts-key { + font-family: var(--ts-font-mono); + font-size: 0.75rem; + color: var(--ts-muted); +} + +.ts-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ts-days { + font-family: var(--ts-font-mono); + font-size: 0.75rem; + color: var(--ts-ink-soft); + white-space: nowrap; +} + +.ts-bar { + position: relative; + height: 0.625rem; + border-radius: 0.25rem; + background: var(--ts-sunken); + overflow: hidden; + + > span { + display: block; + height: 100%; + border-radius: 0.25rem; + background: var(--tone, var(--ts-brand)); + } +} + +.ts-warn { + color: var(--ts-c-red, #d14343); +} + +.ts-empty { + margin: 0; + color: var(--ts-muted); + font-size: 0.875rem; +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/home-sprints.ts b/libs/web/tracker/presenters/src/lib/ui/home/home-sprints.ts new file mode 100644 index 0000000..78acf7c --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/home-sprints.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Home } from '@task-stream/web/tracker/domain'; +import { projectTone } from '../copy'; + +/** The sprints under way in my projects: how far, and whether they make it. */ +@Component({ + selector: 'ts-home-sprints', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + host: { + class: 'ts-panel', + role: 'region', + 'aria-labelledby': 'sprints-title', + }, + styleUrl: './home-panel.scss', + template: ` +
+

Sprints en curso

+
+ + `, + styles: ` + .ts-hsprints { + gap: 0.75rem; + li { + grid-template-columns: auto minmax(0, 1fr) auto; + row-gap: 0.25rem; + } + .ts-bar { + grid-column: 1 / -1; + } + } + `, +}) +export class HomeSprintsComponent { + readonly sprints = input.required(); + protected readonly toneOf = projectTone; + + protected left(days: number): string { + return days < 0 + ? 'terminado' + : days === 0 + ? 'último día' + : `quedan ${days} d`; + } + + protected percent(s: Home['sprints'][number]['sprint']): number { + if (s.points) return Math.round((s.donePoints / s.points) * 100); + return s.tickets ? Math.round((s.doneTickets / s.tickets) * 100) : 0; + } +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/online-now.ts b/libs/web/tracker/presenters/src/lib/ui/home/online-now.ts new file mode 100644 index 0000000..3dc5469 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/online-now.ts @@ -0,0 +1,94 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Online } from '@task-stream/web/tracker/domain'; +import { initials } from '../copy'; + +/** Who of my projects is on a ticket right now, and which. */ +@Component({ + selector: 'ts-online-now', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + host: { + class: 'ts-panel', + role: 'region', + 'aria-labelledby': 'online-title', + }, + styleUrl: './home-panel.scss', + template: ` +
+

Ahora mismo

+ {{ online().length }} conectados +
+ @if (online().length) { +
    + @for (o of online(); track o.user.id) { +
  • + + + {{ o.user.name }} + {{ o.editing ? 'edita' : 'mira' }} + @if (o.issueKey) { + {{ + o.issueKey + }} + } + +
  • + } +
+ } @else { +

+ Nadie más de tus proyectos está en un ticket ahora. +

+ } + `, + styles: ` + .ts-online { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + li { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 0.875rem; + } + a { + color: var(--ts-brand); + font-family: var(--ts-font-mono); + font-size: 0.8125rem; + } + } + .ts-online__who { + position: relative; + display: inline-grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface)); + color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink)); + font-family: var(--ts-font-mono); + font-size: 0.625rem; + font-weight: 600; + i { + position: absolute; + right: -1px; + bottom: -1px; + width: 0.5rem; + height: 0.5rem; + border: 2px solid var(--ts-surface); + border-radius: 50%; + background: var(--ts-c-green, #2e9e6a); + } + } + `, +}) +export class OnlineNowComponent { + readonly online = input.required(); + protected readonly initialsOf = initials; +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/recent-issues.ts b/libs/web/tracker/presenters/src/lib/ui/home/recent-issues.ts new file mode 100644 index 0000000..82f1e1d --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/recent-issues.ts @@ -0,0 +1,37 @@ +/* + * The tickets I opened lately, kept in this browser only: a convenience + * («Retomar»), so storage may fail or be empty. + */ +const KEY = 'task-stream.recentIssues'; +const MAX = 8; + +export interface RecentIssue { + key: string; + title: string; + projectKey: string; + at: string; +} + +export function recentIssues(): RecentIssue[] { + try { + const raw = JSON.parse(localStorage.getItem(KEY) ?? '[]'); + return Array.isArray(raw) ? (raw as RecentIssue[]).slice(0, MAX) : []; + } catch { + return []; + } +} + +/** This one first; the oldest drop off. */ +export function rememberIssue(issue: Omit): void { + try { + const rest = recentIssues().filter((i) => i.key !== issue.key); + localStorage.setItem( + KEY, + JSON.stringify( + [{ ...issue, at: new Date().toISOString() }, ...rest].slice(0, MAX), + ), + ); + } catch { + /* a convenience: nothing to do */ + } +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/resume.ts b/libs/web/tracker/presenters/src/lib/ui/home/resume.ts new file mode 100644 index 0000000..6f1d614 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/resume.ts @@ -0,0 +1,49 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { timeAgo } from '../copy'; +import { recentIssues } from './recent-issues'; + +/** The tickets I opened lately, to pick up where I left off. */ +@Component({ + selector: 'ts-resume', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + host: { + class: 'ts-panel', + role: 'region', + 'aria-labelledby': 'resume-title', + }, + styleUrl: './home-panel.scss', + template: ` +
+

Retomar

+
+ @if (items().length) { + + } @else { +

+ Los tickets que abras saldrán aquí para volver a ellos. +

+ } + `, +}) +export class ResumeComponent { + protected readonly items = signal(recentIssues().slice(0, 5)); + protected readonly ago = (iso: string) => timeAgo(iso); +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/week-agenda.ts b/libs/web/tracker/presenters/src/lib/ui/home/week-agenda.ts new file mode 100644 index 0000000..54cfaeb --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/week-agenda.ts @@ -0,0 +1,172 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { AgendaItem } from '@task-stream/web/tracker/domain'; + +const DAY = 86_400_000; +const KIND: Record = { + ISSUE: 'vence', + SPRINT_END: 'fin de sprint', + RELEASE: 'release', +}; + +/** My coming week: what is due, which sprints end, which releases land. */ +@Component({ + selector: 'ts-week-agenda', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + host: { + class: 'ts-panel', + role: 'region', + 'aria-labelledby': 'agenda-title', + }, + styleUrl: './home-panel.scss', + template: ` +
+

Tu semana

+ {{ count() }} cosas en los próximos 7 días +
+
    + @for (day of days(); track day.date) { +
  1. +

    + {{ day.weekday }} {{ day.number }} +

    + @for (item of day.items; track $index) { + + {{ kindOf(item) }} + {{ item.issueKey ?? item.projectKey }} + {{ item.title }} + + } @empty { + — + } +
  2. + } +
+ `, + styles: ` + .ts-agenda { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + li { + display: grid; + align-content: start; + gap: 0.25rem; + min-height: 6rem; + padding: 0.5rem; + border-radius: 0.625rem; + background: var(--ts-sunken); + } + li.is-today { + outline: 2px solid var(--ts-brand); + outline-offset: -2px; + } + } + .ts-agenda__day { + margin: 0 0 0.25rem; + color: var(--ts-muted); + font-size: 0.75rem; + text-transform: capitalize; + strong { + color: var(--ts-ink); + font-size: 1rem; + } + } + .ts-agenda__item { + display: grid; + gap: 0.0625rem; + padding: 0.25rem 0.375rem; + border-left: 3px solid var(--ts-brand); + border-radius: 0.375rem; + background: var(--ts-surface); + color: inherit; + font-family: var(--ts-font-mono); + font-size: 0.6875rem; + text-decoration: none; + &[data-kind='SPRINT_END'] { + border-left-color: var(--ts-c-amber, #c98a12); + } + &[data-kind='RELEASE'] { + border-left-color: var(--ts-c-green, #2e9e6a); + } + &:hover { + background: var(--pi-color-primary-25); + } + } + .ts-agenda__kind { + color: var(--ts-muted); + font-family: inherit; + } + .ts-agenda__title { + overflow: hidden; + font-family: var(--ts-font-body, inherit); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; + } + .ts-agenda__none { + color: var(--ts-muted); + font-size: 0.75rem; + } + @media (max-width: 720px) { + .ts-agenda { + grid-template-columns: minmax(0, 1fr); + li { + min-height: 0; + } + } + } + `, +}) +export class WeekAgendaComponent { + readonly items = input.required(); + readonly now = input(new Date()); + + protected readonly count = computed(() => this.items().length); + protected readonly days = computed(() => { + const now = this.now(); + const start = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + return Array.from({ length: 7 }, (_, n) => { + const time = start + n * DAY; + const date = new Date(time).toISOString().slice(0, 10); + return { + date, + today: n === 0, + weekday: new Date(time).toLocaleDateString('es', { + weekday: 'short', + timeZone: 'UTC', + }), + number: new Date(time).getUTCDate(), + items: this.items().filter((i) => i.day === date), + }; + }); + }); + + protected kindOf(item: AgendaItem): string { + return KIND[item.kind]; + } + + protected link(item: AgendaItem): unknown[] { + return item.issueKey + ? ['/p', item.projectKey, 'issues', item.issueKey] + : [ + '/p', + item.projectKey, + item.kind === 'RELEASE' ? 'releases' : 'sprints', + ]; + } +} diff --git a/libs/web/tracker/presenters/src/lib/ui/home/week-stats.ts b/libs/web/tracker/presenters/src/lib/ui/home/week-stats.ts new file mode 100644 index 0000000..ce28e5f --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/week-stats.ts @@ -0,0 +1,142 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { Home } from '@task-stream/web/tracker/domain'; + +/** «+30 %» or «−10 %» against the week before (nothing when there was none). */ +function change(now: number, before: number): string { + if (!before) return now ? 'nuevo' : ''; + const pct = Math.round(((now - before) / before) * 100); + return `${pct >= 0 ? '+' : '−'}${Math.abs(pct)} %`; +} + +/** How the week goes: mine (and my streak) and my projects' teams. */ +@Component({ + selector: 'ts-week-stats', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'ts-panel', + role: 'region', + 'aria-labelledby': 'stats-title', + }, + styleUrl: './home-panel.scss', + template: ` +
+

La semana

+ Tickets terminados, últimos 7 días +
+
+
+

Tú

+

+ {{ me().thisWeek }} + @if (myChange(); as c) { + {{ + c + }} + } +

+

+ @if (me().streak > 1) { + {{ me().streak }} días seguidos terminando algo + } @else if (me().streak === 1) { + Terminaste algo hoy o ayer + } @else { + Sin racha: hoy puede empezar una + } +

+
+
+

Tus equipos

+

+ {{ team().thisWeek }} + @if (teamChange(); as c) { + {{ + c + }} + } +

+ +
+
+ `, + styles: ` + .ts-week { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + } + .ts-week__label { + margin: 0; + color: var(--ts-muted); + font-size: 0.75rem; + } + .ts-week__value { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 0.125rem 0 0.25rem; + font-size: 1.75rem; + font-weight: 700; + } + .ts-week__change { + color: var(--ts-c-green, #2e9e6a); + font-size: 0.8125rem; + font-weight: 600; + &.is-down { + color: var(--ts-muted); + } + } + .ts-week__sub { + margin: 0; + color: var(--ts-ink-soft); + font-size: 0.8125rem; + } + .ts-week__bars { + display: flex; + align-items: flex-end; + gap: 2px; + height: 2.5rem; + span { + flex: 1; + min-height: 2px; + border-radius: 2px 2px 0 0; + background: color-mix(in srgb, var(--ts-brand) 35%, transparent); + &.is-this-week { + background: var(--ts-brand); + } + } + } + `, +}) +export class WeekStatsComponent { + readonly me = input.required(); + readonly team = input.required(); + + protected readonly myChange = computed(() => + change(this.me().thisWeek, this.me().lastWeek), + ); + protected readonly teamChange = computed(() => + change(this.team().thisWeek, this.team().lastWeek), + ); + private readonly max = computed(() => Math.max(1, ...this.team().perDay)); + + protected height(n: number): number { + return (n / this.max()) * 100; + } +} diff --git a/libs/web/tracker/presenters/src/lib/ui/project-card.ts b/libs/web/tracker/presenters/src/lib/ui/project-card.ts index 6c07c0f..85981f0 100644 --- a/libs/web/tracker/presenters/src/lib/ui/project-card.ts +++ b/libs/web/tracker/presenters/src/lib/ui/project-card.ts @@ -5,7 +5,7 @@ import { input, } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { Project } from '@task-stream/web/tracker/domain'; +import { HomeProject, Project } from '@task-stream/web/tracker/domain'; import { AvatarsComponent } from './avatars'; import { projectTone, PUBLIC_TONE, timeAgo } from './copy'; @@ -36,6 +36,33 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy'; @if (project().description) {

{{ project().description }}

} + @if (stats(); as s) { +
+ + + + {{ s.open }} abiertos + @if (s.overdue) { + · {{ s.overdue }} vencidos + } + + @if (s.sprint; as sprint) { + + {{ sprint.name }} · {{ sprint.percent }}% + @if (sprint.behind) { + · no llega + } + + } +
+ }
(); + /** Open work, activity and sprint (projects I see whole). */ + readonly stats = input(null); + /** 14 days of activity as a line, scaled to its busiest day. */ + protected readonly spark = computed(() => { + const days = this.stats()?.activity ?? []; + const max = Math.max(1, ...days); + return days + .map( + (n, i) => + `${(i / Math.max(1, days.length - 1)) * 100},${22 - (n / max) * 20}`, + ) + .join(' '); + }); protected readonly tone = computed(() => projectTone(this.project().key)); protected readonly memberNames = computed(() => this.project().members.map((m) => m.user.name),