mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +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:
@@ -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