/** * Pure cohort logic for SmartGroupManual (the "manual" grouping mode). * * Mirrors the design prototype's selection-first model: a flat list of samples, * each optionally assigned to a group / subgroup / sub-subgroup, derived into a * three-level cohort tree for the right rail. Framework-free for unit testing. */ /** A sample with its (optional) manual cohort assignment. */ export interface ManualSample { name: string group: string | null sub: string | null sub2: string | null color: string | null } /** Which slice of the sample list is shown. */ export type ManualFilter = 'all' | 'unassigned' | 'assigned' /** A sample paired with its index in the source list (so the UI can toggle it). */ export interface ManualMatch { sample: ManualSample index: number } /** Second-level subgroup node: a sub-subgroup name → count map. */ export interface CohortSubNode { total: number subs2: Map } /** Top-level cohort node: colour, total, and its subgroups. */ export interface CohortNode { color: string | null total: number subs: Map } /** The assignment values applied to a selection. */ export interface CohortAssignment { grp: string sub: string sub2: string color: string } /** * Build the group → subgroup → sub-subgroup tree from assigned samples. * Unassigned samples are ignored. Missing sub/sub2 values collapse to `—`. */ export function buildCohortTree(samples: ManualSample[]): Map { const groups = new Map() for (const sample of samples) { if (!sample.group) continue if (!groups.has(sample.group)) { groups.set(sample.group, { color: sample.color, total: 0, subs: new Map() }) } const node = groups.get(sample.group)! node.total++ const subKey = sample.sub || '—' if (!node.subs.has(subKey)) { node.subs.set(subKey, { total: 0, subs2: new Map() }) } const subNode = node.subs.get(subKey)! subNode.total++ const sub2Key = sample.sub2 || '—' subNode.subs2.set(sub2Key, (subNode.subs2.get(sub2Key) ?? 0) + 1) } return groups } /** Filter the sample list by a case-insensitive name query and assignment state. */ export function filterSamples( samples: ManualSample[], query: string, filter: ManualFilter, ): ManualMatch[] { const q = query.trim().toLowerCase() return samples .map((sample, index) => ({ sample, index })) .filter(({ sample }) => { if (q && !sample.name.toLowerCase().includes(q)) return false if (filter === 'unassigned') return !sample.group if (filter === 'assigned') return !!sample.group return true }) } /** * Apply a cohort assignment to the selected sample indices, returning a new list. * Blank subgroup / sub-subgroup values become `null`. */ export function assignSamples( samples: ManualSample[], selected: ReadonlySet, assignment: CohortAssignment, ): ManualSample[] { return samples.map((sample, index) => selected.has(index) ? { ...sample, group: assignment.grp.trim(), sub: assignment.sub.trim() || null, sub2: assignment.sub2.trim() || null, color: assignment.color, } : sample, ) }