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
@@ -145,7 +145,8 @@ export function normalizeFieldValue(
iterationId: raw.iterationId,
title: raw.title ?? '',
startDate: raw.startDate ?? '',
duration: typeof raw.duration === 'number' ? raw.duration : 0
duration: typeof raw.duration === 'number' ? raw.duration : 0,
...(typeof raw.field.name === 'string' ? { fieldName: raw.field.name } : {})
}
case 'ProjectV2ItemFieldTextValue':
return { kind: 'text', fieldId, text: raw.text ?? '' }
@@ -155,7 +156,12 @@ export function normalizeFieldValue(
}
return { kind: 'number', fieldId, number: raw.number }
case 'ProjectV2ItemFieldDateValue':
return { kind: 'date', fieldId, date: raw.date ?? '' }
return {
kind: 'date',
fieldId,
date: raw.date ?? '',
...(typeof raw.field.name === 'string' ? { fieldName: raw.field.name } : {})
}
case 'ProjectV2ItemFieldLabelValue': {
const labels = (raw.labels?.nodes ?? [])
.map(normalizeLabel)
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchProjectViewsPage, type RawProjectView } from './project-view-config'
import type * as ProjectViewConfig from './project-view-config'
import { fetchAllItems, fetchItemsCountOnly } from './project-view-items'
import { getProjectViewTable } from './project-view-table'
vi.mock('./project-view-config', async (importOriginal) => ({
...(await importOriginal<typeof ProjectViewConfig>()),
fetchProjectViewsPage: vi.fn()
}))
vi.mock('./project-view-items', () => ({
fetchAllItems: vi.fn(),
fetchItemsCountOnly: vi.fn()
}))
const args = {
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
host: 'github.acme.test'
} as const
const view = (id: string, layout: string): RawProjectView => ({
id,
number: 1,
name: id,
layout,
filter: 'status:open',
fields: { nodes: [] },
groupByFields: { nodes: [] },
sortByFields: { nodes: [] }
})
function page(views: RawProjectView[], hasNextPage = false) {
return {
ok: true as const,
project: { id: 'project', title: 'Plan', url: 'https://github.acme.test/orgs/acme/projects/1' },
views,
hasNextPage,
endCursor: hasNextPage ? 'next' : null
}
}
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(fetchAllItems).mockResolvedValue({
ok: true,
rows: [],
totalCount: 0,
parentFieldDropped: false
})
vi.mocked(fetchItemsCountOnly).mockResolvedValue(12)
})
describe('project view layout selection', () => {
it('fetches roadmap items with the selected host and filter', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('roadmap', 'ROADMAP_LAYOUT')]))
const result = await getProjectViewTable({ ...args, viewId: 'roadmap' })
expect(result).toMatchObject({ ok: true, data: { selectedView: { layout: 'ROADMAP_LAYOUT' } } })
expect(fetchAllItems).toHaveBeenCalledWith({ ...args, query: 'status:open' })
expect(fetchItemsCountOnly).not.toHaveBeenCalled()
})
it('defaults to a roadmap when no table exists across all view pages', async () => {
vi.mocked(fetchProjectViewsPage)
.mockResolvedValueOnce(page([view('roadmap', 'ROADMAP_LAYOUT')], true))
.mockResolvedValueOnce(page([view('board', 'BOARD_LAYOUT')]))
expect(await getProjectViewTable(args)).toMatchObject({
ok: true,
data: { selectedView: { id: 'roadmap' } }
})
expect(fetchProjectViewsPage).toHaveBeenLastCalledWith({ ...args, after: 'next' })
})
it('prefers a table on a later page over an earlier roadmap', async () => {
vi.mocked(fetchProjectViewsPage)
.mockResolvedValueOnce(page([view('roadmap', 'ROADMAP_LAYOUT')], true))
.mockResolvedValueOnce(page([view('table', 'TABLE_LAYOUT')]))
expect(await getProjectViewTable(args)).toMatchObject({
ok: true,
data: { selectedView: { id: 'table' } }
})
})
it('does not substitute a roadmap for a missing explicit selection', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('roadmap', 'ROADMAP_LAYOUT')]))
expect(await getProjectViewTable({ ...args, viewId: 'missing' })).toMatchObject({
ok: false,
error: { type: 'not_found' }
})
expect(fetchAllItems).not.toHaveBeenCalled()
})
it.each(['BOARD_LAYOUT', 'FUTURE_LAYOUT'])(
'rejects %s without fetching items',
async (layout) => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('unsupported', layout)]))
expect(
await getProjectViewTable({ ...args, viewId: 'unsupported', queryOverride: '' })
).toMatchObject({
ok: false,
error: { type: 'unsupported_layout' },
totalCount: 12
})
expect(fetchAllItems).not.toHaveBeenCalled()
expect(fetchItemsCountOnly).toHaveBeenCalledWith({ ...args, query: '' })
}
)
})
@@ -84,6 +84,14 @@ export async function getProjectViewTable(
if (!project) {
return { ok: false, error: { type: 'not_found', message: 'Project not found.' } }
}
const noSelector =
args.viewId === undefined && args.viewNumber === undefined && args.viewName === undefined
if (!selectedRaw && noSelector) {
// Why: `matchesSelector` only defaults to a table view, so a project whose
// views are all roadmaps resolved to nothing even though we can now render
// one. Table stays the preferred default; this is the empty-handed case.
selectedRaw = viewsSeen.find((v) => v.layout === 'ROADMAP_LAYOUT') ?? null
}
if (!selectedRaw) {
return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } }
}
@@ -109,8 +117,10 @@ export async function getProjectViewTable(
const effectiveQuery =
typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter
// Unsupported layout: skip item pagination; best-effort count-only query.
if (selectedView.layout !== 'TABLE_LAYOUT') {
// Why: roadmaps read the same item stream as a table — only the renderer
// differs. Allowlist, not `=== 'BOARD_LAYOUT'`: raw.layout is cast unchecked,
// so a future GitHub layout must reject cleanly, not render as a table.
if (selectedView.layout !== 'TABLE_LAYOUT' && selectedView.layout !== 'ROADMAP_LAYOUT') {
const count = await fetchItemsCountOnly({
owner: args.owner,
ownerType: args.ownerType,
@@ -122,7 +132,7 @@ export async function getProjectViewTable(
ok: false,
error: {
type: 'unsupported_layout',
message: `Orca only renders table views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.`
message: `Orca renders table and roadmap views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.`
},
...(typeof count === 'number' ? { totalCount: count } : {})
}