From 45a3dc1e16fa6302e92cca59f3ce257536d3b51b Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 15:13:58 +0200 Subject: [PATCH] feat: a roadmap of epics over time Tickets get a start date next to their due date, both editable on the ticket page. The Hoja de ruta tab lays the epics out over the months as bars filled with their progress, with sprints and releases as landmarks and a line for today. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 19 + apps/api/schema.gql | 9 + apps/mcp/src/app/api/taskstream.schema.ts | 15 + apps/web/src/app/app.routes.spec.ts | 1 + docs/01-vision-general.md | 1 + .../lib/event-handlers/activity-summary.ts | 1 + .../issue-created.event-handler.ts | 1 + .../domain/src/lib/factories/issue.factory.ts | 6 + .../domain/src/lib/issue/issue.spec.ts | 20 + .../api/tracker/domain/src/lib/issue/issue.ts | 22 ++ .../src/lib/read-models/issue.read-model.ts | 1 + .../src/lib/mongo/read-model.schemas.ts | 1 + .../presenters/src/lib/graphql/inputs.ts | 8 + .../presenters/src/lib/graphql/types.ts | 5 + .../src/lib/http/dto/requests.dto.ts | 10 + .../src/lib/http/dto/responses.dto.ts | 7 + libs/web/tracker/domain/src/index.ts | 1 + .../tracker/domain/src/lib/models/inputs.ts | 2 + .../tracker/domain/src/lib/models/models.ts | 4 +- .../domain/src/lib/models/roadmap.spec.ts | 33 ++ .../tracker/domain/src/lib/models/roadmap.ts | 72 ++++ .../src/lib/graphql/fragments.graphql | 3 +- .../src/lib/graphql/generated.ts | 48 ++- .../presenters/src/lib/pages/issue.page.ts | 31 ++ .../src/lib/pages/project-shell.page.ts | 1 + .../presenters/src/lib/pages/roadmap.page.ts | 373 ++++++++++++++++++ .../presenters/src/lib/pages/settings.page.ts | 7 +- .../presenters/src/lib/tracker.routes.ts | 6 + 28 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 libs/web/tracker/domain/src/lib/models/roadmap.spec.ts create mode 100644 libs/web/tracker/domain/src/lib/models/roadmap.ts create mode 100644 libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5b6e97a..1d89547 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -8503,6 +8503,12 @@ "type": "number", "nullable": true }, + "startDate": { + "type": "string", + "nullable": true, + "format": "date", + "description": "When work should start (roadmap)" + }, "dueDate": { "type": "string", "nullable": true, @@ -8583,6 +8589,7 @@ "labels", "figmaUrl", "storyPoints", + "startDate", "dueDate", "links", "attachments", @@ -9070,6 +9077,12 @@ "minimum": 0, "maximum": 100 }, + "startDate": { + "type": "string", + "nullable": true, + "format": "date", + "description": "When work should start (roadmap)" + }, "dueDate": { "type": "string", "nullable": true, @@ -9191,6 +9204,12 @@ "minimum": 0, "maximum": 100 }, + "startDate": { + "type": "string", + "nullable": true, + "format": "date", + "description": "When work should start (roadmap)" + }, "dueDate": { "type": "string", "nullable": true, diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 55eb7f4..57229e2 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -149,6 +149,9 @@ input CreateIssueInput { priority: IssuePriority projectId: ID! releaseId: ID + + """When work should start (roadmap), YYYY-MM-DD""" + startDate: String storyPoints: Int title: String! @@ -262,6 +265,9 @@ type Issue { reporter: UserRef! sprint: SprintRef + """When work should start (roadmap)""" + startDate: String + """A status id of the project's settings""" status: String! statusCategory: StatusCategory! @@ -955,6 +961,9 @@ input UpdateIssueInput { issueId: ID! labels: [String!] priority: IssuePriority + + """When work should start (roadmap), YYYY-MM-DD""" + startDate: String storyPoints: Int title: String type: String diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 063cc77..f689eac 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1417,6 +1417,11 @@ export interface components { /** @description Figma frame */ figmaUrl: string | null; storyPoints: number | null; + /** + * Format: date + * @description When work should start (roadmap) + */ + startDate: string | null; /** Format: date */ dueDate: string | null; links: components["schemas"]["IssueLinkDto"][]; @@ -1567,6 +1572,11 @@ export interface components { /** @description Figma frame (https) for UI changes */ figmaUrl?: string | null; storyPoints?: number | null; + /** + * Format: date + * @description When work should start (roadmap) + */ + startDate?: string | null; /** Format: date */ dueDate?: string | null; /** @@ -1610,6 +1620,11 @@ export interface components { /** @description Figma frame (https) for UI changes */ figmaUrl?: string | null; storyPoints?: number | null; + /** + * Format: date + * @description When work should start (roadmap) + */ + startDate?: string | null; /** Format: date */ dueDate?: string | null; /** diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index dc1c631..88d143d 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', + 'roadmap', 'epics', 'releases', 'dashboard', diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 8d9b3d9..0cad5cd 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -19,6 +19,7 @@ | 🎲 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 | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index 2d9af91..679eaf6 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -84,6 +84,7 @@ const FIELD: Record = { labels: 'las etiquetas', figmaUrl: 'el enlace de Figma', storyPoints: 'los puntos', + startDate: 'la fecha de inicio', dueDate: 'la fecha límite', }; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts index 8f4b73b..8b2d322 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts @@ -55,6 +55,7 @@ export class IssueCreatedEventHandler labels: issue.labels, figmaUrl: issue.figmaUrl, storyPoints: issue.storyPoints, + startDate: issue.startDate ?? null, dueDate: issue.dueDate, createdAt: issue.createdAt, updatedAt: issue.updatedAt, diff --git a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts index 4464f6e..2fc1c0e 100644 --- a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts +++ b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts @@ -35,6 +35,11 @@ export class IssueFactory { workflow.assertActiveArea(props.area); const title = details.title ?? ''; assertThat(title.length > 0, 'Issue title is required'); + if (details.startDate && details.dueDate) + assertThat( + Date.parse(details.startDate) <= Date.parse(details.dueDate), + 'A ticket cannot be due before it starts', + ); const now = new Date().toISOString(); const data: IssueProps = { @@ -54,6 +59,7 @@ export class IssueFactory { labels: details.labels ?? [], figmaUrl: details.figmaUrl ?? null, storyPoints: details.storyPoints ?? null, + startDate: details.startDate ?? null, dueDate: details.dueDate ?? null, links: [], createdAt: now, diff --git a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts index 0b36e84..8be9403 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts @@ -279,6 +279,26 @@ describe('Issue', () => { }); }); + describe('dates', () => { + it('plans a start and a due date, never the wrong way round', () => { + const issue = newIssue(); + issue.updateDetails('u', { + startDate: '2026-11-02', + dueDate: '2026-11-20', + }); + expect([issue.startDate, issue.dueDate]).toEqual([ + '2026-11-02', + '2026-11-20', + ]); + expect(() => + issue.updateDetails('u', { startDate: '2026-12-01' }), + ).toThrow(ValidationError); + issue.updateDetails('u', { dueDate: null }); + issue.updateDetails('u', { startDate: '2026-12-01' }); + expect(replay(issue, Issue).rebuilt.startDate).toBe('2026-12-01'); + }); + }); + describe('backlog order', () => { it('moves between two tickets, to the top, and survives a replay', () => { const issue = newIssue(); diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index f7763ba..2c75855 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -55,6 +55,8 @@ export interface IssueDetails { labels?: string[]; figmaUrl?: string | null; storyPoints?: number | null; + /** When work on it should start (roadmap); `dueDate` is when it should end. */ + startDate?: string | null; dueDate?: string | null; } @@ -77,6 +79,7 @@ export interface IssueProps { labels: string[]; figmaUrl: string | null; storyPoints: number | null; + startDate?: string | null; dueDate: string | null; links: IssueLink[]; createdAt: string; @@ -103,6 +106,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public labels: string[] = []; public figmaUrl: string | null = null; public storyPoints: number | null = null; + public startDate: string | null = null; public dueDate: string | null = null; public links: IssueLink[] = []; public attachments: IssueAttachment[] = []; @@ -137,6 +141,18 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { if (JSON.stringify(value) === JSON.stringify(this[field])) continue; (patch as Record)[field] = value; } + const start = + patch.startDate !== undefined ? patch.startDate : this.startDate; + const due = patch.dueDate !== undefined ? patch.dueDate : this.dueDate; + if ( + (patch.startDate !== undefined || patch.dueDate !== undefined) && + start && + due + ) + assertThat( + Date.parse(start) <= Date.parse(due), + 'A ticket cannot be due before it starts', + ); if (patch.title !== undefined) assertThat(patch.title.length > 0, 'Issue title is required'); if (patch.type !== undefined) workflow.assertActiveType(patch.type); @@ -575,6 +591,12 @@ export function normalizeIssueDetails(details: IssueDetails): IssueDetails { 'storyPoints must be an integer between 0 and 100', ); } + if (details.startDate !== undefined && details.startDate !== null) { + assertThat( + !Number.isNaN(Date.parse(details.startDate)), + 'startDate must be an ISO date', + ); + } if (details.dueDate !== undefined && details.dueDate !== null) { assertThat( !Number.isNaN(Date.parse(details.dueDate)), diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 23e336b..3cef753 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -74,6 +74,7 @@ export class IssueReadModel { labels!: string[]; figmaUrl!: string | null; storyPoints!: number | null; + startDate!: string | null; dueDate!: string | null; links!: IssueLinkReadModel[]; attachments!: AttachmentReadModel[]; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index a4e85f2..28b1bfa 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -223,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { watchers: rest.watchers ?? [], parent: rest.parent?.id ? rest.parent : null, rank: rest.rank ?? implicitRank(rest.number), + startDate: rest.startDate ?? null, sprint: rest.sprint?.id ? rest.sprint : null, checklist: rest.checklist ?? [], }; diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 6cd9e46..8e40c71 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -123,6 +123,14 @@ abstract class IssueFieldsInput { @Max(100) storyPoints?: number | null; + @Field(() => String, { + nullable: true, + description: 'When work should start (roadmap), YYYY-MM-DD', + }) + @IsOptional() + @IsISO8601() + startDate?: string | null; + @Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?: | string | null; diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 56023b3..1e62936 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -171,6 +171,11 @@ export class IssueGql { @Field(() => [String]) labels!: string[]; @Field(() => String, { nullable: true }) figmaUrl!: string | null; @Field(() => Int, { nullable: true }) storyPoints!: number | null; + @Field(() => String, { + nullable: true, + description: 'When work should start (roadmap)', + }) + startDate!: string | null; @Field(() => String, { nullable: true }) dueDate!: string | null; @Field(() => [IssueLinkGql]) links!: IssueLinkGql[]; @Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; 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 c166cb0..f367137 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 @@ -545,6 +545,16 @@ class IssueFieldsDto { @Max(100) storyPoints?: number | null; + @ApiPropertyOptional({ + type: String, + nullable: true, + format: 'date', + description: 'When work should start (roadmap)', + }) + @IsOptional() + @IsISO8601() + startDate?: string | null; + @ApiPropertyOptional({ type: String, nullable: true, format: 'date' }) @IsOptional() @IsISO8601() 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 6f78cf9..670d3d8 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 @@ -181,6 +181,13 @@ export class IssueDto { @ApiProperty({ type: String, nullable: true, description: 'Figma frame' }) figmaUrl!: string | null; @ApiProperty({ type: Number, nullable: true }) storyPoints!: number | null; + @ApiProperty({ + type: String, + nullable: true, + format: 'date', + description: 'When work should start (roadmap)', + }) + startDate!: string | null; @ApiProperty({ type: String, nullable: true, format: 'date' }) dueDate!: string | null; @ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[]; diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 0ed92be..5ef8445 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -8,3 +8,4 @@ export * from './lib/models/hierarchy'; export * from './lib/models/views'; export * from './lib/models/estimation'; export * from './lib/models/swimlanes'; +export * from './lib/models/roadmap'; diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index b4e334f..c1477f2 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -29,6 +29,8 @@ export interface CreateIssueInput { labels?: string[]; figmaUrl?: string | null; storyPoints?: number | null; + startDate?: string | null; + dueDate?: string | null; assigneeId?: string | null; releaseId?: string | null; /** Epic or ticket the new one breaks down. */ diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 61b3d00..f46a51f 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -116,6 +116,9 @@ export interface IssueSummary { rank: string; /** Estimate; null when nobody estimated it. */ storyPoints: number | null; + /** Planned start and end (`YYYY-MM-DD`), for the roadmap. */ + startDate: string | null; + dueDate: string | null; commentCount: number; updatedAt: string; version: number; @@ -143,7 +146,6 @@ export interface Issue extends IssueSummary { description: string | null; reporter: UserRef; figmaUrl: string | null; - dueDate: string | null; links: IssueLink[]; attachments: Attachment[]; /** People following the ticket. */ diff --git a/libs/web/tracker/domain/src/lib/models/roadmap.spec.ts b/libs/web/tracker/domain/src/lib/models/roadmap.spec.ts new file mode 100644 index 0000000..5b90ede --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/roadmap.spec.ts @@ -0,0 +1,33 @@ +import { + roadmapBar, + roadmapMonths, + roadmapOffset, + roadmapRange, +} from './roadmap'; + +const today = new Date('2026-10-08T12:00:00Z'); + +describe('roadmap', () => { + it('spans whole months around today and the dates it shows', () => { + const range = roadmapRange(['2026-11-20', null, '2027-01-15'], today); + expect(new Date(range.from).toISOString().slice(0, 10)).toBe('2026-09-01'); + expect(new Date(range.to).toISOString().slice(0, 10)).toBe('2027-03-01'); + expect(roadmapMonths(range).map((m) => m.day)).toEqual([ + '2026-09-01', + '2026-10-01', + '2026-11-01', + '2026-12-01', + '2027-01-01', + '2027-02-01', + ]); + }); + + it('places bars and clamps what falls outside', () => { + const range = roadmapRange([], today); + const bar = roadmapBar('2026-10-01', '2026-10-31', range); + expect(bar.left).toBeGreaterThan(0); + expect(bar.left + bar.width).toBeLessThan(100); + expect(roadmapOffset('2020-01-01', range)).toBe(0); + expect(roadmapOffset('2030-01-01', range)).toBe(100); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/roadmap.ts b/libs/web/tracker/domain/src/lib/models/roadmap.ts new file mode 100644 index 0000000..0bc03f5 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/roadmap.ts @@ -0,0 +1,72 @@ +const DAY = 86_400_000; + +/** Time span a roadmap shows (UTC midnights). */ +export interface RoadmapRange { + from: number; + to: number; +} + +const at = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`); + +/** + * From two weeks before the earliest date (or today) to a month after the + * latest (or two months from today), whole months, a year at most. + */ +export function roadmapRange( + days: (string | null)[], + today: Date, +): RoadmapRange { + const now = Date.UTC( + today.getUTCFullYear(), + today.getUTCMonth(), + today.getUTCDate(), + ); + const known = days.filter((d): d is string => !!d).map(at); + const start = Math.min(now - 14 * DAY, ...known); + const end = Math.max(now + 60 * DAY, ...known.map((t) => t + 30 * DAY)); + const first = new Date(start); + const from = Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), 1); + const last = new Date(Math.min(end, from + 366 * DAY)); + const to = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() + 1, 1); + return { from, to }; +} + +/** Where a day falls, as a percentage of the range (clamped to 0-100). */ +export function roadmapOffset(day: string, range: RoadmapRange): number { + const share = (at(day) - range.from) / (range.to - range.from); + return Math.round(Math.min(1, Math.max(0, share)) * 10000) / 100; +} + +/** Left and width (%) of a bar from `start` to `end`, both days included. */ +export function roadmapBar( + start: string, + end: string, + range: RoadmapRange, +): { left: number; width: number } { + const left = roadmapOffset(start, range); + const right = roadmapOffset( + new Date(at(end) + DAY).toISOString().slice(0, 10), + range, + ); + return { left, width: Math.max(right - left, 0.5) }; +} + +/** The first day of each month in the range, with its share of the width. */ +export function roadmapMonths( + range: RoadmapRange, +): { day: string; left: number; width: number }[] { + const months: { day: string; left: number; width: number }[] = []; + const cursor = new Date(range.from); + while (cursor.getTime() < range.to) { + const start = cursor.getTime(); + const next = Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1); + const total = range.to - range.from; + months.push({ + day: new Date(start).toISOString().slice(0, 10), + left: ((start - range.from) / total) * 100, + width: ((Math.min(next, range.to) - start) / total) * 100, + }); + cursor.setTime(next); + } + return months; +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 64011b0..d876665 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -81,13 +81,14 @@ fragment IssueSummaryFields on Issue { } rank storyPoints + startDate + dueDate } fragment IssueFields on Issue { ...IssueSummaryFields description figmaUrl - dueDate createdAt allowedTransitions reporter { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 49c8d0a..8de4d1f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -40,6 +40,8 @@ export type CreateIssueInput = { priority?: IssuePriority | null | undefined; projectId: string; releaseId?: string | null | undefined; + /** When work should start (roadmap), YYYY-MM-DD */ + startDate?: string | null | undefined; storyPoints?: number | null | undefined; title: string; /** A ticket type id */ @@ -212,6 +214,8 @@ export type UpdateIssueInput = { issueId: string; labels?: Array | null | undefined; priority?: IssuePriority | null | undefined; + /** When work should start (roadmap), YYYY-MM-DD */ + startDate?: string | null | undefined; storyPoints?: number | null | undefined; title?: string | null | undefined; type?: string | null | undefined; @@ -293,6 +297,8 @@ export type IssueSummaryFieldsFragment = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -302,7 +308,6 @@ export type IssueSummaryFieldsFragment = { export type IssueFieldsFragment = { description: string | null; figmaUrl: string | null; - dueDate: string | null; createdAt: string; allowedTransitions: Array; id: string; @@ -320,6 +325,8 @@ export type IssueFieldsFragment = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -414,6 +421,8 @@ export type BoardQuery = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -431,7 +440,6 @@ export type IssueQuery = { issue: { description: string | null; figmaUrl: string | null; - dueDate: string | null; createdAt: string; allowedTransitions: Array; id: string; @@ -449,6 +457,8 @@ export type IssueQuery = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -496,6 +506,8 @@ export type SearchIssuesQuery = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -649,6 +661,8 @@ export type MyIssuesQuery = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -679,6 +693,8 @@ export type IssueChildrenQuery = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -709,6 +725,8 @@ export type EpicsQuery = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -781,6 +799,8 @@ export type BacklogQuery = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -1410,6 +1430,8 @@ export type IssueChangedSubscription = { version: number; rank: string; storyPoints: number | null; + startDate: string | null; + dueDate: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -1804,6 +1826,8 @@ export const IssueSummaryFieldsFragmentDoc = { }, { 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' } }, ], }, }, @@ -1843,7 +1867,6 @@ export const IssueFieldsFragmentDoc = { }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, - { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', @@ -2018,6 +2041,8 @@ export const IssueFieldsFragmentDoc = { }, { 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' } }, ], }, }, @@ -2680,6 +2705,8 @@ export const BoardDocument = { }, { 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' } }, ], }, }, @@ -2824,6 +2851,8 @@ export const IssueDocument = { }, { 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' } }, ], }, }, @@ -2843,7 +2872,6 @@ export const IssueDocument = { }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, - { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', @@ -3086,6 +3114,8 @@ export const SearchIssuesDocument = { }, { 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' } }, ], }, }, @@ -4314,6 +4344,8 @@ export const MyIssuesDocument = { }, { 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' } }, ], }, }, @@ -4472,6 +4504,8 @@ export const IssueChildrenDocument = { }, { 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' } }, ], }, }, @@ -4648,6 +4682,8 @@ export const EpicsDocument = { }, { 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' } }, ], }, }, @@ -5128,6 +5164,8 @@ export const BacklogDocument = { }, { 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' } }, ], }, }, @@ -8788,6 +8826,8 @@ export const IssueChangedDocument = { }, { 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' } }, ], }, }, 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 b5f4449..b2801d7 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -15,6 +15,7 @@ import { RouterLink } from '@angular/router'; import { PiActionButtonComponent, PiCheckboxComponent, + PiDateFieldComponent, PiFileUploadComponent, PiIconButtonComponent, PiIconComponent, @@ -73,6 +74,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; AttachmentItem, PiActionButtonComponent, PiCheckboxComponent, + PiDateFieldComponent, PiFileUploadComponent, PiIconComponent, PiIconButtonComponent, @@ -638,6 +640,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; > Estimación + + Empieza + + + Fecha límite + Backlog Sprints Épicas + Hoja de ruta Releases Seguimiento Estadísticas diff --git a/libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts b/libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts new file mode 100644 index 0000000..2330072 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts @@ -0,0 +1,373 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + untracked, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui'; +import { + EpicsFacade, + ProjectFacade, + provideEpicsFacade, +} from '@task-stream/web/tracker/application'; +import { + Epic, + ReleaseStatus, + roadmapBar, + roadmapMonths, + roadmapOffset, + roadmapRange, + SprintStatus, +} from '@task-stream/web/tracker/domain'; +import { statusLook } from '../ui/copy'; + +const today = () => new Date().toISOString().slice(0, 10); +const monthName = (day: string) => + new Date(`${day}T00:00:00Z`).toLocaleDateString('es', { + month: 'short', + year: day.endsWith('-01-01') ? '2-digit' : undefined, + timeZone: 'UTC', + }); +const shortDay = (day: string) => + new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + }); + +/** /p/:key/roadmap — epics over time, with sprints and releases as landmarks. */ +@Component({ + selector: 'ts-roadmap-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, PiNoteComponent, PiSkeletonComponent], + providers: [provideEpicsFacade()], + template: ` +
+

+ Las épicas en el tiempo, con los sprints y las releases como + referencias. Las fechas de una épica se ponen en su ficha («Empieza» y + «Fecha límite»). +

+
+ + @if (epics.error(); as error) { + + } @else if (!epics.loaded()) { + + } @else { +
+
+ +
+ @for (month of months(); track month.day) { + {{ monthLabel(month.day) }} + } +
+
+ + @if (sprintBands().length) { +
+ Sprints +
+ @for (band of sprintBands(); track band.id) { + {{ band.name }} + } +
+
+ } + + @if (releaseMarks().length) { +
+ Releases +
+ @for (mark of releaseMarks(); track mark.id) { + {{ mark.name }} + } +
+
+ } + + @for (row of epicRows(); track row.epic.issue.id) { + + } + + +
+ + @if (undated().length) { +

Épicas sin fechas

+
    + @for (epic of undated(); track epic.issue.id) { +
  • + {{ epic.issue.key }} + {{ epic.issue.title }} + {{ epic.progress.percent }}% +
  • + } +
+ } @else if (!epicRows().length) { +

+ Aún no hay épicas: un ticket con subtareas aparece aquí en cuanto + tenga fechas. +

+ } + } + `, + styles: ` + .ts-roadmap { + --label: 14rem; + position: relative; + display: grid; + padding: 0.5rem 0.75rem 0.75rem; + overflow-x: auto; + } + .ts-roadmap__row { + display: grid; + grid-template-columns: var(--label) minmax(36rem, 1fr); + align-items: center; + min-height: 2.25rem; + border-top: 1px solid var(--ts-line); + &:first-child { + border-top: 0; + } + } + .ts-roadmap__label { + overflow: hidden; + padding-right: 0.75rem; + color: inherit; + font-size: 0.875rem; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + } + a.ts-roadmap__label:hover { + color: var(--ts-brand); + } + .ts-roadmap__track { + position: relative; + height: 1.75rem; + } + .ts-roadmap__months .ts-roadmap__track { + height: 1.5rem; + } + .ts-roadmap__month { + position: absolute; + top: 0; + padding-left: 0.375rem; + border-left: 1px solid var(--ts-line); + color: var(--ts-muted); + font-size: 0.75rem; + text-transform: capitalize; + } + .ts-roadmap__sprint, + .ts-roadmap__bar { + position: absolute; + top: 0.3rem; + height: 1.15rem; + border-radius: 0.375rem; + } + .ts-roadmap__sprint { + overflow: hidden; + padding: 0 0.375rem; + background: var(--ts-line); + color: var(--ts-ink-soft); + font-size: 0.6875rem; + line-height: 1.15rem; + white-space: nowrap; + &.is-active { + background: color-mix(in srgb, var(--ts-brand) 22%, transparent); + color: var(--ts-ink); + } + } + .ts-roadmap__release { + position: absolute; + top: 0.25rem; + transform: translateX(-0.375rem); + color: var(--ts-c-violet, var(--ts-brand)); + font-size: 0.75rem; + white-space: nowrap; + &.is-shipped { + color: var(--ts-c-green, var(--ts-muted)); + } + } + .ts-roadmap__bar { + --tone: var(--ts-brand); + background: linear-gradient( + to right, + var(--tone) var(--done), + color-mix(in srgb, var(--tone) 30%, transparent) var(--done) + ); + &:hover, + &:focus-visible { + outline: 2px solid var(--tone); + outline-offset: 1px; + } + } + .ts-roadmap__today { + position: absolute; + top: 0.5rem; + bottom: 0.75rem; + left: calc( + 0.75rem + var(--label) + (100% - 1.5rem - var(--label)) * var(--at) / + 100% + ); + width: 2px; + background: var(--ts-c-red, var(--ts-brand)); + opacity: 0.6; + pointer-events: none; + } + .ts-roadmap__title { + margin: 1.5rem 0 0.5rem; + font-size: 0.875rem; + color: var(--ts-muted); + } + .ts-roadmap__undated { + display: grid; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; + } + @media (max-width: 640px) { + .ts-roadmap { + --label: 8rem; + } + } + `, +}) +export class RoadmapPage { + protected readonly epics = inject(EpicsFacade); + private readonly project = inject(ProjectFacade); + + private readonly dated = computed(() => + this.epics.epics().filter((e) => e.issue.startDate || e.issue.dueDate), + ); + protected readonly undated = computed(() => + this.epics.epics().filter((e) => !e.issue.startDate && !e.issue.dueDate), + ); + private readonly range = computed(() => + roadmapRange( + [ + ...this.dated().flatMap((e) => [e.issue.startDate, e.issue.dueDate]), + ...this.project.sprints().flatMap((s) => [s.startDate, s.endDate]), + ...this.project.releases().map((r) => r.targetDate), + ], + new Date(), + ), + ); + protected readonly months = computed(() => roadmapMonths(this.range())); + protected readonly todayAt = computed(() => + roadmapOffset(today(), this.range()), + ); + protected readonly epicRows = computed(() => + this.dated() + .map((epic) => { + // One date only: a single-day bar on it. + const start = (epic.issue.startDate ?? epic.issue.dueDate) as string; + const end = (epic.issue.dueDate ?? epic.issue.startDate) as string; + return { + epic, + start, + ...roadmapBar(start, end, this.range()), + title: `${epic.issue.key} · ${shortDay(start)} – ${shortDay(end)} · ${epic.progress.percent}% terminado`, + }; + }) + .sort((a, b) => a.start.localeCompare(b.start)), + ); + protected readonly sprintBands = computed(() => + this.project.sprints().map((s) => ({ + id: s.id, + name: s.name, + active: s.status === SprintStatus.ACTIVE, + title: `${s.name} · ${shortDay(s.startDate)} – ${shortDay(s.endDate)}`, + ...roadmapBar(s.startDate, s.endDate, this.range()), + })), + ); + protected readonly releaseMarks = computed(() => + this.project + .releases() + .filter((r) => r.targetDate && r.status !== ReleaseStatus.ARCHIVED) + .map((r) => { + const day = (r.releasedAt ?? r.targetDate) as string; + return { + id: r.id, + name: r.name, + shipped: r.status === ReleaseStatus.RELEASED, + left: roadmapOffset(day, this.range()), + title: `${r.name} · ${shortDay(day)}`, + }; + }), + ); + + constructor() { + effect(() => { + const projectId = this.project.project()?.id; + if (projectId) untracked(() => this.epics.open(projectId)); + }); + } + + protected monthLabel = monthName; + protected statusTone = (epic: Epic) => + statusLook(this.project.workflow(), epic.issue.status).tone; +} diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index 444de7e..d1d18ec 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -709,10 +709,9 @@ function idFrom(name: string): string { } .ts-status-grid { display: grid; - grid-template-columns: 1.25rem minmax( - 10rem, - 1fr - ) 9rem 8rem 5.5rem 5.5rem 6.5rem; + grid-template-columns: + 1.25rem minmax(10rem, 1fr) + 9rem 8rem 5.5rem 5.5rem 6.5rem; align-items: center; gap: 0.5rem; } diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts index 5f5e695..dd28b2f 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: 'roadmap', + title: 'Hoja de ruta · TaskStream', + loadComponent: () => + import('./pages/roadmap.page').then((m) => m.RoadmapPage), + }, { path: 'epics', title: 'Épicas · TaskStream',