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:
2026-10-08 15:13:58 +02:00
parent 09bd8c19a3
commit 45a3dc1e16
28 changed files with 698 additions and 10 deletions

View File

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

View File

@@ -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();

View File

@@ -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)),

View File

@@ -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[];