236 lines
7.3 KiB
TypeScript
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
|
|
}
|