mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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[] }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user