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:
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
33
libs/web/tracker/domain/src/lib/models/roadmap.spec.ts
Normal file
33
libs/web/tracker/domain/src/lib/models/roadmap.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
72
libs/web/tracker/domain/src/lib/models/roadmap.ts
Normal file
72
libs/web/tracker/domain/src/lib/models/roadmap.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user