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',