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 } : {})
}
@@ -8,12 +8,16 @@ type Props = {
group: ProjectGroup
expanded: boolean
onToggle: () => void
/** Total band width for horizontally scrolling surfaces (the roadmap). The
* label pins to the viewport so it stays readable when scrolled off. */
bandWidth?: number
}
export default function ProjectGroupHeader({
group,
expanded,
onToggle
onToggle,
bandWidth
}: Props): React.JSX.Element {
const isCurrent = group.iteration ? isIterationCurrent(group.iteration) : false
const dateRange = group.iteration
@@ -24,24 +28,30 @@ export default function ProjectGroupHeader({
type="button"
onClick={onToggle}
className={cn(
'flex w-full items-center gap-2 border-b border-border/50 bg-muted/40 px-3 py-1.5 text-left text-xs',
'hover:bg-muted/60'
'flex items-center border-b border-border/50 bg-muted/40 px-3 py-1.5 text-left text-xs',
'hover:bg-muted/60',
// Why: min-w-full lets the band keep painting to the pane's right
// edge when the pane is wider than the timeline grid.
bandWidth == null ? 'w-full' : 'min-w-full'
)}
style={bandWidth == null ? undefined : { width: bandWidth }}
>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
<span className="font-medium">
{group.label ||
translate('auto.components.github.project.ProjectGroupHeader.244c9e7d06', 'All')}
</span>
<span className="rounded-full border border-border/50 bg-background px-1.5 text-[10px] text-muted-foreground">
{group.rows.length}
</span>
{dateRange ? <span className="text-[10px] text-muted-foreground">{dateRange}</span> : null}
{isCurrent ? (
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 text-[10px] text-emerald-700 dark:text-emerald-300">
{translate('auto.components.github.project.ProjectGroupHeader.82a22d2079', 'Current')}
<span className={cn('flex items-center gap-2', bandWidth != null && 'sticky left-3')}>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
<span className="font-medium">
{group.label ||
translate('auto.components.github.project.ProjectGroupHeader.244c9e7d06', 'All')}
</span>
) : null}
<span className="rounded-full border border-border/50 bg-background px-1.5 text-[10px] text-muted-foreground">
{group.rows.length}
</span>
{dateRange ? <span className="text-[10px] text-muted-foreground">{dateRange}</span> : null}
{isCurrent ? (
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 text-[10px] text-emerald-700 dark:text-emerald-300">
{translate('auto.components.github.project.ProjectGroupHeader.82a22d2079', 'Current')}
</span>
) : null}
</span>
</button>
)
}
@@ -126,19 +126,23 @@ function ProjectViewPickerRow({
view: GitHubProjectViewSummary
onPick: (view: GitHubProjectViewSummary) => void | Promise<void>
}): React.JSX.Element {
const supported = view.layout === 'TABLE_LAYOUT'
const supported = view.layout === 'TABLE_LAYOUT' || view.layout === 'ROADMAP_LAYOUT'
const layoutLabel =
view.layout === 'TABLE_LAYOUT'
? translate('auto.components.github.project.ProjectPicker.1a2b8e512e', 'Table')
: view.layout === 'BOARD_LAYOUT'
? translate(
'auto.components.github.project.ProjectPicker.d34ef9b554',
'Board (unsupported)'
)
: translate(
'auto.components.github.project.ProjectPicker.ab1a2c357d',
'Roadmap (unsupported)'
)
: view.layout === 'ROADMAP_LAYOUT'
? translate('auto.components.github.project.ProjectPickerPanels.04ec212ccb', 'Roadmap')
: view.layout === 'BOARD_LAYOUT'
? translate(
'auto.components.github.project.ProjectPicker.d34ef9b554',
'Board (unsupported)'
)
: // Why: raw.layout is cast unchecked, so a future GitHub layout value
// lands here — keep it disabled instead of mislabeling it.
translate(
'auto.components.github.project.ProjectPickerPanels.9fe1ac868c',
'Unsupported'
)
return (
<button
type="button"
@@ -0,0 +1,273 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ProjectRoadmap from './ProjectRoadmap'
import type {
GitHubProjectField,
GitHubProjectFieldValue,
GitHubProjectRow,
GitHubProjectTable
} from '../../../../shared/github/project-types'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div role="tooltip">{children}</div>
}))
const START_FIELD: GitHubProjectField = {
kind: 'field',
id: 'f_start',
name: 'Start date',
dataType: 'DATE'
}
const TARGET_FIELD: GitHubProjectField = {
kind: 'field',
id: 'f_end',
name: 'Target date',
dataType: 'DATE'
}
const TITLE_FIELD: GitHubProjectField = {
kind: 'field',
id: 'f_title',
name: 'Title',
dataType: 'TITLE'
}
function row(id: string, title: string, values: GitHubProjectFieldValue[]): GitHubProjectRow {
const fieldValuesByFieldId: Record<string, GitHubProjectFieldValue> = {}
for (const value of values) {
fieldValuesByFieldId[value.fieldId] = value
}
return {
id,
itemType: 'ISSUE',
content: {
number: 7,
title,
body: null,
url: 'https://github.com/o/r/issues/7',
state: 'OPEN',
stateReason: null,
isDraft: null,
repository: 'o/r',
assignees: [],
labels: [],
parentIssue: null,
issueType: null
},
fieldValuesByFieldId,
updatedAt: '2026-08-31T00:00:00Z',
position: 0
}
}
function table(fields: GitHubProjectField[], rows: GitHubProjectRow[]): GitHubProjectTable {
return {
project: {
id: 'PVT_1',
owner: 'stablyai',
ownerType: 'organization',
number: 3,
title: 'Orca',
url: 'https://github.com/orgs/stablyai/projects/3'
},
selectedView: {
id: 'PVTV_1',
number: 2,
name: 'Roadmap',
layout: 'ROADMAP_LAYOUT',
filter: '',
fields,
groupByFields: [],
sortByFields: []
},
rows,
totalCount: rows.length,
parentFieldDropped: false
}
}
afterEach(() => {
cleanup()
vi.useRealTimers()
window.localStorage.clear()
})
describe('ProjectRoadmap', () => {
it('moves the today marker across local midnight without resetting scroll and cleans up its timer', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 5, 23, 59, 59))
const { unmount } = render(
<ProjectRoadmap
table={table(
[START_FIELD, TARGET_FIELD],
[row('one', 'Scheduled', [{ kind: 'date', fieldId: 'f_start', date: '2026-09-01' }])]
)}
fallback={<div>list</div>}
/>
)
const scroller = screen.getByTestId('project-roadmap-scroller')
const marker = scroller.querySelector<HTMLElement>('.sticky.top-0 .absolute')!
const before = Number.parseFloat(marker.style.left)
scroller.scrollLeft = 123
act(() => vi.advanceTimersByTime(2100))
expect(Number.parseFloat(marker.style.left) - before).toBeCloseTo(148 / 30)
expect(scroller.scrollLeft).toBe(123)
expect(vi.getTimerCount()).toBe(1)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
it.each([false, true])(
'centers when an initially empty view gains dated rows (fields hidden: %s)',
(hidden) => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 5, 12))
const fields = hidden ? [TITLE_FIELD] : [TITLE_FIELD, START_FIELD, TARGET_FIELD]
const { rerender } = render(
<ProjectRoadmap table={table(fields, [])} fallback={<div>list</div>} />
)
const populated = table(fields, [
row('one', 'Arrived', [
{ kind: 'date', fieldId: 'f_start', date: '2026-01-01' },
{ kind: 'date', fieldId: 'f_end', date: '2026-09-10' }
])
])
rerender(<ProjectRoadmap table={populated} fallback={<div>list</div>} />)
const scroller = screen.getByTestId('project-roadmap-scroller')
expect(scroller.scrollLeft).toBeGreaterThan(1000)
scroller.scrollLeft = 123
rerender(
<ProjectRoadmap
table={{ ...populated, rows: [...populated.rows] }}
fallback={<div>list</div>}
/>
)
expect(scroller.scrollLeft).toBe(123)
fireEvent.click(screen.getByRole('button', { name: 'Year' }))
expect(scroller.scrollLeft).not.toBe(123)
expect(window.localStorage.getItem('orca.githubProject.roadmapZoom')).toBe('year')
}
)
it('places a dated row on the timeline and names the fields driving it', () => {
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD, START_FIELD, TARGET_FIELD],
[
row('PVTI_1', 'Ship the thing', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-20' }
])
]
)}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('Placed by Start date → Target date')).toBeTruthy()
expect(screen.getByLabelText(/^Ship the thing — /)).toBeTruthy()
expect(screen.queryByText('list')).toBeNull()
})
it('keeps an undated row in place and flags it rather than hiding it', () => {
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD, START_FIELD, TARGET_FIELD],
[
row('PVTI_1', 'Dated', [{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' }]),
row('PVTI_2', 'Undated', [])
]
)}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('No dates')).toBeTruthy()
expect(screen.getByText('1 without dates')).toBeTruthy()
expect(screen.queryByLabelText(/^Undated — /)).toBeNull()
})
it('opens the row dialog when a bar is clicked', () => {
const onOpenDialog = vi.fn()
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD, START_FIELD, TARGET_FIELD],
[
row('PVTI_1', 'Ship the thing', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-20' }
])
]
)}
onOpenDialog={onOpenDialog}
fallback={<div>list</div>}
/>
)
fireEvent.click(screen.getByLabelText(/^Ship the thing — /))
expect(onOpenDialog).toHaveBeenCalledTimes(1)
expect(onOpenDialog.mock.calls[0]?.[0]).toMatchObject({ id: 'PVTI_1' })
})
it('places items from row-carried dates when the view hides its date fields', () => {
render(
<ProjectRoadmap
table={table(
[TITLE_FIELD],
[
row('PVTI_1', 'Hidden-field item', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02', fieldName: 'Start date' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-20', fieldName: 'Target date' }
])
]
)}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('Placed by Start date → Target date')).toBeTruthy()
expect(screen.getByLabelText(/^Hidden-field item — /)).toBeTruthy()
expect(screen.queryByText('list')).toBeNull()
})
it('announces restricted items by name in the bar label', () => {
const redacted: GitHubProjectRow = {
...row('PVTI_9', '', [
{ kind: 'date', fieldId: 'f_start', date: '2026-03-02' },
{ kind: 'date', fieldId: 'f_end', date: '2026-03-05' }
]),
itemType: 'REDACTED'
}
render(
<ProjectRoadmap
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [redacted])}
fallback={<div>list</div>}
/>
)
expect(screen.getByLabelText(/^Restricted item — /)).toBeTruthy()
})
it('falls back to the caller-supplied list when no field can place items', () => {
render(<ProjectRoadmap table={table([TITLE_FIELD], [])} fallback={<div>list</div>} />)
expect(screen.getByText('list')).toBeTruthy()
expect(
screen.getByText(
'This roadmap view has no date or iteration field to place items on, so Orca is listing them instead.'
)
).toBeTruthy()
})
it('reports an empty filter result instead of drawing an empty grid', () => {
render(
<ProjectRoadmap
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [])}
fallback={<div>list</div>}
/>
)
expect(screen.getByText("No items match this view's filter.")).toBeTruthy()
expect(screen.queryByText('list')).toBeNull()
})
})
@@ -0,0 +1,419 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { CalendarClock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'
import { cn } from '@/lib/utils'
import { i18n, translate } from '@/i18n/i18n'
import ProjectGroupHeader from './ProjectGroupHeader'
import ProjectRoadmapBar from './ProjectRoadmapBar'
import { ProjectTitleCell } from './ProjectCellIdentity'
import { formatRoadmapTick } from './roadmap-tick-format'
import { loadRoadmapZoom, saveRoadmapZoom } from './roadmap-zoom-preference'
import { groupRows, sortRows } from '../../../../shared/github/project-group-sort'
import {
buildRoadmapTicks,
getRoadmapSpan,
resolveRoadmapDateSource,
roadmapOffsetPx,
roadmapSourceFieldNames,
type RoadmapSpan,
type RoadmapTick,
type RoadmapZoom
} from '../../../../shared/github/project-roadmap-timeline'
import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types'
const LABEL_WIDTH_PX = 280
const LANE_HEIGHT_PX = 36
const TICK_WIDTH_PX: Record<RoadmapZoom, number> = { month: 148, quarter: 128, year: 160 }
const ZOOMS: RoadmapZoom[] = ['month', 'quarter', 'year']
function localTodayAsUtcMidnightMs(): number {
const now = new Date()
return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
}
type Props = {
table: GitHubProjectTable
onOpenDialog?: (row: GitHubProjectRow) => void
/** Rendered instead of the timeline when the view has no field to place
* items on — the caller supplies the table list so the items stay usable. */
fallback: React.ReactNode
}
export default function ProjectRoadmap({
table,
onOpenDialog,
fallback
}: Props): React.JSX.Element {
const view = table.selectedView
const prefersReducedMotion = usePrefersReducedMotion()
const locale = i18n.resolvedLanguage ?? i18n.language
// Why: the grid lives on UTC calendar days (parseRoadmapDate), so "today"
// must be the viewer's LOCAL calendar date mapped to UTC midnight — the raw
// instant would shift the marker into the wrong day off UTC.
const [todayMs, setTodayMs] = useState(localTodayAsUtcMidnightMs)
// Why: a pane left open across midnight would otherwise keep yesterday's
// marker; re-arm after each fire so multi-day sessions stay honest.
useEffect(() => {
const now = new Date()
const nextLocalMidnight = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + 1
).getTime()
// Why: the +1s pad absorbs timer drift so the callback lands after the
// date change, not just before it.
const timer = setTimeout(
() => setTodayMs(localTodayAsUtcMidnightMs()),
nextLocalMidnight - now.getTime() + 1000
)
return () => clearTimeout(timer)
}, [todayMs])
const [zoom, setZoom] = useState<RoadmapZoom>(loadRoadmapZoom)
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set())
const scrollRef = useRef<HTMLDivElement | null>(null)
const source = useMemo(() => resolveRoadmapDateSource(view, table.rows), [view, table.rows])
const groups = useMemo(() => groupRows(table, sortRows(table, table.rows)), [table])
const spans = useMemo(() => {
const bySpan = new Map<string, RoadmapSpan>()
if (!source) {
return bySpan
}
for (const row of table.rows) {
const span = getRoadmapSpan(row, source)
if (span) {
bySpan.set(row.id, span)
}
}
return bySpan
}, [source, table.rows])
const tickWidth = TICK_WIDTH_PX[zoom]
const ticks = useMemo(
() => buildRoadmapTicks(Array.from(spans.values()), zoom, todayMs),
[spans, todayMs, zoom]
)
const timelineWidth = ticks.length * tickWidth
const todayPx = roadmapOffsetPx(todayMs, ticks, tickWidth)
const hasTimeline = source !== null && table.rows.length > 0
// Why: the interesting part of a roadmap is around now — open there instead
// of at the padded left edge, and re-centre when the zoom changes scale.
const scrollToToday = useCallback(() => {
const scroller = scrollRef.current
if (!scroller) {
return
}
const lead = (scroller.clientWidth - LABEL_WIDTH_PX) / 3
scroller.scrollTo({
left: Math.max(0, todayPx - lead),
behavior: prefersReducedMotion ? 'instant' : 'smooth'
})
}, [todayPx, prefersReducedMotion])
const todayPxRef = useRef(todayPx)
useLayoutEffect(() => {
todayPxRef.current = todayPx
})
// Center when the timeline appears or zoom changes; refetches must preserve user scroll.
useEffect(() => {
const scroller = scrollRef.current
if (!scroller) {
return
}
const lead = (scroller.clientWidth - LABEL_WIDTH_PX) / 3
scroller.scrollLeft = Math.max(0, todayPxRef.current - lead)
}, [zoom, hasTimeline])
const colorFieldId = useMemo(() => {
const grouped = view.groupByFields.find((field) => field.kind === 'single-select')
return (grouped ?? view.fields.find((field) => field.kind === 'single-select'))?.id ?? null
}, [view])
if (!source) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex-none border-b border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{translate(
'auto.components.github.project.ProjectRoadmap.be52f7b6db',
'This roadmap view has no date or iteration field to place items on, so Orca is listing them instead.'
)}
</div>
{fallback}
</div>
)
}
if (table.rows.length === 0) {
return (
<div className="flex min-h-[120px] items-center justify-center p-6 text-sm text-muted-foreground">
{translate(
'auto.components.github.project.ProjectViewList.4f57d2e0b1',
"No items match this view's filter."
)}
</div>
)
}
const undatedCount = table.rows.length - spans.size
const bandWidth = LABEL_WIDTH_PX + timelineWidth
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<RoadmapControls
placedBy={roadmapSourceFieldNames(source).join(' → ')}
undatedCount={undatedCount}
zoom={zoom}
onZoom={(next) => {
setZoom(next)
saveRoadmapZoom(next)
}}
onToday={scrollToToday}
/>
<div
ref={scrollRef}
className="min-h-0 min-w-0 flex-1 overflow-auto scrollbar-sleek"
data-testid="project-roadmap-scroller"
>
<div className="relative w-max min-w-full">
<RoadmapHeaderRow
ticks={ticks}
tickWidth={tickWidth}
zoom={zoom}
locale={locale}
todayPx={todayPx}
/>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0"
style={{
left: LABEL_WIDTH_PX,
backgroundImage: 'linear-gradient(to right, var(--border) 0 1px, transparent 1px)',
backgroundSize: `${tickWidth}px 100%`
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-px bg-foreground/30"
style={{ left: LABEL_WIDTH_PX + todayPx }}
/>
{groups.map((group) => {
const expanded = !collapsed.has(group.key)
return (
<div key={group.key}>
{view.groupByFields[0] ? (
<ProjectGroupHeader
group={group}
expanded={expanded}
bandWidth={bandWidth}
onToggle={() =>
setCollapsed((previous) => {
const next = new Set(previous)
if (!next.delete(group.key)) {
next.add(group.key)
}
return next
})
}
/>
) : null}
{expanded
? group.rows.map((row) => (
<RoadmapLane
key={row.id}
row={row}
span={spans.get(row.id) ?? null}
ticks={ticks}
tickWidth={tickWidth}
timelineWidth={timelineWidth}
colorFieldId={colorFieldId}
locale={locale}
onOpenDialog={onOpenDialog}
/>
))
: null}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
function RoadmapControls({
placedBy,
undatedCount,
zoom,
onZoom,
onToday
}: {
placedBy: string
undatedCount: number
zoom: RoadmapZoom
onZoom: (zoom: RoadmapZoom) => void
onToday: () => void
}): React.JSX.Element {
const zoomLabels: Record<RoadmapZoom, string> = {
month: translate('auto.components.github.project.ProjectRoadmap.6405e036e0', 'Month'),
quarter: translate('auto.components.github.project.ProjectRoadmap.f2b1cabef7', 'Quarter'),
year: translate('auto.components.github.project.ProjectRoadmap.b6afc6fe45', 'Year')
}
return (
<div className="flex min-w-0 flex-none flex-wrap items-center gap-2 border-b border-border/50 px-3 py-1.5 text-xs text-muted-foreground">
<span className="truncate">
{translate(
'auto.components.github.project.ProjectRoadmap.343888b143',
'Placed by {{value0}}',
{
value0: placedBy
}
)}
</span>
{undatedCount > 0 ? (
<span className="rounded-full border border-border/50 px-1.5 text-[10px]">
{translate(
'auto.components.github.project.ProjectRoadmap.6a088a5da1',
'{{value0}} without dates',
{ value0: undatedCount }
)}
</span>
) : null}
<div className="ml-auto flex items-center gap-1">
<Button type="button" size="xs" variant="outline" onClick={onToday}>
<CalendarClock className="size-3" />
{translate('auto.components.github.project.ProjectRoadmap.86eebd6020', 'Today')}
</Button>
<div
role="group"
aria-label={translate(
'auto.components.github.project.ProjectRoadmap.0bb1c1bc07',
'Timeline zoom'
)}
className="flex items-center rounded-md border border-border/60"
>
{ZOOMS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === zoom}
onClick={() => onZoom(option)}
className={cn(
'px-2 py-0.5 text-[11px] first:rounded-l-md last:rounded-r-md',
option === zoom ? 'bg-accent text-foreground' : 'hover:bg-accent/60'
)}
>
{zoomLabels[option]}
</button>
))}
</div>
</div>
</div>
)
}
function RoadmapHeaderRow({
ticks,
tickWidth,
zoom,
locale,
todayPx
}: {
ticks: RoadmapTick[]
tickWidth: number
zoom: RoadmapZoom
locale: string
todayPx: number
}): React.JSX.Element {
return (
<div className="sticky top-0 z-20 flex border-b border-border/60 bg-background/95 backdrop-blur">
<div
className="sticky left-0 z-30 shrink-0 border-r border-border/50 bg-background px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
style={{ width: LABEL_WIDTH_PX }}
>
{translate('auto.components.github.project.ProjectRoadmap.e304235879', 'Item')}
</div>
<div className="relative flex">
{ticks.map((tick, index) => {
const { label, sublabel } = formatRoadmapTick(tick, zoom, index, locale)
return (
<div
key={tick.key}
className="shrink-0 border-l border-border/40 px-2 py-2 text-[11px] text-muted-foreground"
style={{ width: tickWidth }}
>
<span className="font-medium text-foreground/80">{label}</span>
{sublabel ? <span className="ml-1 opacity-70">{sublabel}</span> : null}
</div>
)
})}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-px bg-foreground/30"
style={{ left: todayPx }}
/>
</div>
</div>
)
}
function RoadmapLane({
row,
span,
ticks,
tickWidth,
timelineWidth,
colorFieldId,
locale,
onOpenDialog
}: {
row: GitHubProjectRow
span: RoadmapSpan | null
ticks: RoadmapTick[]
tickWidth: number
timelineWidth: number
colorFieldId: string | null
locale: string
onOpenDialog?: (row: GitHubProjectRow) => void
}): React.JSX.Element {
const statusValue = colorFieldId ? row.fieldValuesByFieldId[colorFieldId] : undefined
const chipColor = statusValue?.kind === 'single-select' ? statusValue.color : null
const left = span ? roadmapOffsetPx(span.startMs, ticks, tickWidth) : 0
const width = span ? roadmapOffsetPx(span.endMs, ticks, tickWidth) - left : 0
return (
<div
className="group flex items-stretch border-b border-border/30 hover:bg-accent/40"
style={{ minHeight: LANE_HEIGHT_PX }}
>
<div
className={cn(
'sticky left-0 z-10 flex shrink-0 items-center gap-2 overflow-hidden border-r border-border/40 px-3',
'[background:color-mix(in_srgb,var(--background)_95%,var(--muted))]',
'group-hover:[background:color-mix(in_srgb,var(--accent)_60%,var(--background))]'
)}
style={{ width: LABEL_WIDTH_PX }}
>
<ProjectTitleCell row={row} onOpenDialog={() => onOpenDialog?.(row)} />
{span ? null : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{translate('auto.components.github.project.ProjectRoadmap.e077c79083', 'No dates')}
</span>
)}
</div>
<div className="relative shrink-0" style={{ width: timelineWidth }}>
{span ? (
<ProjectRoadmapBar
row={row}
span={span}
leftPx={left}
widthPx={width}
chipColor={chipColor}
locale={locale}
onOpen={() => onOpenDialog?.(row)}
/>
) : null}
</div>
</div>
)
}
@@ -0,0 +1,92 @@
import React from 'react'
import { GitPullRequest, Lock } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { chipStyle, labelChipColors, singleSelectChipColors } from './project-cell-chip-colors'
import { formatRoadmapSpan } from './roadmap-tick-format'
import type { RoadmapSpan } from '../../../../shared/github/project-roadmap-timeline'
import type { GitHubProjectRow } from '../../../../shared/github/project-types'
const MIN_BAR_WIDTH_PX = 24
type Props = {
row: GitHubProjectRow
span: RoadmapSpan
leftPx: number
widthPx: number
/** GitHub single-select color token for the row's status, when it has one. */
chipColor: string | null
locale: string
onOpen?: () => void
}
export default function ProjectRoadmapBar({
row,
span,
leftPx,
widthPx,
chipColor,
locale,
onOpen
}: Props): React.JSX.Element {
const colors = chipColor ? singleSelectChipColors(chipColor) : labelChipColors('')
const interactive = row.itemType !== 'REDACTED' && row.itemType !== 'DRAFT_ISSUE'
const dates = formatRoadmapSpan(span, locale)
// Why: shared by the visible text, aria-label, and tooltip — a redacted row
// must never announce or render an empty name.
const title =
row.itemType === 'REDACTED'
? translate('auto.components.github.project.ProjectRoadmapBar.7d1220d979', 'Restricted item')
: row.content.title
const bar = (
<button
type="button"
aria-disabled={interactive ? undefined : true}
onClick={interactive ? onOpen : undefined}
aria-label={translate(
'auto.components.github.project.ProjectRoadmapBar.cd68ccc17a',
'{{value0}} — {{value1}}',
{ value0: title, value1: dates }
)}
className={cn(
'absolute top-1/2 flex h-6 -translate-y-1/2 items-center gap-1.5 overflow-hidden rounded-md px-2 text-[11px] font-medium leading-none',
'text-[var(--github-project-chip-fg-light)] dark:text-[var(--github-project-chip-fg-dark)]',
interactive ? 'cursor-pointer hover:brightness-110' : 'cursor-default',
row.itemType === 'REDACTED' && 'opacity-60',
// Why: a point marker sizes to its own label instead of the span, so
// a single-date item stays readable rather than collapsing to a sliver.
span.point && 'max-w-60'
)}
style={{
left: span.point ? Math.max(0, leftPx - 6) : leftPx,
...(span.point ? {} : { width: Math.max(widthPx, MIN_BAR_WIDTH_PX) }),
...chipStyle(colors)
}}
>
{span.point ? (
<span aria-hidden className="size-2 shrink-0 rotate-45 rounded-[1px] bg-current" />
) : null}
{row.itemType === 'PULL_REQUEST' ? (
<GitPullRequest className="size-3 shrink-0" />
) : row.itemType === 'REDACTED' ? (
<Lock className="size-3 shrink-0" />
) : null}
{row.content.number == null ? null : (
<span className="shrink-0 opacity-70">#{row.content.number}</span>
)}
<span className="truncate">{title}</span>
</button>
)
return (
<Tooltip>
<TooltipTrigger asChild>{bar}</TooltipTrigger>
<TooltipContent side="top" align="start">
<div className="max-w-72 space-y-0.5">
<div className="truncate font-medium">{title}</div>
<div className="text-muted-foreground">{dates}</div>
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -42,13 +42,17 @@ function ProjectViewTab({
active: boolean
onPick: (viewId: string) => void
}): React.JSX.Element {
const supported = view.layout === 'TABLE_LAYOUT'
// Why: allowlist, not denylist — raw.layout is cast unchecked, so a future
// GitHub layout value must stay disabled instead of masquerading as a table.
const supported = view.layout === 'TABLE_LAYOUT' || view.layout === 'ROADMAP_LAYOUT'
const layoutLabel =
view.layout === 'BOARD_LAYOUT'
? 'Board'
: view.layout === 'ROADMAP_LAYOUT'
? 'Roadmap'
: 'Table'
: view.layout === 'TABLE_LAYOUT'
? 'Table'
: formatUnknownLayout(view.layout)
const Icon =
view.layout === 'BOARD_LAYOUT'
? KanbanSquare
@@ -106,8 +110,8 @@ function ProjectViewTab({
<p className="text-xs leading-5 text-muted-foreground">
{message}{' '}
{translate(
'auto.components.github.project.ProjectViewWrapper.1bf8c01c8b',
'Switch to a Table view to work with this project in Orca.'
'auto.components.github.project.ProjectViewStates.ac83c45672',
'Switch to a Table or Roadmap view to work with this project in Orca.'
)}
</p>
<Button
@@ -154,7 +158,12 @@ export function ProjectViewErrorState({
error.type === 'too_large'
? `This view has ${totalCount ?? 'many'} items — too large to render in Orca. Narrow the view's filter on GitHub.`
: error.type === 'unsupported_layout'
? 'Orca only renders table views yet. This is a Board or Roadmap view.'
? // Why: an older paired host still reports roadmaps as unsupported, so this
// copy must not name the layout — the tab strip already does that.
translate(
'auto.components.github.project.ProjectViewStates.e4cc8b14f2',
'Orca renders table and roadmap project views. This view uses a layout it cannot render yet.'
)
: error.type === 'not_found'
? 'Could not find this project or view.'
: error.type === 'schema_drift'
@@ -168,6 +177,14 @@ export function ProjectViewErrorState({
)
}
function formatUnknownLayout(layout: string): string {
const base = layout
.replace(/_LAYOUT$/, '')
.replaceAll('_', ' ')
.toLowerCase()
return base ? base.charAt(0).toUpperCase() + base.slice(1) : layout
}
function OpenInGitHubButton({ onClick }: { onClick: () => void }): React.JSX.Element {
return (
<Button size="sm" variant="outline" onClick={onClick}>
@@ -4,6 +4,7 @@ import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import ProjectViewList from './ProjectViewList'
import ProjectRoadmap from './ProjectRoadmap'
import ProjectItemSlugDialog from './ProjectItemSlugDialog'
import { ProjectMissingRepoDialog } from './ProjectMissingRepoDialog'
import { ProjectViewToolbar } from './ProjectViewToolbar'
@@ -123,7 +124,7 @@ function ProjectViewBody({
if (!visibleTable) {
return null
}
return (
const list = (
<ProjectViewList
table={visibleTable}
onOpenDialog={rowActions.openDialog}
@@ -140,4 +141,10 @@ function ProjectViewBody({
sourceSettings={tableState.settings}
/>
)
if (visibleTable.selectedView.layout === 'ROADMAP_LAYOUT') {
return (
<ProjectRoadmap table={visibleTable} onOpenDialog={rowActions.openDialog} fallback={list} />
)
}
return list
}
@@ -0,0 +1,68 @@
// Why: tick geometry is locale-free and lives in the shared timeline module;
// only the human-readable labels need Intl, so they are formatted here.
import type {
RoadmapSpan,
RoadmapTick,
RoadmapZoom
} from '../../../../shared/github/project-roadmap-timeline'
import { ROADMAP_DAY_MS } from '../../../../shared/github/project-roadmap-timeline'
export type RoadmapTickLabel = { label: string; sublabel: string | null }
// Why: Intl.DateTimeFormat construction costs ~0.1-1ms, and these run per tick
// and per bar on every render — cache per locale; the options never vary.
const monthFormatters = new Map<string, Intl.DateTimeFormat>()
const dayFormatters = new Map<string, Intl.DateTimeFormat>()
function cachedFormatter(
cache: Map<string, Intl.DateTimeFormat>,
locale: string,
options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormat {
let formatter = cache.get(locale)
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale, options)
cache.set(locale, formatter)
}
return formatter
}
export function formatRoadmapTick(
tick: RoadmapTick,
zoom: RoadmapZoom,
index: number,
locale: string
): RoadmapTickLabel {
const date = new Date(tick.startMs)
const year = String(date.getUTCFullYear())
if (zoom === 'year') {
return { label: year, sublabel: null }
}
if (zoom === 'quarter') {
return { label: `Q${Math.floor(date.getUTCMonth() / 3) + 1}`, sublabel: year }
}
const month = cachedFormatter(monthFormatters, locale, {
month: 'short',
timeZone: 'UTC'
}).format(date)
// Why: repeating the year on every month is noise — show it where the
// reader loses the thread, at the grid's start and each January.
return { label: month, sublabel: index === 0 || date.getUTCMonth() === 0 ? year : null }
}
/** Renders the span back as the inclusive calendar range the user typed on
* GitHub, so the tooltip matches the field values rather than the exclusive
* end the geometry uses. */
export function formatRoadmapSpan(span: RoadmapSpan, locale: string): string {
const format = cachedFormatter(dayFormatters, locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC'
})
const start = format.format(new Date(span.startMs))
if (span.point) {
return start
}
return `${start} ${format.format(new Date(span.endMs - ROADMAP_DAY_MS))}`
}
@@ -0,0 +1,27 @@
// Why: timeline zoom is a per-device viewing preference like column widths —
// keep it out of the debounced settings write and off the remote wire.
import type { RoadmapZoom } from '../../../../shared/github/project-roadmap-timeline'
const STORAGE_KEY = 'orca.githubProject.roadmapZoom'
const DEFAULT_ZOOM: RoadmapZoom = 'month'
function isRoadmapZoom(value: string | null): value is RoadmapZoom {
return value === 'month' || value === 'quarter' || value === 'year'
}
export function loadRoadmapZoom(): RoadmapZoom {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
return isRoadmapZoom(stored) ? stored : DEFAULT_ZOOM
} catch {
return DEFAULT_ZOOM
}
}
export function saveRoadmapZoom(zoom: RoadmapZoom): void {
try {
window.localStorage.setItem(STORAGE_KEY, zoom)
} catch {
// localStorage may be disabled — zoom just won't persist this session.
}
}
+4
View File
@@ -576,12 +576,16 @@
},
"ProjectPicker": {
"43a88ae574": "BOARD_LAYOUT",
"ab1a2c357d": "Roadmap (unsupported)",
"b787682111": "Browse all",
"ba0ab9a117": "Browse all (loading…)",
"cafb908f34": "TABLE_LAYOUT"
},
"ProjectRow": {
"c3b81ddea2": "DRAFT_ISSUE"
},
"ProjectViewWrapper": {
"1bf8c01c8b": "Switch to a Table view to work with this project in Orca."
}
}
},
+24
View File
@@ -2559,6 +2559,30 @@
"7c302f8174": "Untitled"
}
}
},
"ProjectRoadmap": {
"be52f7b6db": "This roadmap view has no date or iteration field to place items on, so Orca is listing them instead.",
"6405e036e0": "Month",
"f2b1cabef7": "Quarter",
"b6afc6fe45": "Year",
"343888b143": "Placed by {{value0}}",
"6a088a5da1": "{{value0}} without dates",
"86eebd6020": "Today",
"0bb1c1bc07": "Timeline zoom",
"e304235879": "Item",
"e077c79083": "No dates"
},
"ProjectRoadmapBar": {
"cd68ccc17a": "{{value0}} — {{value1}}",
"7d1220d979": "Restricted item"
},
"ProjectPickerPanels": {
"04ec212ccb": "Roadmap",
"9fe1ac868c": "Unsupported"
},
"ProjectViewStates": {
"ac83c45672": "Switch to a Table or Roadmap view to work with this project in Orca.",
"e4cc8b14f2": "Orca renders table and roadmap project views. This view uses a layout it cannot render yet."
}
},
"GitHubMarkdownComposer": {
@@ -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[] }