diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 2c43b62..37b086b 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2278,6 +2278,26 @@ "type": "boolean" } }, + { + "name": "dueFrom", + "required": false, + "in": "query", + "description": "Due on or after this day", + "schema": { + "format": "date", + "type": "string" + } + }, + { + "name": "dueTo", + "required": false, + "in": "query", + "description": "Due on or before this day", + "schema": { + "format": "date", + "type": "string" + } + }, { "name": "sort", "required": false, diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 1949be5..c3a6496 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -462,6 +462,12 @@ input IssueFilterInput { areas: [String!] assigneeId: ID categories: [StatusCategory!] + + """Due on or after this day (YYYY-MM-DD)""" + dueFrom: String + + """Due on or before this day (YYYY-MM-DD)""" + dueTo: String labels: [String!] """Only tickets in no sprint""" diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 01fb9e5..cdbf0d9 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -3924,6 +3924,10 @@ export interface operations { sprintId?: string; /** @description Only tickets in no sprint */ noSprint?: boolean; + /** @description Due on or after this day */ + dueFrom?: string; + /** @description Due on or before this day */ + dueTo?: string; /** @description RECENT (default): last updated first. RANK: backlog order. */ sort?: components["schemas"]["IssueSort"]; /** @description Matches key, title or description */ diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 42f1b52..da351a2 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -30,6 +30,7 @@ describe('appRoutes', () => { 'issues/:issueKey', 'backlog', 'sprints', + 'calendar', 'roadmap', 'epics', 'trash', diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index bd4a88a..62b2548 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -19,6 +19,7 @@ | 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | | 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | +| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | diff --git a/docs/03-api.md b/docs/03-api.md index 8d4dab8..c6f300f 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -302,7 +302,7 @@ que funcionan también sin token. | `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | | `publicProjects` | Proyectos públicos (sin token) | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` | -| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | +| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, fecha límite entre `dueFrom` y `dueTo`, texto); `sort: RANK` para el orden del backlog | | `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir | | `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto | | `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` | diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index 2ad2839..d231981 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -28,6 +28,9 @@ export interface IssueFilter { /** Only the tickets hanging from this one. */ parentId?: string; sprintId?: string; + /** Due between these days (`YYYY-MM-DD`, both included). */ + dueFrom?: string; + dueTo?: string; /** Only tickets in no sprint (wins over `sprintId`). */ noSprint?: boolean; /** RECENT (default): last updated first. RANK: backlog order. */ 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 f5241c9..ee6ab0d 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -413,6 +413,13 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) }; if (filter.releaseId) query['release.id'] = filter.releaseId; if (filter.parentId) query['parent.id'] = filter.parentId; + if (filter.dueFrom || filter.dueTo) + query['dueDate'] = { + $ne: null, + ...(filter.dueFrom ? { $gte: filter.dueFrom } : {}), + // Full ISO dates on the last day still count. + ...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}), + }; if (filter.noSprint) query['sprint'] = null; else if (filter.sprintId) query['sprint.id'] = filter.sprintId; if (filter.unassigned) query['assignee'] = null; diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 01b0ca5..e08eb62 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -508,6 +508,20 @@ export class IssueFilterInput { @IsOptional() @IsBoolean() noSprint?: boolean; + @Field({ + nullable: true, + description: 'Due on or after this day (YYYY-MM-DD)', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dueFrom?: string; + @Field({ + nullable: true, + description: 'Due on or before this day (YYYY-MM-DD)', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dueTo?: string; @Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT }) @IsOptional() @IsEnum(IssueSort) diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 7a7344c..db75657 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -509,6 +509,22 @@ export class IssueSearchQueryDto extends PageQueryDto { @IsBoolean() noSprint?: boolean; + @ApiPropertyOptional({ + format: 'date', + description: 'Due on or after this day', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dueFrom?: string; + + @ApiPropertyOptional({ + format: 'date', + description: 'Due on or before this day', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + dueTo?: string; + @ApiPropertyOptional({ enum: IssueSort, enumName: 'IssueSort', 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 d2db009..03b537d 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -164,6 +164,8 @@ export class IssuesController { labels: query.label, sprintId: query.sprintId, noSprint: query.noSprint, + dueFrom: query.dueFrom, + dueTo: query.dueTo, sort: query.sort, search: query.search, limit: query.limit, diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index cb1a175..260f3b8 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -11,3 +11,4 @@ export * from './lib/models/swimlanes'; export * from './lib/models/roadmap'; export * from './lib/models/shortcuts'; export * from './lib/models/due'; +export * from './lib/models/calendar'; diff --git a/libs/web/tracker/domain/src/lib/models/calendar.spec.ts b/libs/web/tracker/domain/src/lib/models/calendar.spec.ts new file mode 100644 index 0000000..350d735 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/calendar.spec.ts @@ -0,0 +1,56 @@ +import { addDays, monthGrid, shiftMonth, toIcs, weekBands } from './calendar'; + +describe('sprint calendar', () => { + it('lays a month out in weeks from Monday', () => { + const weeks = monthGrid('2026-10'); + expect(weeks[0][0]).toBe('2026-09-28'); + expect(weeks.at(-1)?.[6]).toBe('2026-11-01'); + expect(weeks.every((w) => w.length === 7)).toBe(true); + expect(shiftMonth('2026-12', 1)).toBe('2027-01'); + expect(shiftMonth('2026-01', -1)).toBe('2025-12'); + expect(addDays('2026-02-28', 1)).toBe('2026-03-01'); + }); + + it('places sprints as bands across a week, in rows that do not overlap', () => { + const week = monthGrid('2026-10')[2]; // 12 to 18 October + const bands = weekBands( + [ + { id: 'a', startDate: '2026-10-05', endDate: '2026-10-14' }, + { id: 'b', startDate: '2026-10-15', endDate: '2026-10-28' }, + { id: 'c', startDate: '2026-10-13', endDate: '2026-10-13' }, + { id: 'x', startDate: '2026-11-01', endDate: '2026-11-10' }, + ], + week, + ); + expect(bands.map((b) => [b.item.id, b.column, b.span, b.row])).toEqual([ + ['a', 1, 3, 0], + ['c', 2, 1, 1], + ['b', 4, 4, 0], + ]); + expect(bands[0]).toMatchObject({ + continuesBefore: true, + continuesAfter: false, + }); + expect(bands[2]).toMatchObject({ continuesAfter: true }); + }); + + it('writes an iCalendar file with all-day events', () => { + const ics = toIcs( + 'WEB, sprints', + [ + { + uid: 's1', + title: 'Sprint 1; pagos', + start: '2026-10-12', + end: '2026-10-23', + }, + ], + new Date('2026-10-08T10:00:00Z'), + ); + expect(ics).toContain('X-WR-CALNAME:WEB\\, sprints'); + expect(ics).toContain('DTSTART;VALUE=DATE:20261012'); + expect(ics).toContain('DTEND;VALUE=DATE:20261024'); + expect(ics).toContain('SUMMARY:Sprint 1\\; pagos'); + expect(ics.split('\r\n')[0]).toBe('BEGIN:VCALENDAR'); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/calendar.ts b/libs/web/tracker/domain/src/lib/models/calendar.ts new file mode 100644 index 0000000..3b2a09b --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/calendar.ts @@ -0,0 +1,146 @@ +/* + * The sprint calendar: a month as weeks of days (Monday first), the sprints + * as bands across them, and the same plan as an iCalendar file. Days are + * `YYYY-MM-DD` strings throughout: no time zones to trip over. + */ + +const DAY = 86_400_000; + +const toDay = (time: number) => new Date(time).toISOString().slice(0, 10); +const timeOf = (day: string) => Date.parse(`${day}T00:00:00Z`); + +export function addDays(day: string, days: number): string { + return toDay(timeOf(day) + days * DAY); +} + +/** `YYYY-MM` of a day, and the month after or before it. */ +export function monthOf(day: string): string { + return day.slice(0, 7); +} + +export function shiftMonth(month: string, by: number): string { + const [year, m] = month.split('-').map(Number); + const date = new Date(Date.UTC(year, m - 1 + by, 1)); + return date.toISOString().slice(0, 7); +} + +/** The weeks (Monday to Sunday) that cover a month, with spill-over days. */ +export function monthGrid(month: string): string[][] { + const first = `${month}-01`; + const weekday = (new Date(timeOf(first)).getUTCDay() + 6) % 7; + let day = addDays(first, -weekday); + const next = `${shiftMonth(month, 1)}-01`; + const weeks: string[][] = []; + while (day < next) { + const week: string[] = []; + for (let i = 0; i < 7; i++) { + week.push(day); + day = addDays(day, 1); + } + weeks.push(week); + } + return weeks; +} + +export interface CalendarSpan { + id: string; + startDate: string; + endDate: string; +} + +/** Where a span sits in a week: columns 1-7, and a row so none overlap. */ +export interface WeekBand { + item: T; + /** First column (1 = Monday). */ + column: number; + span: number; + row: number; + /** It goes on before this week / after it. */ + continuesBefore: boolean; + continuesAfter: boolean; +} + +/** The spans that touch a week, laid out in rows (first come, first row). */ +export function weekBands( + items: T[], + week: string[], +): WeekBand[] { + const [first, last] = [week[0], week[6]]; + const rows: string[][] = []; + return items + .filter((item) => item.startDate <= last && item.endDate >= first) + .sort((a, b) => a.startDate.localeCompare(b.startDate)) + .map((item) => { + const start = item.startDate < first ? first : item.startDate; + const end = item.endDate > last ? last : item.endDate; + const column = week.indexOf(start) + 1; + const span = week.indexOf(end) - week.indexOf(start) + 1; + // The first row whose last band ended before this one starts. + let row = rows.findIndex((ends) => ends[ends.length - 1] < start); + if (row === -1) { + rows.push([]); + row = rows.length - 1; + } + rows[row].push(end); + return { + item, + column, + span, + row, + continuesBefore: item.startDate < first, + continuesAfter: item.endDate > last, + }; + }); +} + +export interface CalendarEvent { + uid: string; + title: string; + /** First day, and the last one included (same for one-day events). */ + start: string; + end: string; + description?: string | null; + url?: string | null; +} + +const icsText = (text: string) => + text + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/[,;]/g, (c) => `\\${c}`); +const icsDay = (day: string) => day.replace(/-/g, ''); + +/** The plan as an iCalendar file, all-day events, for any calendar app. */ +export function toIcs( + name: string, + events: CalendarEvent[], + now = new Date(), +): string { + const stamp = now + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TaskStream//Calendario de sprints//ES', + 'CALSCALE:GREGORIAN', + `X-WR-CALNAME:${icsText(name)}`, + ...events.flatMap((event) => [ + 'BEGIN:VEVENT', + `UID:${event.uid}@taskstream`, + `DTSTAMP:${stamp}`, + `DTSTART;VALUE=DATE:${icsDay(event.start)}`, + // DTEND is exclusive: the day after the last one. + `DTEND;VALUE=DATE:${icsDay(addDays(event.end, 1))}`, + `SUMMARY:${icsText(event.title)}`, + ...(event.description + ? [`DESCRIPTION:${icsText(event.description)}`] + : []), + ...(event.url ? [`URL:${event.url}`] : []), + 'END:VEVENT', + ]), + 'END:VCALENDAR', + ]; + return lines.join('\r\n') + '\r\n'; +} diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 5b3f2f3..29d88b1 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -115,6 +115,12 @@ export abstract class IssuesGateway { parentId: string | null, ): Observable; abstract epics(projectId: string): Observable; + /** Tickets due between two days (`YYYY-MM-DD`, both included). */ + abstract due( + projectId: string, + from: string, + to: string, + ): Observable; /** To the trash (whoever opened it, or a manager). */ abstract trash(issueId: string): Observable; abstract restore(issueId: string): Observable; diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index e8aa18b..ac2f789 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -25,6 +25,7 @@ import { BoardDocument, ChangeIssueStatusDocument, CreateIssueDocument, + DueIssuesDocument, EpicsDocument, IssueChildrenDocument, IssueDocument, @@ -183,6 +184,14 @@ export class GraphqlIssuesGateway extends IssuesGateway { ); } + due(projectId: string, from: string, to: string): Observable { + return this.gql + .query(DueIssuesDocument, { + filter: { projectId, dueFrom: from, dueTo: to }, + }) + .pipe(map((d) => d.issues.items.map(toIssueSummary))); + } + trash(issueId: string): Observable { return this.gql .mutate(TrashIssueDocument, { issueId }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index dce6e2a..a7e153a 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -149,6 +149,10 @@ export type IssueFilterInput = { areas?: Array | null | undefined; assigneeId?: string | null | undefined; categories?: Array | null | undefined; + /** Due on or after this day (YYYY-MM-DD) */ + dueFrom?: string | null | undefined; + /** Due on or before this day (YYYY-MM-DD) */ + dueTo?: string | null | undefined; labels?: Array | null | undefined; /** Only tickets in no sprint */ noSprint?: boolean | null | undefined; @@ -1124,6 +1128,39 @@ export type TrashQuery = { }>; }; +export type DueIssuesQueryVariables = Exact<{ + filter: IssueFilterInput; +}>; + +export type DueIssuesQuery = { + issues: { + items: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + rank: string; + storyPoints: number | null; + startDate: string | null; + dueDate: string | null; + trashedAt: string | null; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + }>; + }; +}; + export type NotificationFieldsFragment = { id: string; kind: NotificationKind; @@ -6993,6 +7030,167 @@ export const TrashDocument = { }, ], } as unknown as DocumentNode; +export const DueIssuesDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'DueIssues' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'filter' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'IssueFilterInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'issues' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'filter' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'filter' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { kind: 'IntValue', value: '200' }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'items' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, + { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const MyNotificationsDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 8d73545..7615238 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -239,3 +239,11 @@ query Trash($projectId: ID!) { ...IssueSummaryFields } } + +query DueIssues($filter: IssueFilterInput!) { + issues(filter: $filter, limit: 200) { + items { + ...IssueSummaryFields + } + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/calendar.page.ts b/libs/web/tracker/presenters/src/lib/pages/calendar.page.ts new file mode 100644 index 0000000..a28035f --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/calendar.page.ts @@ -0,0 +1,601 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + signal, + TemplateRef, + untracked, + viewChild, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { + PiActionButtonComponent, + PiLabelDirective, + PiTextareaComponent, + PiTextFieldComponent, +} from '@pi/ui'; +import { DrawerService, ToastService } from '@task-stream/web/shared/ui'; +import { ProjectFacade } from '@task-stream/web/tracker/application'; +import { + addDays, + CalendarEvent, + dueState, + IssuesGateway, + IssueSummary, + localDay, + monthGrid, + monthOf, + ReleaseStatus, + shiftMonth, + Sprint, + SprintStatus, + toIcs, + weekBands, +} from '@task-stream/web/tracker/domain'; + +const WEEKDAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; +/** Tickets listed in a day before "+N más". */ +const PER_DAY = 3; + +/** + * /p/:key/calendar — the sprints of the project month by month, with the + * releases and the tickets due each day. Managers plan a sprint by clicking + * the day it starts; the whole plan downloads as an .ics file. + */ +@Component({ + selector: 'ts-calendar-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + RouterLink, + PiActionButtonComponent, + PiLabelDirective, + PiTextareaComponent, + PiTextFieldComponent, + ], + template: ` +
+
+ +

{{ monthLabel() }}

+ + +
+ +
    +
  • Sprint en curso
  • +
  • Sprint planificado
  • +
  • Cerrado
  • +
  • Release
  • +
+ +
+ @if (project.canManage()) { +

+ Pulsa el día en que empieza un sprint para planificarlo. +

+ } + +
+
+ @for (day of weekdays; track day) { + {{ day }} + } +
+ @for (week of weeks(); track week[0]) { +
+
+ @for (band of bandsOf(week); track band.item.id) { + + {{ band.item.name }} + + } +
+ @for (day of week; track day) { +
+ + + @for (release of releasesOn(day); track release.id) { + ◆ {{ release.name }} + } + @for (issue of dueOn(day).slice(0, perDay); track issue.id) { + {{ issue.key }} {{ issue.title }} + } + @if (dueOn(day).length > perDay) { + +{{ dueOn(day).length - perDay }} más + } +
+ } +
+ } +
+ + +
+ + Nombre + +
+ + Empieza + + + Acaba + +
+ + Objetivo + + + +
+ `, + styles: ` + .ts-cal__toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 0.5rem; + } + .ts-cal__nav { + display: flex; + gap: 0.5rem; + align-items: center; + } + .ts-cal__month { + min-width: 10rem; + margin: 0; + font-size: 1.125rem; + text-align: center; + text-transform: capitalize; + } + .ts-cal__arrow { + width: 2rem; + height: 2rem; + border: 1px solid var(--ts-line); + border-radius: 0.5rem; + background: var(--ts-surface); + color: inherit; + font-size: 1.125rem; + cursor: pointer; + } + .ts-cal__legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.75rem; + color: var(--ts-muted); + li { + display: inline-flex; + gap: 0.375rem; + align-items: center; + } + } + .ts-cal__key { + width: 0.875rem; + height: 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--ts-brand) 35%, transparent); + &.is-active { + background: var(--ts-brand); + } + &.is-closed { + background: var(--ts-line-strong); + } + } + .ts-cal__hint { + margin: 0 0 0.75rem; + font-size: 0.8125rem; + } + .ts-cal { + overflow-x: auto; + border: 1px solid var(--ts-line); + border-radius: 0.75rem; + background: var(--ts-surface); + } + .ts-cal__weekdays, + .ts-cal__week { + display: grid; + grid-template-columns: repeat(7, minmax(7.5rem, 1fr)); + min-width: 52.5rem; + } + .ts-cal__weekdays span { + padding: 0.375rem 0.5rem; + color: var(--ts-muted); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + .ts-cal__week { + position: relative; + border-top: 1px solid var(--ts-line); + } + /* Sprint bands sit under the day numbers, across the week. */ + .ts-cal__bands { + position: absolute; + inset: 2rem 0 auto; + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-auto-rows: 1.25rem; + gap: 0.125rem 0; + pointer-events: none; + } + .ts-cal__band { + overflow: hidden; + margin: 0 0.25rem; + padding: 0 0.5rem; + border: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--ts-brand) 30%, transparent); + color: var(--ts-ink); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25rem; + text-align: left; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: auto; + &[data-status='ACTIVE'] { + background: var(--ts-brand); + color: var(--ts-on-brand, #fff); + } + &[data-status='CLOSED'] { + background: var(--ts-line-strong); + color: var(--ts-muted); + } + &.continues-before { + margin-left: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &.continues-after { + margin-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + .ts-cal__day { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-height: 7.5rem; + padding: 0.25rem 0.375rem 0.375rem; + border-left: 1px solid var(--ts-line); + &:first-of-type { + border-left: 0; + } + &.is-other { + background: color-mix(in srgb, var(--ts-line) 35%, transparent); + color: var(--ts-muted); + } + } + .ts-cal__spacer { + flex: none; + height: calc(var(--rows, 0) * 1.375rem); + } + .ts-cal__number { + flex: none; + width: 1.75rem; + height: 1.5rem; + border: 0; + border-radius: 0.375rem; + background: none; + color: inherit; + font: inherit; + font-size: 0.8125rem; + cursor: pointer; + &:disabled { + cursor: default; + } + &:not(:disabled):hover { + background: color-mix(in srgb, var(--ts-brand) 14%, transparent); + } + } + .ts-cal__day.is-today .ts-cal__number { + background: var(--ts-brand); + color: var(--ts-on-brand, #fff); + font-weight: 700; + } + .ts-cal__release, + .ts-cal__issue, + .ts-cal__more { + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; + } + .ts-cal__release { + color: var(--ts-c-violet, var(--ts-brand)); + font-weight: 600; + text-decoration: none; + &.is-shipped { + color: var(--ts-c-green, var(--ts-muted)); + } + } + .ts-cal__issue { + color: inherit; + text-decoration: none; + &:hover { + color: var(--ts-brand); + } + &[data-due='overdue'] { + color: var(--ts-c-red, inherit); + } + } + .ts-cal__more { + color: var(--ts-muted); + } + `, +}) +export class CalendarPage { + protected readonly project = inject(ProjectFacade); + private readonly issues = inject(IssuesGateway); + private readonly drawer = inject(DrawerService); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly planForm = + viewChild.required>('planForm'); + + protected readonly weekdays = WEEKDAYS; + protected readonly perDay = PER_DAY; + protected readonly todayDay = localDay(new Date()); + protected readonly monthOf = monthOf; + protected readonly month = signal(monthOf(this.todayDay)); + protected readonly weeks = computed(() => monthGrid(this.month())); + private readonly dueIssues = signal([]); + + protected readonly monthLabel = computed(() => + new Date(`${this.month()}-01T00:00:00Z`).toLocaleDateString('es', { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }), + ); + + /* Plan a sprint */ + protected readonly name = signal(''); + protected readonly start = signal(''); + protected readonly end = signal(''); + protected readonly goal = signal(''); + protected readonly canPlan = computed( + () => + !!this.name().trim() && + /^\d{4}-\d{2}-\d{2}$/.test(this.start()) && + /^\d{4}-\d{2}-\d{2}$/.test(this.end()) && + this.start() <= this.end(), + ); + + constructor() { + // Tickets due in the weeks shown (spill-over days included). + effect(() => { + const projectId = this.project.project()?.id; + const weeks = this.weeks(); + if (!projectId) return; + const from = weeks[0][0]; + const to = weeks[weeks.length - 1][6]; + untracked(() => + this.issues + .due(projectId, from, to) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (issues) => this.dueIssues.set(issues), + error: () => this.dueIssues.set([]), + }), + ); + }); + } + + protected bandsOf(week: string[]) { + return weekBands(this.project.sprints(), week); + } + + protected rowsOf(week: string[]): number { + return this.bandsOf(week).reduce( + (rows, band) => Math.max(rows, band.row + 1), + 0, + ); + } + + protected releasesOn(day: string) { + return this.project + .releases() + .filter( + (r) => + r.status !== ReleaseStatus.ARCHIVED && + (r.releasedAt ?? r.targetDate)?.slice(0, 10) === day, + ); + } + + protected dueOn(day: string): IssueSummary[] { + return this.dueIssues().filter((i) => i.dueDate?.slice(0, 10) === day); + } + + protected due(issue: IssueSummary) { + return dueState(issue.dueDate, issue.statusCategory, this.todayDay); + } + + protected dayLabel(day: string): string { + return new Date(`${day}T00:00:00Z`).toLocaleDateString('es', { + weekday: 'long', + day: 'numeric', + month: 'long', + timeZone: 'UTC', + }); + } + + protected sprintTitle(sprint: Sprint): string { + const status = + sprint.status === SprintStatus.ACTIVE + ? 'en curso' + : sprint.status === SprintStatus.CLOSED + ? 'cerrado' + : 'planificado'; + return `${sprint.name} (${status}) · ${sprint.startDate} – ${sprint.endDate} · ${sprint.progress.finished}/${sprint.progress.total} tickets`; + } + + protected move(by: number): void { + this.month.update((month) => shiftMonth(month, by)); + } + + protected today(): void { + this.month.set(monthOf(this.todayDay)); + } + + /** Two weeks from the day clicked, named after the next number. */ + protected planFrom(day: string): void { + if (!this.project.canManage()) return; + this.name.set(`Sprint ${this.project.sprints().length + 1}`); + this.start.set(day); + this.end.set(addDays(day, 13)); + this.goal.set(''); + this.drawer.open('Planificar sprint', this.planForm()); + } + + protected plan(): void { + if (!this.canPlan()) return; + this.project.createSprint({ + name: this.name().trim(), + goal: this.goal().trim() || null, + startDate: this.start(), + endDate: this.end(), + }); + this.drawer.closeAll(); + } + + /** Sprints, releases and due tickets of the project, for any calendar app. */ + protected downloadIcs(): void { + const project = this.project.project(); + if (!project) return; + const base = `${location.origin}/p/${project.key}`; + const events: CalendarEvent[] = [ + ...this.project.sprints().map((s) => ({ + uid: `sprint-${s.id}`, + title: `${project.key} · ${s.name}`, + start: s.startDate, + end: s.endDate, + description: s.goal, + url: `${base}/sprints`, + })), + ...this.project + .releases() + .filter((r) => r.targetDate || r.releasedAt) + .map((r) => { + const day = (r.releasedAt ?? r.targetDate ?? '').slice(0, 10); + return { + uid: `release-${r.id}`, + title: `${project.key} · Release ${r.name}`, + start: day, + end: day, + url: `${base}/releases`, + }; + }), + ...this.dueIssues().map((i) => { + const day = (i.dueDate ?? '').slice(0, 10); + return { + uid: `due-${i.id}`, + title: `${i.key} vence: ${i.title}`, + start: day, + end: day, + url: `${base}/issues/${i.key}`, + }; + }), + ]; + const blob = new Blob([toIcs(`${project.key} · ${project.name}`, events)], { + type: 'text/calendar;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${project.key}-sprints.ics`; + link.click(); + URL.revokeObjectURL(url); + this.toast.success('Calendario descargado'); + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts index 199c1de..92906e0 100644 --- a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts @@ -87,6 +87,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy'; Tablero Backlog Sprints + Calendario Épicas Hoja de ruta Releases diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts index 2ae1475..368179d 100644 --- a/libs/web/tracker/presenters/src/lib/tracker.routes.ts +++ b/libs/web/tracker/presenters/src/lib/tracker.routes.ts @@ -50,6 +50,12 @@ export const trackerRoutes: Routes = [ loadComponent: () => import('./pages/sprints.page').then((m) => m.SprintsPage), }, + { + path: 'calendar', + title: 'Calendario · TaskStream', + loadComponent: () => + import('./pages/calendar.page').then((m) => m.CalendarPage), + }, { path: 'roadmap', title: 'Hoja de ruta · TaskStream',