All files / src/components/organizer/SpeakerStatus phaseColumns.ts

91.3% Statements 21/23
86.66% Branches 13/15
100% Functions 4/4
100% Lines 19/19

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103                                                  2x               8x   2x                         29x     2x                           19x   3x   3x   6x   3x   1x   1x   2x                               21x 21x 21x 11x 11x 11x    
/**
 * 4-phase kanban column model (Epic 14 — Story 14.C.2 / 14.C.3).
 *
 * Replaces the 8-lane board with four phase columns while keeping every card's
 * exact 8-state chip. This module is pure (no React / no i18n) so the phase
 * grouping + the workflow-safe drag geometry can be unit-tested directly.
 *
 * The legal transition allow-list itself stays in `speakerTransitions.ts` and is
 * NOT re-encoded here — this module only answers "which phase is a state in" and
 * "what single forward transition does a cross-column drop trigger".
 */
import type { KanbanState } from './speakerTransitions';
 
export type PhaseKey = 'sourcing' | 'inviting' | 'content' | 'confirmed';
 
export interface PhaseColumn {
  key: PhaseKey;
  /** The 8-state members rendered inside this column (order = card sort order within a severity band). */
  states: readonly KanbanState[];
}
 
/**
 * FR15 — the four phase columns. DECLINED is intentionally absent: it renders as a
 * collapsible bottom strip (FR18), not a column.
 */
export const PHASE_COLUMNS: readonly PhaseColumn[] = [
  { key: 'sourcing', states: ['IDENTIFIED', 'CONTACTED'] },
  { key: 'inviting', states: ['READY', 'INVITED'] },
  { key: 'content', states: ['ACCEPTED', 'CONTENT_SUBMITTED'] },
  { key: 'confirmed', states: ['QUALITY_REVIEWED'] },
] as const;
 
/** All non-terminal states the four columns render (DECLINED excluded). */
export const PHASE_COLUMN_STATES: readonly KanbanState[] = PHASE_COLUMNS.flatMap((c) => c.states);
 
const STATE_TO_PHASE: Readonly<Record<KanbanState, PhaseKey | null>> = {
  IDENTIFIED: 'sourcing',
  CONTACTED: 'sourcing',
  READY: 'inviting',
  INVITED: 'inviting',
  ACCEPTED: 'content',
  CONTENT_SUBMITTED: 'content',
  QUALITY_REVIEWED: 'confirmed',
  DECLINED: null, // strip, not a column
};
 
/** Phase a given state belongs to, or null for DECLINED (the strip). */
export function phaseForState(state: KanbanState): PhaseKey | null {
  return STATE_TO_PHASE[state] ?? null;
}
 
const PHASE_INDEX: Readonly<Record<PhaseKey, number>> = {
  sourcing: 0,
  inviting: 1,
  content: 2,
  confirmed: 3,
};
 
/**
 * The single forward successor of a state along the linear funnel. Returns null
 * for QUALITY_REVIEWED (no forward step) and DECLINED (terminal). This is the
 * step a cross-column forward drag advances — exactly one (FR19): dropping
 * IDENTIFIED on the *Content* column still only triggers IDENTIFIED → CONTACTED.
 */
export function forwardSuccessor(state: KanbanState): KanbanState | null {
  switch (state) {
    case 'IDENTIFIED':
      return 'CONTACTED';
    case 'CONTACTED':
      return 'READY';
    case 'READY':
      return 'INVITED';
    case 'INVITED':
      return 'ACCEPTED';
    case 'ACCEPTED':
      return 'CONTENT_SUBMITTED';
    case 'CONTENT_SUBMITTED':
      return 'QUALITY_REVIEWED';
    default:
      return null;
  }
}
 
export type ColumnDrop = { kind: 'forward'; from: KanbanState; to: KanbanState } | { kind: 'noop' };
 
/**
 * Resolve a drop of a card (`from` state) onto a phase column (`targetPhase`).
 *
 * - A drop onto a phase strictly *after* the card's current phase is a **forward**
 *   move and resolves to the card's single forward successor (FR19 — one step only).
 * - A drop onto the same or an earlier phase is a **noop** (snap-back, FR20). The
 *   one legal back-transition (QUALITY_REVIEWED → CONTENT_SUBMITTED) and Decline live
 *   on the card/drawer affordances, not on the drag, so the allow-list is untouched.
 */
export function resolveColumnDrop(from: KanbanState, targetPhase: PhaseKey): ColumnDrop {
  const fromPhase = phaseForState(from);
  Iif (!fromPhase) return { kind: 'noop' }; // DECLINED is not draggable
  if (PHASE_INDEX[targetPhase] <= PHASE_INDEX[fromPhase]) return { kind: 'noop' };
  const to = forwardSuccessor(from);
  Iif (!to) return { kind: 'noop' };
  return { kind: 'forward', from, to };
}