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 };
}
|