From a567e33bf7d8b1bd5eee0306fd4f81d75cffa8e2 Mon Sep 17 00:00:00 2001 From: NaoyaTatetsu Date: Sun, 6 Sep 2026 16:03:41 +0900 Subject: [PATCH] feat(github-projects): render Roadmap project views as a timeline (#17795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../project-view-field-normalization.ts | 10 +- .../project-view/project-view-table.test.ts | 107 +++++ .../github/project-view/project-view-table.ts | 16 +- .../github-project/ProjectGroupHeader.tsx | 42 +- .../github-project/ProjectPickerPanels.tsx | 24 +- .../github-project/ProjectRoadmap.test.tsx | 273 ++++++++++++ .../github-project/ProjectRoadmap.tsx | 419 ++++++++++++++++++ .../github-project/ProjectRoadmapBar.tsx | 92 ++++ .../github-project/ProjectViewStates.tsx | 27 +- .../github-project/ProjectViewWrapper.tsx | 9 +- .../github-project/roadmap-tick-format.ts | 68 +++ .../github-project/roadmap-zoom-preference.ts | 27 ++ .../src/i18n/en-runtime-required.json | 4 + src/renderer/src/i18n/locales/en.json | 24 + .../github/project-roadmap-timeline.test.ts | 310 +++++++++++++ src/shared/github/project-roadmap-timeline.ts | 301 +++++++++++++ src/shared/github/project-types.ts | 5 +- 17 files changed, 1720 insertions(+), 38 deletions(-) create mode 100644 src/main/github/project-view/project-view-table.test.ts create mode 100644 src/renderer/src/components/github-project/ProjectRoadmap.test.tsx create mode 100644 src/renderer/src/components/github-project/ProjectRoadmap.tsx create mode 100644 src/renderer/src/components/github-project/ProjectRoadmapBar.tsx create mode 100644 src/renderer/src/components/github-project/roadmap-tick-format.ts create mode 100644 src/renderer/src/components/github-project/roadmap-zoom-preference.ts create mode 100644 src/shared/github/project-roadmap-timeline.test.ts create mode 100644 src/shared/github/project-roadmap-timeline.ts diff --git a/src/main/github/project-view/project-view-field-normalization.ts b/src/main/github/project-view/project-view-field-normalization.ts index 1441a999809..ab41b812e08 100644 --- a/src/main/github/project-view/project-view-field-normalization.ts +++ b/src/main/github/project-view/project-view-field-normalization.ts @@ -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) diff --git a/src/main/github/project-view/project-view-table.test.ts b/src/main/github/project-view/project-view-table.test.ts new file mode 100644 index 00000000000..b51a08d4bf0 --- /dev/null +++ b/src/main/github/project-view/project-view-table.test.ts @@ -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()), + 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: '' }) + } + ) +}) diff --git a/src/main/github/project-view/project-view-table.ts b/src/main/github/project-view/project-view-table.ts index 042aeb333cf..bb580cb78e0 100644 --- a/src/main/github/project-view/project-view-table.ts +++ b/src/main/github/project-view/project-view-table.ts @@ -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 } : {}) } diff --git a/src/renderer/src/components/github-project/ProjectGroupHeader.tsx b/src/renderer/src/components/github-project/ProjectGroupHeader.tsx index add12fb4e99..7eb26d51fde 100644 --- a/src/renderer/src/components/github-project/ProjectGroupHeader.tsx +++ b/src/renderer/src/components/github-project/ProjectGroupHeader.tsx @@ -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 ? : } - - {group.label || - translate('auto.components.github.project.ProjectGroupHeader.244c9e7d06', 'All')} - - - {group.rows.length} - - {dateRange ? {dateRange} : null} - {isCurrent ? ( - - {translate('auto.components.github.project.ProjectGroupHeader.82a22d2079', 'Current')} + + {expanded ? : } + + {group.label || + translate('auto.components.github.project.ProjectGroupHeader.244c9e7d06', 'All')} - ) : null} + + {group.rows.length} + + {dateRange ? {dateRange} : null} + {isCurrent ? ( + + {translate('auto.components.github.project.ProjectGroupHeader.82a22d2079', 'Current')} + + ) : null} + ) } diff --git a/src/renderer/src/components/github-project/ProjectPickerPanels.tsx b/src/renderer/src/components/github-project/ProjectPickerPanels.tsx index f82ff38d7cb..a0183ee6968 100644 --- a/src/renderer/src/components/github-project/ProjectPickerPanels.tsx +++ b/src/renderer/src/components/github-project/ProjectPickerPanels.tsx @@ -126,19 +126,23 @@ function ProjectViewPickerRow({ view: GitHubProjectViewSummary onPick: (view: GitHubProjectViewSummary) => void | Promise }): 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 ( +
+ {ZOOMS.map((option) => ( + + ))} +
+ + + ) +} + +function RoadmapHeaderRow({ + ticks, + tickWidth, + zoom, + locale, + todayPx +}: { + ticks: RoadmapTick[] + tickWidth: number + zoom: RoadmapZoom + locale: string + todayPx: number +}): React.JSX.Element { + return ( +
+
+ {translate('auto.components.github.project.ProjectRoadmap.e304235879', 'Item')} +
+
+ {ticks.map((tick, index) => { + const { label, sublabel } = formatRoadmapTick(tick, zoom, index, locale) + return ( +
+ {label} + {sublabel ? {sublabel} : null} +
+ ) + })} +
+
+
+ ) +} + +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 ( +
+
+ onOpenDialog?.(row)} /> + {span ? null : ( + + {translate('auto.components.github.project.ProjectRoadmap.e077c79083', 'No dates')} + + )} +
+
+ {span ? ( + onOpenDialog?.(row)} + /> + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/github-project/ProjectRoadmapBar.tsx b/src/renderer/src/components/github-project/ProjectRoadmapBar.tsx new file mode 100644 index 00000000000..361378c0710 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectRoadmapBar.tsx @@ -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 = ( + + ) + return ( + + {bar} + +
+
{title}
+
{dates}
+
+
+
+ ) +} diff --git a/src/renderer/src/components/github-project/ProjectViewStates.tsx b/src/renderer/src/components/github-project/ProjectViewStates.tsx index 83e088b2759..e3184878851 100644 --- a/src/renderer/src/components/github-project/ProjectViewStates.tsx +++ b/src/renderer/src/components/github-project/ProjectViewStates.tsx @@ -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({

{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.' )}