feat(github-projects): render Roadmap project views as a timeline (#17795)

* Add roadmap timeline view for GitHub Projects

- Renders roadmap-layout project views as a scrollable timeline with
  date/iteration-based placement, zoom levels, and grouped lanes,
  instead of surfacing them as unsupported
- Derives placement fields from view config or row-carried field
  values since GitHub's API never exposes a roadmap's date source
  directly
- Falls back to the existing table list when no field can place items

* Fix roadmap timeline edge cases: reject invalid calendar dates and refre

- parseRoadmapDate previously let Date.UTC silently normalize overflowing
  dates (e.g. 2026-02-30 → Mar 2); now round-trips components to reject them
- ProjectRoadmap's "today" marker was frozen at mount, so panes left open
  across midnight showed the wrong day; now re-derives and re-arms a timer

* fix(github-projects): center roadmaps when dated rows arrive

* fix(github-projects): keep pinned roadmap header opaque

* fix: remove stale pnpm executable lockfile entries

* fix(i18n): retain replaced project labels in runtime catalog

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
NaoyaTatetsu
2026-09-06 00:03:41 -07:00
committed by GitHub
co-authored by Neil
parent 1326d6b40c
commit a567e33bf7
17 changed files with 1720 additions and 38 deletions
@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest'
import {
buildRoadmapTicks,
getRoadmapSpan,
parseRoadmapDate,
resolveRoadmapDateSource,
roadmapOffsetPx,
ROADMAP_DAY_MS,
type RoadmapSpan
} from './project-roadmap-timeline'
import type {
GitHubProjectField,
GitHubProjectFieldValue,
GitHubProjectRow,
GitHubProjectView
} from './project-types'
function dateField(id: string, name: string): GitHubProjectField {
return { kind: 'field', id, name, dataType: 'DATE' }
}
function iterationField(id: string, name: string): GitHubProjectField {
return { kind: 'iteration', id, name, dataType: 'ITERATION', iterations: [] }
}
function view(fields: GitHubProjectField[]): GitHubProjectView {
return {
id: 'PVTV_1',
number: 1,
name: 'Roadmap',
layout: 'ROADMAP_LAYOUT',
filter: '',
fields,
groupByFields: [],
sortByFields: []
}
}
function row(values: GitHubProjectFieldValue[]): GitHubProjectRow {
const fieldValuesByFieldId: Record<string, GitHubProjectFieldValue> = {}
for (const value of values) {
fieldValuesByFieldId[value.fieldId] = value
}
return {
id: 'PVTI_1',
itemType: 'ISSUE',
content: {
number: 1,
title: 'Item',
body: null,
url: 'https://github.com/o/r/issues/1',
state: 'OPEN',
stateReason: null,
isDraft: null,
repository: 'o/r',
assignees: [],
labels: [],
parentIssue: null,
issueType: null
},
fieldValuesByFieldId,
updatedAt: '2026-08-31T00:00:00Z',
position: 0
}
}
const utc = (iso: string): number => Date.parse(`${iso}T00:00:00Z`)
describe('resolveRoadmapDateSource', () => {
it('pairs date fields by name regardless of view order', () => {
const target = dateField('f_end', 'Target date')
const start = dateField('f_start', 'Start date')
expect(resolveRoadmapDateSource(view([target, start]))).toEqual({
kind: 'date-range',
startField: start,
targetField: target
})
})
it('keeps a name-matched field in its role when the other name matches nothing', () => {
const review = dateField('f_review', 'Review date')
const start = dateField('f_start', 'Start date')
expect(resolveRoadmapDateSource(view([review, start]))).toEqual({
kind: 'date-range',
startField: start,
targetField: review
})
})
it('finds placement fields via sortByFields when the visible list hides them', () => {
const start = dateField('f_start', 'Start date')
const target = dateField('f_end', 'Target date')
const hidden = view([{ kind: 'field', id: 'f_t', name: 'Title', dataType: 'TITLE' }])
hidden.sortByFields = [
{ direction: 'ASC', field: start },
{ direction: 'ASC', field: target }
]
expect(resolveRoadmapDateSource(hidden)).toEqual({
kind: 'date-range',
startField: start,
targetField: target
})
})
it('derives placement fields from row values when no configured field has them', () => {
const bare = view([{ kind: 'field', id: 'f_t', name: 'Title', dataType: 'TITLE' }])
const rows = [
row([
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02', fieldName: 'Start date' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-04', fieldName: 'Target date' }
])
]
expect(resolveRoadmapDateSource(bare, rows)).toEqual({
kind: 'date-range',
startField: { kind: 'field', id: 'f_start', name: 'Start date', dataType: 'DATE' },
targetField: { kind: 'field', id: 'f_end', name: 'Target date', dataType: 'DATE' }
})
})
it('falls back to view order when names do not match the patterns', () => {
const first = dateField('f_1', '開始')
const second = dateField('f_2', '完了')
expect(resolveRoadmapDateSource(view([first, second]))).toEqual({
kind: 'date-range',
startField: first,
targetField: second
})
})
it('prefers an iteration field over a lone date field', () => {
const iteration = iterationField('f_it', 'Sprint')
expect(resolveRoadmapDateSource(view([dateField('f_1', 'Start date'), iteration]))).toEqual({
kind: 'iteration',
field: iteration
})
})
it('uses a lone date field as a point source', () => {
const only = dateField('f_1', 'Ship date')
expect(resolveRoadmapDateSource(view([only]))).toEqual({ kind: 'date-point', field: only })
})
it('returns null when the view has nothing to place items on', () => {
expect(
resolveRoadmapDateSource(
view([{ kind: 'field', id: 'f_t', name: 'Title', dataType: 'TITLE' }])
)
).toBeNull()
})
})
describe('getRoadmapSpan', () => {
const start = dateField('f_start', 'Start date')
const target = dateField('f_end', 'Target date')
const source = { kind: 'date-range', startField: start, targetField: target } as const
it('spans an inclusive end date', () => {
const span = getRoadmapSpan(
row([
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-04' }
]),
source
)
expect(span).toEqual({ startMs: utc('2026-03-02'), endMs: utc('2026-03-05'), point: false })
})
it('renders a single known date as a point', () => {
const span = getRoadmapSpan(
row([{ kind: 'date', fieldId: 'f_end', date: '2026-03-04' }]),
source
)
expect(span).toEqual({ startMs: utc('2026-03-04'), endMs: utc('2026-03-05'), point: true })
})
it('orders an inverted pair instead of dropping the row', () => {
const span = getRoadmapSpan(
row([
{ kind: 'date', fieldId: 'f_start', date: '2026-03-10' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-01' }
]),
source
)
expect(span).toEqual({ startMs: utc('2026-03-01'), endMs: utc('2026-03-11'), point: false })
})
it('returns null when neither date is set', () => {
expect(getRoadmapSpan(row([]), source)).toBeNull()
})
it('derives the span from an iteration value', () => {
const span = getRoadmapSpan(
row([
{
kind: 'iteration',
fieldId: 'f_it',
iterationId: 'it_1',
title: 'Sprint 1',
startDate: '2026-03-02',
duration: 14
}
]),
{ kind: 'iteration', field: iterationField('f_it', 'Sprint') }
)
expect(span).toEqual({
startMs: utc('2026-03-02'),
endMs: utc('2026-03-02') + 14 * ROADMAP_DAY_MS,
point: false
})
})
it('ignores a value whose kind does not match the source', () => {
expect(
getRoadmapSpan(row([{ kind: 'text', fieldId: 'f_start', text: '2026-03-02' }]), source)
).toBeNull()
})
})
describe('parseRoadmapDate', () => {
it('reads the date part of an ISO timestamp', () => {
expect(parseRoadmapDate('2026-03-02T11:22:33Z')).toBe(utc('2026-03-02'))
})
it('rejects malformed input', () => {
expect(parseRoadmapDate('March 2')).toBeNull()
})
it('rejects invalid calendar dates instead of letting Date.UTC normalize them', () => {
expect(parseRoadmapDate('2026-02-30')).toBeNull()
expect(parseRoadmapDate('2026-13-01')).toBeNull()
expect(parseRoadmapDate('2026-04-31')).toBeNull()
expect(parseRoadmapDate('2024-02-29')).toBe(Date.parse('2024-02-29T00:00:00Z'))
})
})
describe('buildRoadmapTicks', () => {
const span = (from: string, to: string): RoadmapSpan => ({
startMs: utc(from),
endMs: utc(to),
point: false
})
it('pads one month on each side of the covered range', () => {
const ticks = buildRoadmapTicks([span('2026-03-02', '2026-10-10')], 'month', utc('2026-03-15'))
expect(ticks[0]?.startMs).toBe(utc('2026-02-01'))
expect(ticks.at(-1)?.endMs).toBe(utc('2026-12-01'))
})
it('always covers today even when every item sits elsewhere', () => {
const ticks = buildRoadmapTicks([span('2026-03-02', '2026-03-10')], 'month', utc('2026-09-15'))
expect(ticks[0]?.startMs).toBe(utc('2026-02-01'))
expect(ticks.at(-1)?.endMs).toBe(utc('2026-11-01'))
})
it('widens an empty roadmap to the minimum readable width', () => {
expect(buildRoadmapTicks([], 'month', utc('2026-03-15'))).toHaveLength(6)
expect(buildRoadmapTicks([], 'quarter', utc('2026-03-15'))).toHaveLength(4)
expect(buildRoadmapTicks([], 'year', utc('2026-03-15'))).toHaveLength(3)
})
it('snaps quarter and year grids to their unit boundaries', () => {
const quarters = buildRoadmapTicks(
[span('2026-05-02', '2026-05-10')],
'quarter',
utc('2026-05-15')
)
expect(quarters[0]?.startMs).toBe(utc('2026-01-01'))
const years = buildRoadmapTicks([span('2026-05-02', '2026-05-10')], 'year', utc('2026-05-15'))
expect(years[0]?.startMs).toBe(utc('2025-01-01'))
})
it('caps the grid instead of expanding for a far-future date, keeping today visible', () => {
const today = utc('2026-03-15')
const ticks = buildRoadmapTicks([span('2026-03-02', '9999-01-01')], 'month', today)
expect(ticks).toHaveLength(480)
expect(ticks[0]!.startMs).toBeLessThanOrEqual(today)
expect(ticks.at(-1)!.endMs).toBeGreaterThan(today)
})
it('caps the grid for a far-past date without pushing today off the edge', () => {
const today = utc('2026-03-15')
const ticks = buildRoadmapTicks([span('0206-03-02', '0206-03-10')], 'month', today)
expect(ticks).toHaveLength(480)
expect(ticks[0]!.startMs).toBeLessThanOrEqual(today)
expect(ticks.at(-1)!.endMs).toBeGreaterThan(today)
// One month of trailing padding after today survives the trim.
expect(ticks.at(-1)!.endMs).toBe(utc('2026-05-01'))
})
})
describe('roadmapOffsetPx', () => {
const ticks = buildRoadmapTicks([], 'month', utc('2026-03-15'))
it('interpolates inside the containing tick so column rules stay aligned', () => {
const second = ticks[1]
expect(second).toBeDefined()
expect(roadmapOffsetPx(second!.startMs, ticks, 100)).toBe(100)
const midpoint = second!.startMs + (second!.endMs - second!.startMs) / 2
expect(roadmapOffsetPx(midpoint, ticks, 100)).toBeCloseTo(150, 5)
})
it('clamps out-of-range timestamps to the grid edges', () => {
expect(roadmapOffsetPx(utc('1990-01-01'), ticks, 100)).toBe(0)
expect(roadmapOffsetPx(utc('2090-01-01'), ticks, 100)).toBe(ticks.length * 100)
})
it('returns zero for an empty grid', () => {
expect(roadmapOffsetPx(utc('2026-03-15'), [], 100)).toBe(0)
})
})
@@ -0,0 +1,301 @@
// Why: GitHub's GraphQL API never exposes which fields a Roadmap view places
// its items on — `ProjectV2View` carries the layout and the field set, not the
// roadmap's date configuration. The placement is therefore derived from the
// view's own fields here, as pure logic, so desktop and mobile can draw the
// same bars and the geometry stays testable without a renderer.
import type { GitHubProjectField, GitHubProjectRow, GitHubProjectView } from './project-types'
export const ROADMAP_DAY_MS = 86_400_000
export type RoadmapZoom = 'month' | 'quarter' | 'year'
export type RoadmapDateSource =
| { kind: 'date-range'; startField: GitHubProjectField; targetField: GitHubProjectField }
| { kind: 'date-point'; field: GitHubProjectField }
| { kind: 'iteration'; field: GitHubProjectField }
export type RoadmapSpan = {
startMs: number
/** Exclusive — a single-day item ends one day after it starts. */
endMs: number
/** True when only one date was known, so the bar renders as a marker. */
point: boolean
}
export type RoadmapTick = {
key: string
startMs: number
/** Exclusive. */
endMs: number
}
const START_FIELD_PATTERN = /start|kick.?off|begin/i
const TARGET_FIELD_PATTERN = /target|end|due|finish|complet|deadline|ship/i
const MONTHS_PER_UNIT: Record<RoadmapZoom, number> = { month: 1, quarter: 3, year: 12 }
const MIN_UNITS: Record<RoadmapZoom, number> = { month: 6, quarter: 4, year: 3 }
// Why: one bogus far-future date must not expand the grid to tens of thousands
// of columns. Beyond this the range stops growing and out-of-range bars clamp
// to the edge, which is visibly wrong in the right way rather than a hang.
const MAX_TICKS = 480
function isDateField(field: GitHubProjectField): boolean {
return field.kind === 'field' && field.dataType === 'DATE'
}
function pickDateSource(
dateFields: GitHubProjectField[],
iterationField: GitHubProjectField | null
): RoadmapDateSource | null {
const startField = dateFields.find((field) => START_FIELD_PATTERN.test(field.name))
const targetField = dateFields.find(
(field) => field.id !== startField?.id && TARGET_FIELD_PATTERN.test(field.name)
)
if (startField && targetField) {
return { kind: 'date-range', startField, targetField }
}
// Why: when only one name matched, keep it in its matched role and pair it
// with the remaining date field — a plain order fallback inverts the pair.
if (startField) {
const other = dateFields.find((field) => field.id !== startField.id)
if (other) {
return { kind: 'date-range', startField, targetField: other }
}
}
if (targetField) {
const other = dateFields.find((field) => field.id !== targetField.id)
if (other) {
return { kind: 'date-range', startField: other, targetField }
}
}
const [first, second] = dateFields
// Why: localized or oddly named date fields still describe a range — fall
// back to the view's own field order rather than degrading to a marker.
if (first && second) {
return { kind: 'date-range', startField: first, targetField: second }
}
if (iterationField) {
return { kind: 'iteration', field: iterationField }
}
if (first) {
return { kind: 'date-point', field: first }
}
return null
}
export function resolveRoadmapDateSource(
view: GitHubProjectView,
rows: readonly GitHubProjectRow[] = []
): RoadmapDateSource | null {
// Why: roadmaps are commonly placed by fields hidden from the view's
// visible-field list — sort/group fields are the next best config signal.
const seen = new Set<string>()
const candidates: GitHubProjectField[] = []
for (const field of [
...view.fields,
...view.sortByFields.map((sort) => sort.field),
...view.groupByFields
]) {
if (!seen.has(field.id)) {
seen.add(field.id)
candidates.push(field)
}
}
const fromConfig = pickDateSource(
candidates.filter(isDateField),
candidates.find((field) => field.kind === 'iteration') ?? null
)
// Why: item field values are fetched independently of the view config, so
// rows can carry usable dates even when no configured field exposes them.
return fromConfig ?? pickDateSource(...collectRowPlacementFields(rows))
}
function collectRowPlacementFields(
rows: readonly GitHubProjectRow[]
): [GitHubProjectField[], GitHubProjectField | null] {
const dateFieldsById = new Map<string, GitHubProjectField>()
let iterationField: GitHubProjectField | null = null
for (const row of rows) {
for (const value of Object.values(row.fieldValuesByFieldId)) {
if (value.kind === 'date' && !dateFieldsById.has(value.fieldId)) {
dateFieldsById.set(value.fieldId, {
kind: 'field',
id: value.fieldId,
name: value.fieldName ?? 'Date',
dataType: 'DATE'
})
} else if (value.kind === 'iteration' && !iterationField) {
iterationField = {
kind: 'iteration',
id: value.fieldId,
name: value.fieldName ?? 'Iteration',
dataType: 'ITERATION',
iterations: []
}
}
}
}
return [Array.from(dateFieldsById.values()), iterationField]
}
/** Accepts the `YYYY-MM-DD` calendar dates GitHub returns, and tolerates a
* full ISO timestamp by reading its date part. */
export function parseRoadmapDate(value: string): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
if (!match) {
return null
}
const [, year, month, day] = match
const ms = Date.UTC(Number(year), Number(month) - 1, Number(day))
if (Number.isNaN(ms)) {
return null
}
// Why: Date.UTC normalizes overflow (2026-02-30 → Mar 2) instead of failing;
// round-trip the components so an invalid calendar date is rejected, not
// silently moved to a different day.
const roundTrip = new Date(ms)
if (
roundTrip.getUTCFullYear() !== Number(year) ||
roundTrip.getUTCMonth() !== Number(month) - 1 ||
roundTrip.getUTCDate() !== Number(day)
) {
return null
}
return ms
}
function readDateValue(row: GitHubProjectRow, field: GitHubProjectField): number | null {
const value = row.fieldValuesByFieldId[field.id]
return value?.kind === 'date' ? parseRoadmapDate(value.date) : null
}
export function getRoadmapSpan(
row: GitHubProjectRow,
source: RoadmapDateSource
): RoadmapSpan | null {
if (source.kind === 'iteration') {
const value = row.fieldValuesByFieldId[source.field.id]
if (value?.kind !== 'iteration') {
return null
}
const startMs = parseRoadmapDate(value.startDate)
if (startMs === null) {
return null
}
const days = value.duration > 0 ? value.duration : 1
return { startMs, endMs: startMs + days * ROADMAP_DAY_MS, point: false }
}
if (source.kind === 'date-point') {
const ms = readDateValue(row, source.field)
return ms === null ? null : { startMs: ms, endMs: ms + ROADMAP_DAY_MS, point: true }
}
const startValue = readDateValue(row, source.startField)
const targetValue = readDateValue(row, source.targetField)
if (startValue === null || targetValue === null) {
const known = startValue ?? targetValue
return known === null ? null : { startMs: known, endMs: known + ROADMAP_DAY_MS, point: true }
}
// Why: a target before the start is user data, not corruption — order the
// pair so the item still gets a visible bar.
return {
startMs: Math.min(startValue, targetValue),
endMs: Math.max(startValue, targetValue) + ROADMAP_DAY_MS,
point: false
}
}
function unitIndexOf(ms: number, zoom: RoadmapZoom): number {
const date = new Date(ms)
return Math.floor((date.getUTCFullYear() * 12 + date.getUTCMonth()) / MONTHS_PER_UNIT[zoom])
}
function unitStart(index: number, zoom: RoadmapZoom): number {
const months = index * MONTHS_PER_UNIT[zoom]
return Date.UTC(Math.floor(months / 12), months % 12, 1)
}
/** Builds the column grid covering every span plus today, padded by one unit
* on each side and widened to a readable minimum. */
export function buildRoadmapTicks(
spans: readonly RoadmapSpan[],
zoom: RoadmapZoom,
todayMs: number
): RoadmapTick[] {
let earliest = todayMs
let latest = todayMs
for (const span of spans) {
earliest = Math.min(earliest, span.startMs)
latest = Math.max(latest, span.endMs)
}
let firstIdx = unitIndexOf(earliest, zoom) - 1
// Why: span ends are exclusive, so step back a tick before resolving the
// containing unit — otherwise a span landing exactly on a boundary claims
// the next unit and the trailing padding drifts by one column.
let lastIdxExclusive = unitIndexOf(latest - 1, zoom) + 2
if (lastIdxExclusive - firstIdx < MIN_UNITS[zoom]) {
lastIdxExclusive = firstIdx + MIN_UNITS[zoom]
}
// Why: the cap must keep today inside the grid — a single typo'd date in
// either direction otherwise consumes every column and the whole roadmap
// clamps to one edge. Trim the side farther from today first.
if (lastIdxExclusive - firstIdx > MAX_TICKS) {
const todayIdx = unitIndexOf(todayMs, zoom)
firstIdx = Math.max(firstIdx, todayIdx + 2 - MAX_TICKS)
lastIdxExclusive = Math.min(lastIdxExclusive, firstIdx + MAX_TICKS)
}
const ticks: RoadmapTick[] = []
for (let index = firstIdx; index < lastIdxExclusive; index++) {
const startMs = unitStart(index, zoom)
ticks.push({
key: new Date(startMs).toISOString().slice(0, 10),
startMs,
endMs: unitStart(index + 1, zoom)
})
}
return ticks
}
/** Maps a timestamp to a pixel offset inside the grid. Interpolating within
* the containing tick (rather than across the whole range) is what keeps bar
* edges aligned to the column rules, since months differ in length. */
export function roadmapOffsetPx(
ms: number,
ticks: readonly RoadmapTick[],
tickWidthPx: number
): number {
const first = ticks[0]
const last = ticks.at(-1)
if (!first || !last) {
return 0
}
if (ms <= first.startMs) {
return 0
}
if (ms >= last.endMs) {
return ticks.length * tickWidthPx
}
let low = 0
let high = ticks.length - 1
while (low < high) {
const mid = (low + high) >> 1
const tick = ticks[mid]
if (tick && ms >= tick.endMs) {
low = mid + 1
} else {
high = mid
}
}
const tick = ticks[low]
if (!tick) {
return 0
}
const ratio = (ms - tick.startMs) / (tick.endMs - tick.startMs)
return (low + ratio) * tickWidthPx
}
export function roadmapSourceFieldNames(source: RoadmapDateSource): string[] {
if (source.kind === 'date-range') {
return [source.startField.name, source.targetField.name]
}
return [source.field.name]
}
+4 -1
View File
@@ -133,10 +133,13 @@ export type GitHubProjectFieldValue =
title: string
startDate: string
duration: number
/** Owning field's display name. Optional for wire compat — older hosts
* don't send it; used when the field is hidden from the view config. */
fieldName?: string
}
| { kind: 'text'; fieldId: string; text: string }
| { kind: 'number'; fieldId: string; number: number }
| { kind: 'date'; fieldId: string; date: string }
| { kind: 'date'; fieldId: string; date: string; fieldName?: string }
| { kind: 'labels'; fieldId: string; labels: GitHubProjectLabel[] }
| { kind: 'users'; fieldId: string; users: GitHubProjectUser[] }