mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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>
278 lines
8.5 KiB
TypeScript
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
|
|
}
|