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

@@ -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. */

View File

@@ -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. */

View 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);
});
});

View 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;
}