Files
orca/src/shared/github/project-group-sort.ts
T
monemandNeil 130866134d fix(github-project): order number-field groups by numeric value, not by label (#19397)
Grouping a GitHub Projects view by a NUMBER field sorted group headers as
text (1, 10, 2, 8). Number values now carry their value as the group order
hint and the NUMBER comparator sorts by it, falling back to label. Stale
non-number values use the finite unknown-option sentinel so mismatched
single-select/iteration comparators stay consistent.

Fixes #19395

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-10-26 11:23:17 -07:00

278 lines
8.5 KiB
TypeScript

// Pure selected-view grouping/sorting keeps desktop/mobile order independent of fetch order.
import type {
GitHubProjectField,
GitHubProjectRow,
GitHubProjectSort,
GitHubProjectTable
} from './project-types'
export type ProjectGroup = {
key: string
label: string
/** Iteration metadata for headers that render a date range + Current pill. */
iteration: {
startDate: string
duration: number
completed: boolean
} | null
rows: GitHubProjectRow[]
}
export const EMPTY_PROJECT_GROUP_KEY = '__empty__'
type ProjectFieldValue = GitHubProjectRow['fieldValuesByFieldId'][string]
/** False for anything that renders as an empty cell — absent, or present with a blank payload. */
function hasNonEmptyFieldValue(value: ProjectFieldValue | undefined): boolean {
if (!value) {
return false
}
switch (value.kind) {
case 'users':
return Boolean(value.users[0]?.login)
case 'labels':
return Boolean(value.labels[0]?.name)
case 'text':
return value.text.trim().length > 0
case 'date':
return value.date.trim().length > 0
case 'iteration':
case 'number':
case 'single-select':
return true
}
}
// Finite sentinels avoid Infinity - Infinity yielding NaN and skipping sort tie-breaks.
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
function createFieldOrderIndex(field: GitHubProjectField): ReadonlyMap<string, number> {
const entries =
field.kind === 'iteration'
? (field.iterations ?? [])
: field.kind === 'single-select'
? (field.options ?? [])
: []
const indices = new Map<string, number>()
entries.forEach((entry, index) => {
const id = entry.id
if (!indices.has(id)) {
indices.set(id, index)
}
})
return indices
}
// Preserve the mobile mirror's fallback for partial ordering metadata.
function getFieldValueForGrouping(
row: GitHubProjectRow,
field: GitHubProjectField,
orderIndex: ReadonlyMap<string, number>
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
const value = row.fieldValuesByFieldId[field.id]
if (!hasNonEmptyFieldValue(value)) {
return {
key: EMPTY_PROJECT_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
}
}
if (field.kind === 'iteration' && value.kind === 'iteration') {
const iterations = field.iterations ?? []
const idx = orderIndex.get(value.iterationId) ?? -1
const meta = iterations[idx]
return {
key: value.iterationId,
label: value.title || meta?.title || 'Iteration',
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: meta
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
: null
}
}
if (field.kind === 'single-select' && value.kind === 'single-select') {
const idx = orderIndex.get(value.optionId) ?? -1
return {
key: value.optionId,
label: value.name,
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: null
}
}
const label = deriveStringValue(value)
return {
key: `raw:${label}`,
label,
// Why: number labels sort as text (10 before 2, -10 between -1 and -2).
orderHint: value.kind === 'number' ? value.number : UNKNOWN_INDEX_SENTINEL - 1,
iteration: null
}
}
function labelForEmpty(field: GitHubProjectField): string {
return `No ${field.name}`
}
function deriveStringValue(value: GitHubProjectRow['fieldValuesByFieldId'][string]): string {
switch (value.kind) {
case 'text':
return value.text
case 'number':
return String(value.number)
case 'date':
return value.date
case 'single-select':
return value.name
case 'iteration':
return value.title
case 'labels':
return value.labels.map((l) => l.name).join(', ')
case 'users':
return value.users.map((u) => u.login).join(', ')
}
}
export function groupRows(
table: GitHubProjectTable,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupField = table.selectedView.groupByFields[0]
if (!groupField) {
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
}
return groupRowsByField(groupField, rowsInOrder)
}
/** Share field grouping between table/board so labels and ordering agree. */
export function groupRowsByField(
groupField: GitHubProjectField,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupOrderIndex = createFieldOrderIndex(groupField)
const buckets = new Map<
string,
{
label: string
orderHint: number
iteration: ProjectGroup['iteration']
rows: GitHubProjectRow[]
}
>()
for (const row of rowsInOrder) {
const { key, label, orderHint, iteration } = getFieldValueForGrouping(
row,
groupField,
groupOrderIndex
)
let bucket = buckets.get(key)
if (!bucket) {
bucket = { label, orderHint, iteration, rows: [] }
buckets.set(key, bucket)
}
bucket.rows.push(row)
}
const entries = Array.from(buckets.entries())
entries.sort((a, b) => {
if (a[0] === EMPTY_PROJECT_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_PROJECT_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
return a[1].orderHint - b[1].orderHint
}
if (groupField.dataType === 'NUMBER') {
return a[1].orderHint - b[1].orderHint || a[1].label.localeCompare(b[1].label)
}
return a[1].label.localeCompare(b[1].label)
})
return entries.map(([key, v]) => ({
key,
label: v.label,
iteration: v.iteration,
rows: v.rows
}))
}
function compareSort(
a: GitHubProjectRow,
b: GitHubProjectRow,
sort: GitHubProjectSort,
orderIndex: ReadonlyMap<string, number>
): number {
const field = sort.field
const aValue = a.fieldValuesByFieldId[field.id]
const bValue = b.fieldValuesByFieldId[field.id]
// Why: return before the trailing DESC flip so empty sorts last in both directions.
const aFilled = hasNonEmptyFieldValue(aValue)
const bFilled = hasNonEmptyFieldValue(bValue)
if (!aFilled || !bFilled) {
return aFilled === bFilled ? 0 : aFilled ? -1 : 1
}
let cmp = 0
if (
field.kind === 'single-select' &&
aValue.kind === 'single-select' &&
bValue.kind === 'single-select'
) {
const aIdx = orderIndex.get(aValue.optionId) ?? -1
const bIdx = orderIndex.get(bValue.optionId) ?? -1
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (
field.kind === 'iteration' &&
aValue.kind === 'iteration' &&
bValue.kind === 'iteration'
) {
const aIdx = orderIndex.get(aValue.iterationId) ?? -1
const bIdx = orderIndex.get(bValue.iterationId) ?? -1
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
cmp = aValue.number - bValue.number
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
cmp = aValue.date.localeCompare(bValue.date)
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
cmp = aValue.text.localeCompare(bValue.text)
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
cmp = (aValue.users[0]?.login ?? '').localeCompare(bValue.users[0]?.login ?? '')
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
cmp = (aValue.labels[0]?.name ?? '').localeCompare(bValue.labels[0]?.name ?? '')
} else {
// Ignore unknown sort kinds and continue tie-breaks, ending at row.position.
return 0
}
return sort.direction === 'DESC' ? -cmp : cmp
}
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
const sorts = table.selectedView.sortByFields.map((sort) => ({
sort,
orderIndex: createFieldOrderIndex(sort.field)
}))
const out = [...rows]
out.sort((a, b) => {
for (const { sort, orderIndex } of sorts) {
const cmp = compareSort(a, b, sort, orderIndex)
if (cmp !== 0) {
return cmp
}
}
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
})
return out
}
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
if (Number.isNaN(start)) {
return false
}
const end = start + iteration.duration * 86_400_000
const now = Date.now()
return now >= start && now < end
}