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 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ const FIELD: Record<string, string> = {
|
||||
labels: 'las etiquetas',
|
||||
figmaUrl: 'el enlace de Figma',
|
||||
storyPoints: 'los puntos',
|
||||
startDate: 'la fecha de inicio',
|
||||
dueDate: 'la fecha límite',
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>)[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)),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user