orca/src/shared/github-project-group-sort.ts

236 lines
7.3 KiB
TypeScript

// Why: grouping and sorting of Project rows is deterministic shared logic
// driven by `selectedView` — it must not depend on fetch ordering. Keeping it
// in a pure shared module lets desktop and mobile render Project views the same.
import type {
GitHubProjectField,
GitHubProjectRow,
GitHubProjectSort,
GitHubProjectTable
} from './github-project-types'
export type ProjectGroup = {
/** Stable key used for React reconciliation. */
key: string
/** Human-readable label used in the group header. */
label: string
/** Iteration metadata for headers that render a date range + Current pill. */
iteration: {
startDate: string
duration: number
completed: boolean
} | null
rows: GitHubProjectRow[]
}
const EMPTY_GROUP_KEY = '__empty__'
// Why: use a finite sentinel instead of Infinity so subtractions in the sort
// comparator stay finite. `Infinity - Infinity` is NaN, which makes
// Array.sort's behavior implementation-defined and skips later tie-breaks.
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
function getFieldValueForGrouping(
row: GitHubProjectRow,
field: GitHubProjectField
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
const value = row.fieldValuesByFieldId[field.id]
if (!value) {
return {
key: EMPTY_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
}
}
if (field.kind === 'iteration' && value.kind === 'iteration') {
const idx = field.iterations.findIndex((it) => it.id === value.iterationId)
const meta = field.iterations.find((it) => it.id === value.iterationId)
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 = field.options.findIndex((o) => o.id === value.optionId)
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, orderHint: 0, 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 }]
}
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)
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())
// Ordering rules per design doc §Grouping.
entries.sort((a, b) => {
if (a[0] === EMPTY_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
return a[1].orderHint - b[1].orderHint
}
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): number {
const field = sort.field
const aValue = a.fieldValuesByFieldId[field.id]
const bValue = b.fieldValuesByFieldId[field.id]
if (!aValue && !bValue) {
return 0
}
if (!aValue) {
return 1
}
if (!bValue) {
return -1
}
let cmp = 0
if (
field.kind === 'single-select' &&
aValue.kind === 'single-select' &&
bValue.kind === 'single-select'
) {
const aIdx = field.options.findIndex((o) => o.id === aValue.optionId)
const bIdx = field.options.findIndex((o) => o.id === bValue.optionId)
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 = field.iterations.findIndex((it) => it.id === aValue.iterationId)
const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId)
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') {
const aLogin = aValue.users[0]?.login ?? ''
const bLogin = bValue.users[0]?.login ?? ''
if (!aLogin && !bLogin) {
cmp = 0
} else if (!aLogin) {
cmp = 1
} else if (!bLogin) {
cmp = -1
} else {
cmp = aLogin.localeCompare(bLogin)
}
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
const aName = aValue.labels[0]?.name ?? ''
const bName = bValue.labels[0]?.name ?? ''
if (!aName && !bName) {
cmp = 0
} else if (!aName) {
cmp = 1
} else if (!bName) {
cmp = -1
} else {
cmp = aName.localeCompare(bName)
}
} else {
// Why: unknown sort-field kind — ignore this sort field and fall through
// to tie-breaks (and eventually row.position).
return 0
}
return sort.direction === 'DESC' ? -cmp : cmp
}
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
const sorts = table.selectedView.sortByFields
const out = [...rows]
out.sort((a, b) => {
for (const sort of sorts) {
const cmp = compareSort(a, b, sort)
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
}