Skip to content

Instantly share code, notes, and snippets.

@divideby0
Last active February 17, 2026 02:07
Show Gist options
  • Select an option

  • Save divideby0/b1d90cf6bc101752b9e99854c3656895 to your computer and use it in GitHub Desktop.

Select an option

Save divideby0/b1d90cf6bc101752b9e99854c3656895 to your computer and use it in GitHub Desktop.
Decision App (Spantree/decisionapp) — GitHub Issue Drafts

Decision App — GitHub Issue Drafts

Codebase Status (as of 2a402dd on main)

Architecture

  • Event-sourced Zustand store with 8 event types, all flowing through dispatch()projectEvents() reducer
  • Branching — fork/switch/rename/delete, each branch has independent event stream
  • Radix UI colors — 10-step light + dark palettes (red → green)
  • Storybook 10 + Chromatic CI
  • Pluggable persistence — localStorage adapter, interface ready for future backends
  • Read-only mode, weight toggling, winner highlighting, inline editing, comments via HoverCard

✅ Already event-sourced (all go through dispatch())

Event Type Triggered By
CriterionAdded addCriterion()
CriterionRenamed renameCriterion() / saveHeaderEdit()
CriterionRemoved removeCriterion()
ToolAdded addTool()
ToolRenamed renameTool() / saveHeaderEdit()
ToolRemoved removeTool()
ScoreSet addScore()
WeightSet setWeight()

Note: Criteria and option lifecycle events are already event-sourced as of the current codebase. Every mutation goes through dispatch(makeEvent(...)).

Branching architecture findings

IDs: branch-${Date.now()}-${Math.random().toString(36).slice(2, 8)} (timestamp + 6-char random). main branch hardcoded as id: 'main'.

Event IDs: Same pattern — evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.

Fork model: createBranch copies all parent events into new branch ([...parentEvents]). parentBranchId and forkEventIndex are stored but never used for reconstruction. Each branch is fully self-contained — no parent chain traversal.

Persistence: Only eventsByBranch, branches, and activeBranchId are persisted (via Zustand partialize). Domain state is re-derived on rehydrate.

Issues to file

# Title Priority Notes
1 Branch architecture: ID generation, parent chain, merge High Foundation for collab
2 Configurable scale types per criterion Medium Currently hardcoded 1-10
3 Description fields for criteria and options Medium No description on types
4 Cell detail drawer (replace HoverCard) Medium HoverCard exists but limited
5 Unit test coverage Medium Vitest configured, minimal tests
6 Supabase real-time collaboration Low Persistence interface ready
7 LLM-assisted research & scoring Low Product vision

Branch Architecture: Git-Inspired Event Store with Pluggable Storage

Vision

Model the matrix event store after git's core data structures — content-addressable objects, commits, and refs — but keep the abstraction storage-agnostic so it works equally well in localStorage, Supabase, Convex, or any future backend.

Git Concepts → Matrix Concepts

Git Decision App Notes
Blob PughEvent Atomic unit of change (ScoreSet, CriterionAdded, etc.)
Tree Snapshot Materialized state at a point in time (criteria, tools, scores, weights)
Commit Commit Points to a snapshot + parent commit(s) + metadata
Ref / Branch BranchRef Named pointer to a commit hash
HEAD activeBranchId Which branch is currently checked out
SHA-1 Content hash Deterministic ID derived from content, not timestamp+random

Proposed Data Model

Events (blobs)

interface PughEvent {
  id: string;           // content-addressable hash of the event payload
  type: PughEventType;
  timestamp: number;
  user: string;
  // ... type-specific fields
}

Event IDs should be deterministic — hash of (type, timestamp, user, payload). This makes deduplication trivial across replicas.

Commits

interface Commit {
  id: string;              // hash of (parentIds + eventIds + timestamp + author)
  parentIds: string[];     // 1 parent = normal commit, 2 = merge commit, 0 = root
  eventIds: string[];      // ordered list of event hashes in this commit
  author: string;
  timestamp: number;
  message?: string;        // optional, like git commit messages
}

A commit bundles one or more events together. You can commit after every keystroke (auto-save) or batch changes into logical units.

Refs (branches + tags)

interface Ref {
  name: string;           // 'main', 'alice/experiment', etc.
  commitId: string;       // points to latest commit on this branch
  type: 'branch' | 'tag'; // tags are immutable refs
}

State reconstruction

To get the current state for a branch:

1. Resolve ref → commitId
2. Walk commit chain via parentIds (like git log)
3. Collect all eventIds in topological order
4. projectEvents(events) → domain state

This naturally deduplicates shared history — forked branches share parent commits, not copied events.

Merge

// A merge commit has two parents
const mergeCommit: Commit = {
  id: hash(...),
  parentIds: [mainHeadId, featureBranchHeadId],
  eventIds: resolutionEventIds,  // conflict resolution events, if any
  author: 'cedric',
  timestamp: Date.now(),
  message: 'Merge experiment into main',
};

Conflict detection: walk both branches from common ancestor, find cells with divergent ScoreSet events. Resolution events in the merge commit settle conflicts.

Storage Abstraction

The key insight: separate the object store from the ref store. Both are simple key-value operations.

interface ObjectStore {
  // Content-addressable: key = hash of value
  put(object: PughEvent | Commit): Promise<string>;  // returns hash/id
  get<T>(id: string): Promise<T | null>;
  getMany<T>(ids: string[]): Promise<T[]>;
  has(id: string): Promise<boolean>;
}

interface RefStore {
  // Named pointers to commits
  getRef(name: string): Promise<string | null>;      // returns commitId
  setRef(name: string, commitId: string): Promise<void>;
  deleteRef(name: string): Promise<void>;
  listRefs(): Promise<Ref[]>;
}

interface MatrixRepository {
  objects: ObjectStore;
  refs: RefStore;
  
  // High-level operations (built on objects + refs)
  commit(branchName: string, events: PughEvent[], message?: string): Promise<Commit>;
  checkout(branchName: string): Promise<PughDomainState>;
  log(branchName: string, limit?: number): Promise<Commit[]>;
  diff(base: string, head: string): Promise<BranchDiff>;
  merge(source: string, target: string, strategy: MergeStrategy): Promise<Commit>;
  fork(newBranchName: string, fromBranch?: string): Promise<void>;
}

Storage implementations

Backend ObjectStore RefStore Notes
localStorage JSON blob keyed by hash JSON object of name→commitId Current use case, single user
Supabase events + commits tables, id as PK refs table with name + commit_id Real-time via Supabase Realtime subscriptions
Convex Convex documents with content-hash IDs Convex document per ref Reactive queries, automatic sync
IndexedDB Object stores for events + commits Object store for refs Offline-first, larger capacity than localStorage

Supabase schema sketch

create table matrix_objects (
  id text primary key,          -- content hash
  matrix_id uuid references matrices,
  kind text not null,           -- 'event' | 'commit'
  data jsonb not null,
  created_at timestamptz default now()
);

create table matrix_refs (
  matrix_id uuid references matrices,
  name text not null,
  commit_id text references matrix_objects(id),
  type text default 'branch',
  primary key (matrix_id, name)
);

Content Hashing

Use a fast, deterministic hash. Doesn't need to be cryptographic — just collision-resistant:

async function hashObject(obj: Record<string, unknown>): Promise<string> {
  const canonical = JSON.stringify(obj, Object.keys(obj).sort());
  const buffer = new TextEncoder().encode(canonical);
  const hash = await crypto.subtle.digest('SHA-256', buffer);
  return Array.from(new Uint8Array(hash))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

Or use a shorter hash (first 20 hex chars) for readability, like git's abbreviated hashes.

Migration from Current Architecture

The current store uses:

  • eventsByBranch: Record<string, PughEvent[]> — flat event arrays per branch
  • branches: Branch[] — branch metadata with unused parentBranchId/forkEventIndex
  • Zustand persist with version: 2

Migration to v3:

  1. Take each branch's event array
  2. Create a root commit from main's events
  3. For non-main branches, find the common prefix with parent → create shared commits, then branch-specific commits
  4. Generate content hashes for all events and commits
  5. Build ref entries pointing to head commits
  6. Write new persisted shape

Phased Implementation

Phase 1: Introduce ObjectStore + RefStore interfaces, implement localStorage backend, migrate existing data. Keep Zustand as the reactive layer on top.

Phase 2: Add commit semantics — auto-commit on each change (or debounced batches). Walk commit chain for state reconstruction.

Phase 3: Supabase/Convex backend. Real-time sync via RefStore subscriptions.

Phase 4: Merge, diff, and conflict resolution UI.

Acceptance Criteria

  • ObjectStore and RefStore interfaces defined
  • MatrixRepository high-level API with commit, checkout, log, fork
  • Content-addressable hashing for events and commits
  • localStorage implementation passing all tests
  • State reconstruction via commit chain (not event array copy)
  • Zustand store refactored to use MatrixRepository internally
  • Migration from v2 persisted format
  • Storybook devtools panel showing commit history
  • Stretch: diff + merge with conflict resolution

Labels

enhancement, architecture

[REFACTOR] Adopt git-inspired branching with pluggable storage

Problem

Current branching copies all parent events into each new branch ([...parentEvents]). parentBranchId and forkEventIndex are stored but never used for reconstruction. IDs are weak (timestamp + 6 random chars). No merge capability.

Git Concepts → Matrix Concepts

Git Decision App Notes
Blob PughEvent Atomic unit of change
Commit Commit Bundle of events + parent pointer(s) + metadata
Ref BranchRef Named pointer to a commit
HEAD activeBranchId Current branch
SHA UUIDv7 Sortable, timestamp-embedded
Tree Projected state Materialized by replaying events

Data Model

Commits

interface Commit {
  id: string;              // UUIDv7
  parentIds: string[];     // 0 = root, 1 = normal, 2 = merge
  eventIds: string[];      // ordered UUIDv7s of events in this commit
  author: string;
  timestamp: number;
  comment?: string;
}

Refs

interface Ref {
  name: string;            // 'main', 'experiment', etc.
  commitId: string;
  type: 'branch' | 'tag';
}

State reconstruction

ref → commitId → walk parentIds → collect eventIds in order → projectEvents()

Forked branches share parent commits — no duplication.

Storage Abstraction

Two interfaces that every backend implements:

interface ObjectStore {
  put(object: PughEvent | Commit): Promise<string>;
  get<T>(id: string): Promise<T | null>;
  getMany<T>(ids: string[]): Promise<T[]>;
  has(id: string): Promise<boolean>;
}

interface RefStore {
  getRef(name: string): Promise<string | null>;
  setRef(name: string, commitId: string): Promise<void>;
  deleteRef(name: string): Promise<void>;
  listRefs(): Promise<Ref[]>;
}

interface MatrixRepository {
  objects: ObjectStore;
  refs: RefStore;
  commit(branch: string, events: PughEvent[], comment?: string): Promise<Commit>;
  checkout(branch: string): Promise<PughDomainState>;
  log(branch: string, limit?: number): Promise<Commit[]>;
  fork(newBranch: string, from?: string): Promise<void>;
  diff(base: string, head: string): Promise<BranchDiff>;
  merge(source: string, target: string, strategy: MergeStrategy): Promise<Commit>;
}

Storage implementations

Backend Package ObjectStore RefStore
Memory @decisioncc/react-pugh-matrix Map<string, object> Map<string, string>
localStorage @decisioncc/react-pugh-matrix JSON keyed by ID JSON name→commitId
Supabase @decisioncc/storage-supabase matrix_objects table matrix_refs table
Convex @decisioncc/storage-convex (future) Convex documents Convex documents

Default storage is memory (no persistence, gone on refresh). localStorage is opt-in. Supabase/Convex are separate packages.

Content hashing (for deduplication, not IDs)

Events and commits use UUIDv7 as their primary ID. Content hashing is optional — useful for dedup when syncing across replicas:

async function contentHash(obj: Record<string, unknown>): Promise<string> {
  const canonical = JSON.stringify(obj, Object.keys(obj).sort());
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 20);
}

Phased Implementation

  1. Interfaces + memory storeObjectStore, RefStore, MatrixRepository. Zustand uses MatrixRepository internally.
  2. localStorage store — persists objects + refs to localStorage. Include branchId on events to avoid collisions.
  3. Commit semantics — auto-commit per change (or debounced). Commit chain for history/log.
  4. Diff + merge — detect conflicting cells, resolution UI. Merge commits with 2 parents.

Acceptance Criteria

  • ObjectStore and RefStore interfaces defined
  • Memory store implementation (default)
  • localStorage store implementation
  • MatrixRepository with commit, checkout, log, fork
  • Zustand store refactored to use MatrixRepository
  • UUIDv7 for all IDs (uuidv7 package)
  • State reconstruction via commit chain walk
  • Migration from v2 persisted format
  • Stretch: diff + merge with conflict resolution

Labels

refactor, architecture

Configurable Scale Types Per Criterion

Problem

All scores are currently hardcoded to a 1–10 integer scale. The color palette (SCORE_COLORS_LIGHT / SCORE_COLORS_DARK) is a fixed 10-entry lookup table. Different criteria benefit from different scales — e.g., cost might be 1-5 stars, satisfaction a thumbs up/down, or impact a -5 to +5 range.

Current Implementation

// Color lookup assumes 1-10
const SCORE_COLORS_LIGHT: Record<number, { bg: string; text: string }> = {
  1: { bg: red.red5, text: red.red11 },
  // ... 2-9
  10: { bg: green.green6, text: green.green12 },
};

function getScoreColor(score: number, isDark: boolean) {
  const clamped = Math.max(1, Math.min(10, Math.round(score)));
  const palette = isDark ? SCORE_COLORS_DARK : SCORE_COLORS_LIGHT;
  return palette[clamped];
}

Weight input is also capped at 0-10 via handleWeightChange.

Proposed Scale Types

type ScaleType = 
  | { kind: '1-10' }                           // current default
  | { kind: 'stars'; max: number }              // 1-5 star rating
  | { kind: 'range'; min: number; max: number } // arbitrary range (e.g., -5 to +5)
  | { kind: 'binary' }                          // thumbs up/down, yes/no
  | { kind: 'unbounded' }                       // any integer

interface Criterion {
  id: string;
  label: string;
  user: string;
  scale?: ScaleType;  // defaults to { kind: '1-10' } if omitted
}

New event type

interface CriterionScaleSet extends PughEventBase {
  type: 'CriterionScaleSet';
  criterionId: string;
  scale: ScaleType;
}

Color Mapping

Replace fixed lookup with a continuous function that maps any score range to the Radix color ramp:

function getScoreColor(score: number, scale: ScaleType, isDark: boolean) {
  const ratio = normalizeScore(score, scale); // 0.0 to 1.0
  const step = Math.round(ratio * 9) + 1;     // 1 to 10
  const palette = isDark ? SCORE_COLORS_DARK : SCORE_COLORS_LIGHT;
  return palette[step];
}

Acceptance Criteria

  • ScaleType union type added to types
  • CriterionScaleSet event type added
  • Criterion type extended with optional scale field
  • getScoreColor adapts to scale range
  • Input validation respects scale bounds
  • Binary scale renders as toggle/icon (not number input)
  • Star scale renders as visual stars (★)
  • Storybook stories for each scale type
  • Backward compatible — omitted scale defaults to 1-10

Labels

enhancement, data-model

Description Fields for Criteria and Options

Problem

Criteria and options only have id, label, and user. There's no way to explain what a criterion measures or provide context about an option. For real-world evaluations, descriptions are essential — "Performance" could mean response time, throughput, or benchmark scores depending on context.

Current Types

interface Criterion {
  id: string;
  label: string;
  user: string;
}

interface Tool {
  id: string;
  label: string;
  user: string;
}

Proposed Changes

New event types

interface CriterionDescriptionSet extends PughEventBase {
  type: 'CriterionDescriptionSet';
  criterionId: string;
  description: string;
}

interface ToolDescriptionSet extends PughEventBase {
  type: 'ToolDescriptionSet';
  toolId: string;
  description: string;
}

Updated domain types

interface Criterion {
  id: string;
  label: string;
  description?: string;
  user: string;
}

interface Tool {
  id: string;
  label: string;
  description?: string;
  user: string;
}

UI

  • Row headers (criteria): Info icon or subtle "ℹ" next to label → expands/collapses description, or shows in HoverCard
  • Column headers (tools): Same pattern — hover or click to see description
  • Edit mode: Clicking the description area opens an inline text input or small textarea
  • Read-only mode: Descriptions visible on hover only (keeps table compact)

Acceptance Criteria

  • CriterionDescriptionSet and ToolDescriptionSet events added
  • projectEvents reducer handles new events
  • Descriptions visible in UI (hover or expandable)
  • Descriptions editable inline (respects readOnly prop)
  • Storybook story showing criteria and tool descriptions
  • Description changes are event-sourced (history preserved)

Labels

enhancement, data-model

[FEAT] Support configurable score scales per criterion

Problem

All scores are hardcoded to 1–10 integers. Different criteria call for different scales — star ratings, unbounded counts, binary yes/no, or arbitrary numeric ranges. The current SCORE_COLORS_LIGHT/SCORE_COLORS_DARK lookup tables assume exactly 10 discrete values.

Design

Matrix-level default + per-criterion override

Each matrix has a default scale (set via MatrixDefaultScaleSet event). Individual criteria can override it (via CriterionScaleOverridden event). If neither is set, the default is { kind: 'numeric', min: 1, max: 10, step: 1 }.

Scale types

type ScaleType =
  | { kind: 'numeric'; min: number; max: number; step: number }   // e.g., 1-10, 0-5, -5 to +5
  | { kind: 'binary' }                                             // yes/no, pass/fail
  | { kind: 'unbounded' }                                          // github stars, revenue, etc.

No labels on scales. Scores are purely numeric. The current codebase requires a text label with every score — this is removed. If users want qualitative context, they use:

  • Criterion descriptions (markdown) to explain what the scale means
  • Cell comments to annotate individual scores

If there's future demand, a categorical scale type could map discrete labels to values (e.g., "Poor/Fair/Good/Excellent" → 1/2/3/4), but that's out of scope here.

The CellScored event has a required score: number and optional comment?: string. No label field.

No mixed sign within a matrix

A matrix must be either all unsigned (min ≥ 0) or all signed (min < 0). This simplifies normalization and color mapping. Set at the matrix level:

interface MatrixCreated extends PughEventBase {
  type: 'MatrixCreated';
  title: string;
  description?: string;
  allowNegative: boolean;   // false = all scales must have min ≥ 0
  defaultScale: ScaleType;
}

Validation: if allowNegative: false, reject any scale with min < 0.

Decimals

The step field controls precision:

  • step: 1 → integers only (1, 2, 3...)
  • step: 0.5 → half-steps (4.0, 4.5, 5.0...)
  • step: 0.1 → tenths

Input validation rounds to the nearest valid step.

Normalization for weighted totals

All scores must be normalized to [0, 1] (or [-1, 1] for signed matrices) before weighting. This makes totals comparable across different scale types.

Bounded scales (numeric, binary)

// Unsigned: [min, max] → [0, 1]
normalized = (score - min) / (max - min)

// Signed: [min, max] → [-1, 1]
normalized = (2 * (score - min) / (max - min)) - 1

Binary: yes = 1.0, no = 0.0.

Unbounded scales

Unbounded values (GitHub stars, revenue) have no fixed range. Normalization is relative to other options in the same criterion:

// Each cell's share of the total
normalized = cellValue / sum(allValuesForThisCriterion)

If all values are 0, normalized = 0. This naturally produces a 0–1 range where the proportions are preserved.

Edge case: If one option has 50,000 GitHub stars and others have 200, the dominant option gets ~0.99. This is by design — it reflects the actual ratio. If users want less extreme weighting, they can adjust the criterion weight or use a bounded scale with a cap.

Color mapping

Replace the fixed 10-entry lookup with a continuous function:

function getScoreColor(score: number, scale: ScaleType, allScores: number[], isDark: boolean) {
  const ratio = normalizeScore(score, scale, allScores); // 0.0 to 1.0
  const step = Math.round(ratio * 9) + 1;                // map to 1-10 palette index
  return (isDark ? SCORE_COLORS_DARK : SCORE_COLORS_LIGHT)[step];
}

For signed matrices, map [-1, 1][1, 10] (negative = red, zero = yellow, positive = green).

Input components

Scale Input
numeric (bounded) Number input with min/max/step constraints
binary Toggle switch or checkbox
unbounded Number input, no bounds, integers only

Acceptance Criteria

  • ScaleType union type defined
  • MatrixDefaultScaleSet event for matrix-level default
  • CriterionScaleOverridden event for per-criterion override
  • allowNegative flag on matrix prevents mixed-sign scales
  • step field controls decimal precision
  • Normalization function handles bounded, unbounded, and signed scales
  • getScoreColor adapts to normalized values
  • Weighted totals use normalized scores
  • Input components per scale type (number, toggle)
  • Storybook stories for each scale type
  • Backward compatible — omitted scale defaults to numeric 1-10

Labels

enhancement, data-model

Cell Detail Drawer (Upgrade from HoverCard)

Problem

Score cells currently show history and comments via a Radix HoverCard on hover. This works for quick glances but has limitations:

  • Mobile: Hover doesn't exist on touch devices
  • Comments: No way to add threaded discussion — just a single comment field per score event
  • History: Limited space in a hover tooltip for long histories
  • Editing: Score editing is a separate inline flow, disconnected from the history view

Current Implementation

The HoverCard shows:

  • Score history (sorted by timestamp, most recent first)
  • Score value + label + comment for each historical entry
  • Timestamp formatted as date

Proposed Changes

Replace (or supplement) the HoverCard with a Radix Sheet (drawer) that slides in from the right:

Drawer contents

  1. Cell identity — which tool × criterion this is
  2. Current score — editable inline
  3. Score history — full chronological list with author attribution
  4. Threaded comments — per-cell discussion, separate from score labels
  5. Activity timeline — interleaved score changes + comments

New event types

interface CommentAdded extends PughEventBase {
  type: 'CommentAdded';
  toolId: string;
  criterionId: string;
  parentCommentId?: string;  // for threading
  text: string;
}

Interaction model

  • Desktop: Click cell → drawer opens. HoverCard still works for quick preview.
  • Mobile: Tap cell → drawer opens (HoverCard disabled).
  • Read-only mode: Drawer opens but editing disabled.

Dependencies

  • Radix UI Dialog or Sheet component (or build with Radix primitives)

Acceptance Criteria

  • Drawer component with cell history + comments
  • CommentAdded event type with optional threading
  • Click-to-open on desktop, tap-to-open on mobile
  • Drawer respects readOnly prop
  • Storybook story for drawer interaction
  • HoverCard preserved as quick-preview (desktop only)

Labels

enhancement, ui

[FEAT] Replace HoverCard with cell detail drawer

Problem

Score cells show history and comments via Radix HoverCard on hover. This doesn't work on mobile (no hover) and provides limited space for rich content like threaded comments or long history.

Note: Editable matrices are desktop-only. Mobile will be read-only.

Design

Replace HoverCard with a Radix Sheet (drawer from the right) on click:

Drawer contents

  1. Cell identity — option × criterion label
  2. Current score — editable (desktop), read-only (mobile)
  3. Score history — all CellScored events for this cell, with author + timestamp
  4. Threaded comments — via CellCommented events with parentCommentId

New event type

interface CellCommented extends PughEventBase {
  type: 'CellCommented';
  optionId: string;
  criterionId: string;
  comment: string;
  parentCommentId?: string;  // for threading
}

Interaction

  • Desktop: Click cell → drawer opens. Edit score + add comments.
  • Mobile (read-only): Tap cell → drawer opens. View history + comments only.

Acceptance Criteria

  • Drawer component using Radix Sheet or Dialog
  • CellCommented event type with threading
  • Score history timeline in drawer
  • Comment thread UI
  • Click-to-open (desktop), tap-to-open (mobile read-only)
  • Respects readOnly prop
  • Storybook story

Labels

enhancement, ui

Unit Test Coverage

Problem

Vitest is configured with browser-playwright and coverage-v8, but there are minimal actual test files. The event sourcing logic, projection reducer, and store actions have no dedicated unit tests. Storybook interaction tests provide some coverage but aren't a substitute for targeted unit tests.

Current Test Infrastructure

  • vitest.config.ts configured with browser mode (playwright)
  • @vitest/coverage-v8 installed
  • .storybook/vitest.setup.ts exists
  • Storybook stories have some play functions for interaction testing

Proposed Test Coverage

Priority 1: Pure logic (no React)

These are fast, isolated, high-value:

  • projectEvents() — the core reducer

    • Empty events → empty state
    • CriterionAdded / ToolAdded populates arrays
    • CriterionRenamed / ToolRenamed updates labels
    • CriterionRemoved / ToolRemoved cascades (removes related scores)
    • ScoreSet appends to scores array
    • WeightSet updates weights map
    • Event ordering matters (later events override)
  • seedEventsFromOptions()

    • Generates correct event types from legacy data
    • Skips default weight (10) events
    • Preserves user attribution
  • makeEvent() (currently inline in createPughStore)

    • Generates unique IDs
    • Sets timestamp and user

Priority 2: Store behavior

  • createPughStore() — integration tests
    • Initial state from options
    • dispatch updates domain state
    • Branch CRUD (create, switch, rename, delete)
    • Branch isolation (events on one branch don't affect another)
    • Cannot delete main branch
    • Persistence round-trip (save → load → same state)

Priority 3: Component rendering

  • PughMatrix — via React Testing Library or Storybook tests
    • Renders correct number of rows/columns
    • Highlight prop applies CSS class
    • ReadOnly disables interactive elements
    • Weight toggle shows/hides column
    • Winner highlighting on highest total

Acceptance Criteria

  • projectEvents has >95% branch coverage
  • seedEventsFromOptions fully tested
  • Store CRUD operations tested
  • Branch operations tested (create, switch, delete, isolation)
  • CI runs tests on PR (GitHub Actions)
  • Overall coverage >80%

Labels

testing, chore

Supabase Real-Time Collaboration

Problem

The app currently persists to localStorage only. The Persister interface is already abstracted, making this a natural extension point. For the "GitHub for Pugh matrices" vision, multiple users need to collaborate on the same matrix in real time.

Current Persistence Architecture

// Pluggable interface — clean extension point
interface Persister {
  save(key: string, value: string): void;
  load(key: string): string | null;
  remove(key: string): void;
  subscribe?(key: string, callback: () => void): () => void;
}

The store already supports subscribe for rehydration — when external storage changes, the store re-derives domain state from events.

Persisted shape (via partialize):

{
  eventsByBranch: Record<string, PughEvent[]>;
  branches: Branch[];
  activeBranchId: string;
}

Proposed Implementation

Supabase Persister

function createSupabasePersister(supabase: SupabaseClient, matrixId: string): Persister {
  // save → upsert to `matrices` table
  // load → select from `matrices` table  
  // subscribe → Supabase Realtime channel subscription
}

Database Schema

create table matrices (
  id uuid primary key default gen_random_uuid(),
  slug text unique,
  owner_id uuid references auth.users,
  created_at timestamptz default now()
);

create table matrix_events (
  id uuid primary key,
  matrix_id uuid references matrices,
  branch_id text not null,
  event jsonb not null,
  created_at timestamptz default now()
);

Real-Time Flow

  1. User makes a change → dispatch() fires → event appended locally
  2. Persister writes event to Supabase
  3. Supabase Realtime broadcasts to other clients
  4. Other clients receive event → append to local store → re-project

Considerations

  • Conflict resolution: With event sourcing, conflicts = same cell scored by different users simultaneously. Last-write-wins by timestamp, or present merge UI.
  • Optimistic updates: Apply locally first, sync async. Revert on failure.
  • Offline support: Queue events in localStorage, flush when reconnected.
  • Auth: Supabase Auth for user identity. user field on events maps to auth user.

Dependencies

  • Branch architecture improvements (#XX) — proper UUIDs needed for distributed IDs
  • Supabase project setup

Acceptance Criteria

  • createSupabasePersister implements Persister interface
  • Events sync to Supabase in real time
  • Multiple browser tabs see live updates
  • Offline events queue and sync on reconnect
  • User presence indicators (who's viewing)
  • Works without Supabase (localStorage fallback, no hard dependency)

Labels

enhancement, infrastructure

[FEAT] Add Supabase persistence and real-time sync

Context

This would ship as a separate package (@decisioncc/storage-supabase) — the core @decisioncc/react-pugh-matrix component has no Supabase dependency. The core ships with memory (default, no persistence) and localStorage adapters.

Eventual plans include a @decisioncc/storage-convex adapter, but that's lower priority.

What Supabase Enables

1. Persistent storage

Events and commits stored in Postgres. Matrices survive across devices and sessions.

2. User authentication

Supabase Auth provides user identity. Events are attributed to authenticated users (not 'anonymous'). Enables:

  • Per-user score attribution
  • Access control (viewer/editor/admin per matrix)
  • Audit trail of who changed what

3. Real-time collaboration

Supabase Realtime broadcasts changes. When one user scores a cell, all connected clients see the update immediately. Enables:

  • Live co-editing
  • Presence indicators (who's viewing)
  • Conflict detection (same cell scored simultaneously)

Storage Schema

Uses the ObjectStore + RefStore interfaces from the branching refactor:

create table matrices (
  id uuid primary key default gen_random_uuid(),
  slug text unique,
  owner_id uuid references auth.users,
  allow_negative boolean default false,
  default_scale jsonb default '{"kind":"numeric","min":1,"max":10,"step":1}',
  created_at timestamptz default now()
);

create table matrix_objects (
  id text primary key,                    -- UUIDv7
  matrix_id uuid references matrices,
  kind text not null,                     -- 'event' | 'commit'
  branch_id text not null,               -- partition events by branch
  data jsonb not null,
  created_at timestamptz default now()
);

create table matrix_refs (
  matrix_id uuid references matrices,
  name text not null,
  commit_id text references matrix_objects(id),
  type text default 'branch',
  primary key (matrix_id, name)
);

create table matrix_collaborators (
  matrix_id uuid references matrices,
  user_id uuid references auth.users,
  role text check (role in ('viewer', 'editor', 'admin')),
  primary key (matrix_id, user_id)
);

Real-Time Flow

  1. User dispatches event → committed locally (optimistic)
  2. Supabase persister writes to matrix_objects
  3. Supabase Realtime broadcasts insert to other clients
  4. Other clients append event → re-project domain state

Conflict handling

With event sourcing, conflicts = same cell scored by different users at ~the same time. Resolution:

  • Default: Last-write-wins by UUIDv7 ordering (timestamp-embedded)
  • Future: Merge UI showing conflicting scores for manual resolution

Offline support

Queue events in memory/localStorage → flush to Supabase on reconnect. The ObjectStore interface supports this naturally — local writes succeed immediately, sync is async.

Acceptance Criteria

  • @decisioncc/storage-supabase package with ObjectStore + RefStore implementations
  • Supabase Auth integration — events attributed to authenticated users
  • Real-time sync via Supabase Realtime subscriptions
  • Optimistic local updates with async persistence
  • Access control: viewer (read-only), editor (score + comment), admin (all)
  • Works without Supabase — core component uses memory store by default
  • Offline event queuing with reconnect flush

Labels

enhancement, infrastructure

Standardize event type naming

Naming Conventions

Following event sourcing best practices:

  • Past tense — events describe something that happened, not a command
  • Domain language — use the domain terms (Criterion, Option, Score, Weight), not generic CRUD (Created, Updated, Deleted)
  • Specific over genericCriterionWeightAdjusted not FieldUpdated
  • Aggregate prefix — group by the aggregate/entity they affect: Matrix.*, Criterion.*, Option.*, Score.*
  • No abbreviationsDescriptionChanged not DescChanged

Naming pattern: {Entity}{WhatHappened}

Current Events (8)

Event Entity Status Notes
CriterionAdded Criterion ✅ Good
CriterionRenamed Criterion ✅ Good
CriterionRemoved Criterion ✅ Good
ToolAdded Tool ⚠️ Rename Should be OptionAdded — "Tool" is domain-specific to the demo data. The generic concept is "Option"
ToolRenamed Tool ⚠️ Rename OptionRenamed
ToolRemoved Tool ⚠️ Rename OptionRemoved
ScoreSet Score ⚠️ Rename "Set" is imperative. → ScoreRecorded or CellScored
WeightSet Criterion ⚠️ Rename CriterionWeightAdjusted

Renaming rationale: Tool → Option

The current codebase uses "Tool" because the demo compares React/Vue/Svelte. But a Pugh matrix compares options (alternatives, candidates). "Tool" leaks a specific use case into the generic data model. The component props, types, and events should use Option as the universal term.

This is a breaking change to the event schema — handle via versioned event migration (see Migration section below).

Proposed Complete Event Catalog

Matrix lifecycle

Event Payload When
MatrixCreated { title, description? } New matrix initialized
MatrixTitleChanged { title } Matrix renamed
MatrixDescriptionChanged { description } Matrix description updated
MatrixArchived {} Matrix soft-deleted

Criterion lifecycle

Event Payload When
CriterionAdded { criterionId, label } New row added
CriterionRenamed { criterionId, label } Row header edited
CriterionDescriptionChanged { criterionId, description } Criterion description set/updated
CriterionRemoved { criterionId } Row deleted
CriterionWeightAdjusted { criterionId, weight } Weight slider/input changed
CriterionScaleChanged { criterionId, scale: ScaleType } Scale type configured (1-10, stars, etc.)
CriterionReordered { criterionId, position } Row drag-reordered

Option lifecycle (currently "Tool")

Event Payload When
OptionAdded { optionId, label } New column added
OptionRenamed { optionId, label } Column header edited
OptionDescriptionChanged { optionId, description } Option description set/updated
OptionRemoved { optionId } Column deleted
OptionReordered { optionId, position } Column drag-reordered

Scoring

Event Payload When
CellScored { optionId, criterionId, score, label?, comment? } User enters/updates a score
CellCleared { optionId, criterionId } User removes a score
CellCommented { optionId, criterionId, comment, parentCommentId? } Threaded comment on a cell

Collaboration (future)

Event Payload When
CollaboratorInvited { userId, role } User granted access
CollaboratorRemoved { userId } Access revoked
CollaboratorRoleChanged { userId, role } Permissions changed

Base Event Shape

interface PughEventBase {
  id: string;           // UUIDv7 — timestamp-sortable, extractable creation time
  type: string;         // discriminator
  timestamp: number;    // epoch ms
  user: string;         // who performed the action
  correlationId?: string; // links related events (e.g., bulk import)
}

correlationId

Groups events that logically belong together. Examples:

  • AI scores multiple cells at once → all CellScored events share a correlationId
  • "Duplicate matrix" generates many CriterionAdded + OptionAdded + CellScored events
  • Undo: find all events with the same correlationId and reverse them

Full Type Definitions

// ─── Matrix ───────────────────────────────────────────
interface MatrixCreated extends PughEventBase {
  type: 'MatrixCreated';
  title: string;
  description?: string;
}

interface MatrixTitleChanged extends PughEventBase {
  type: 'MatrixTitleChanged';
  title: string;
}

interface MatrixDescriptionChanged extends PughEventBase {
  type: 'MatrixDescriptionChanged';
  description: string;
}

interface MatrixArchived extends PughEventBase {
  type: 'MatrixArchived';
}

// ─── Criterion ────────────────────────────────────────
interface CriterionAdded extends PughEventBase {
  type: 'CriterionAdded';
  criterionId: string;
  label: string;
}

interface CriterionRenamed extends PughEventBase {
  type: 'CriterionRenamed';
  criterionId: string;
  label: string;           // was `newLabel` — normalized to just `label`
}

interface CriterionDescriptionChanged extends PughEventBase {
  type: 'CriterionDescriptionChanged';
  criterionId: string;
  description: string;
}

interface CriterionRemoved extends PughEventBase {
  type: 'CriterionRemoved';
  criterionId: string;
}

interface CriterionWeightAdjusted extends PughEventBase {
  type: 'CriterionWeightAdjusted';
  criterionId: string;
  weight: number;
}

interface CriterionScaleChanged extends PughEventBase {
  type: 'CriterionScaleChanged';
  criterionId: string;
  scale: ScaleType;
}

interface CriterionReordered extends PughEventBase {
  type: 'CriterionReordered';
  criterionId: string;
  position: number;
}

// ─── Option ───────────────────────────────────────────
interface OptionAdded extends PughEventBase {
  type: 'OptionAdded';
  optionId: string;
  label: string;
}

interface OptionRenamed extends PughEventBase {
  type: 'OptionRenamed';
  optionId: string;
  label: string;
}

interface OptionDescriptionChanged extends PughEventBase {
  type: 'OptionDescriptionChanged';
  optionId: string;
  description: string;
}

interface OptionRemoved extends PughEventBase {
  type: 'OptionRemoved';
  optionId: string;
}

interface OptionReordered extends PughEventBase {
  type: 'OptionReordered';
  optionId: string;
  position: number;
}

// ─── Scoring ──────────────────────────────────────────
interface CellScored extends PughEventBase {
  type: 'CellScored';
  optionId: string;
  criterionId: string;
  score: number;
  label?: string;
  comment?: string;
}

interface CellCleared extends PughEventBase {
  type: 'CellCleared';
  optionId: string;
  criterionId: string;
}

interface CellCommented extends PughEventBase {
  type: 'CellCommented';
  optionId: string;
  criterionId: string;
  comment: string;
  parentCommentId?: string;
}

// ─── Collaboration (future) ──────────────────────────
interface CollaboratorInvited extends PughEventBase {
  type: 'CollaboratorInvited';
  userId: string;
  role: 'viewer' | 'editor' | 'admin';
}

interface CollaboratorRemoved extends PughEventBase {
  type: 'CollaboratorRemoved';
  userId: string;
}

interface CollaboratorRoleChanged extends PughEventBase {
  type: 'CollaboratorRoleChanged';
  userId: string;
  role: 'viewer' | 'editor' | 'admin';
}

// ─── Union ────────────────────────────────────────────
type PughEvent =
  // Matrix
  | MatrixCreated
  | MatrixTitleChanged
  | MatrixDescriptionChanged
  | MatrixArchived
  // Criterion
  | CriterionAdded
  | CriterionRenamed
  | CriterionDescriptionChanged
  | CriterionRemoved
  | CriterionWeightAdjusted
  | CriterionScaleChanged
  | CriterionReordered
  // Option
  | OptionAdded
  | OptionRenamed
  | OptionDescriptionChanged
  | OptionRemoved
  | OptionReordered
  // Scoring
  | CellScored
  | CellCleared
  | CellCommented
  // Collaboration
  | CollaboratorInvited
  | CollaboratorRemoved
  | CollaboratorRoleChanged;

Migration: v2 → v3 Event Types

Since events are immutable once written, use an upcaster pattern — transform old event shapes on read:

const EVENT_UPCASTERS: Record<string, (event: any) => PughEvent> = {
  'ToolAdded': (e) => ({ ...e, type: 'OptionAdded', optionId: e.toolId }),
  'ToolRenamed': (e) => ({ ...e, type: 'OptionRenamed', optionId: e.toolId, label: e.newLabel }),
  'ToolRemoved': (e) => ({ ...e, type: 'OptionRemoved', optionId: e.toolId }),
  'ScoreSet': (e) => ({ ...e, type: 'CellScored', optionId: e.toolId }),
  'WeightSet': (e) => ({ ...e, type: 'CriterionWeightAdjusted', criterionId: e.criterionId }),
};

function upcastEvent(raw: any): PughEvent {
  const upcaster = EVENT_UPCASTERS[raw.type];
  return upcaster ? upcaster(raw) : raw;
}

This runs at the storage boundary — old events are upcasted on load, new events are written in the new format. No need to rewrite stored data.

Acceptance Criteria

  • All events follow {Entity}{PastTenseVerb} naming convention
  • Tool* events renamed to Option* across codebase
  • ScoreSetCellScored, WeightSetCriterionWeightAdjusted
  • CriterionRenamed.newLabel normalized to .label
  • New events added: MatrixCreated, *DescriptionChanged, *Reordered, CellCleared, CellCommented
  • correlationId on base event type
  • Upcaster layer for backward-compatible reads of v2 events
  • Projection function (projectEvents) updated for all new types
  • TypeScript exhaustive switch check on PughEvent union (never-default pattern)

Labels

enhancement, architecture, breaking-change

[FIX] Resolve SonarQube findings from static analysis scan

SonarQube Community Edition 26.2 scan on commit 2a402dd — Feb 16, 2026.

Summary: 6 bugs, 0 vulnerabilities, 40 code smells, 0% test coverage, 0% duplication (2,195 LOC).

Rating Grade
Security A
Reliability C
Maintainability A

🔴 Bugs (6)

Missing keyboard listeners on click handlers

Non-interactive elements with onClick must also have onKeyDown/onKeyPress for keyboard and screen reader accessibility.

Rule File Line
jsx-a11y/click-events-have-key-events src/PughMatrix.tsx#L266 Tool header click-to-edit
jsx-a11y/click-events-have-key-events src/PughMatrix.tsx#L314 Criterion cell click-to-edit
jsx-a11y/click-events-have-key-events src/PughMatrix.tsx#L379 Score cell click-to-open editor
jsx-a11y/click-events-have-key-events src/BranchSelector.tsx#L105 Branch item click-to-switch

Fix: Add onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick() }}, tabIndex={0}, and role="button". Or replace <td>/<div> with <button> styled as table cells.

React Hooks called in wrong context

useState and useMemo called inside a Storybook render function that doesn't start with an uppercase letter.

Rule File Line
react-hooks/rules-of-hooks src/PughMatrix.stories.tsx#L267 useState in render
react-hooks/rules-of-hooks src/PughMatrix.stories.tsx#L268 useMemo in render

Fix: Extract to a named component (function InteractiveDemo() { ... }) or rename render to Render.


🔴 Critical Code Smells (3)

Cognitive complexity: projectEvents() — 37 (max 15)

File Line
src/events/projection.ts#L4 function projectEvents(events: PughEvent[])

The giant switch over 8 event types with nested logic. Will improve significantly with the event type refactor (#6) — recommend splitting into per-event-type handler functions:

const handlers: Record<PughEvent['type'], (state: Draft, event: PughEvent) => void> = {
  CriterionAdded: (state, e) => { ... },
  // ...
};

Cognitive complexity: PughMatrix render — 21 (max 15)

File Line
src/PughMatrix.tsx#L353 Score cell rendering block

Fix: Extract score cell, header cell, and totals row into sub-components.

Function nesting > 4 levels deep

File Line
src/store/createPughStore.ts#L207 persistmerge → lambda nesting

Fix: Extract the merge function to a named top-level helper.


🟡 Major Code Smells (14)

Non-native interactive elements missing ARIA roles (5)

File Line Element
src/BranchSelector.tsx#L105 Branch item
src/BranchSelector.tsx#L125 Branch rename area
src/PughMatrix.tsx#L266 Tool header
src/PughMatrix.tsx#L314 Criterion cell
src/PughMatrix.tsx#L379 Score cell

Fix: Add role="button" (or role="option" for branch list items) + tabIndex={0} + keyboard handlers.

Nested ternary operations (5)

File Line Context
src/PughMatrix.tsx#L262 Header class: isWinner ? ... : isHighlighted ? ...
src/PughMatrix.tsx#L288 Header class variant
src/PughMatrix.tsx#L368 Cell class
src/PughMatrix.tsx#L426 Total cell class
src/PughMatrix.tsx#L483 Another class computation

Fix: Extract to a helper function or use clsx:

const cellClass = clsx('pugh-tool-header', {
  'pugh-winner-header': isWinner(tool.id),
  'pugh-highlight-header': isHighlighted(tool.id),
});

CSS contrast failures (2)

File Line
src/pugh-matrix.css#L480 Text doesn't meet WCAG AA 4.5:1 ratio
src/pugh-matrix.css#L486 Text doesn't meet WCAG AA 4.5:1 ratio

Fix: Use Radix color step pairings that guarantee AA contrast (e.g., step 11 text on step 3 background).

React Hooks in Storybook render (2)

Covered above in Bugs section — same root cause.


🟢 Minor Code Smells (25)

Readonly props (4)

Component props interfaces should use Readonly<>:

File Line
src/PughMatrix.tsx#L63 PughMatrixProps
src/BranchSelector.tsx#L8 BranchSelectorProps
src/PughMatrix.stories.tsx#L109 Story wrapper props
src/store/PughStoreProvider.tsx#L10 Provider props

Prefer Number.isNaN over isNaN (3)

File Lines
src/PughMatrix.tsx L164, L175, L183

Prefer String#replaceAll() over String#replace() (2)

File Lines
src/PughMatrix.tsx L221, L228

Prefer globalThis over window (8)

File Lines
src/persist/localStoragePersister.ts L7, L8, L17, L18, L27, L28, L41, L42

Unexpected negated conditions (4)

File Lines
src/PughMatrix.tsx L262, L310, L368, L445
src/persist/localStoragePersister.ts L7

Unused import + unnecessary assertion (2)

File Line Issue
src/store/createPughStore.ts#L5 Unused import PughDomainState
src/store/createPughStore.ts#L63 Unnecessary type assertion

Acceptance Criteria

  • 0 bugs (a11y keyboard + hooks)
  • 0 critical code smells (cognitive complexity, nesting)
  • 0 major code smells (ARIA roles, ternaries, contrast)
  • Minor smells addressed (globalThis, Number.isNaN, Readonly, etc.)
  • Re-scan confirms clean dashboard

[REFACTOR] Split readOnly into granular display and interaction props

Problem

The current readOnly boolean is too coarse. Two distinct read-only modes were discussed:

  1. Weights locked, ratings editable — useful when a facilitator sets weights and participants only rate
  2. Everything locked — for embedding in docs, presentations, read-only sharing

Additionally, showTotals is currently internal toggle state, but it should also be controllable as a prop. Showing totals on a matrix with hidden weights implies equal weighting, which could mislead viewers into thinking one option is categorically better.

Current Props

interface PughMatrixProps {
  highlight?: string;
  showWinner?: boolean;
  isDark?: boolean;
  readOnly?: boolean;    // single boolean for everything
}

Internal state: showTotals, showWeights (toggled via buttons).

Proposed Props

interface PughMatrixProps {
  // Display
  highlight?: string;
  showWinner?: boolean;
  isDark?: boolean;
  
  // Visibility (can also be toggled via UI buttons when not locked)
  showWeights?: boolean;         // show/hide weight column
  showTotals?: boolean;          // show/hide weighted totals row
  
  // Interaction locks
  readOnlyWeights?: boolean;     // weights visible but not editable
  readOnlyRatings?: boolean;     // ratings visible but not editable
  
  // Convenience
  readOnly?: boolean;            // shorthand: sets both readOnlyWeights + readOnlyRatings
}

Prop precedence

  • readOnly={true} sets both readOnlyWeights and readOnlyRatings to true
  • Specific flags override: readOnly={true} readOnlyRatings={false} → weights locked, ratings editable (unusual but valid)
  • showWeights and showTotals as props override the initial internal state but still allow toggling (unless a locked variant is needed)

Use cases

Scenario Props
Facilitator sets weights, team rates readOnlyWeights
Embedded in Docusaurus readOnly
Presentation mode (no totals) readOnly showTotals={false}
Collaborative rating, no weights shown showWeights={false} showTotals={false}
Full interactive (default) (no props needed)

Totals without weights = misleading

If showWeights={false} and showTotals={true}, the totals assume equal weighting. This should either:

  • Show a disclaimer ("assuming equal weights")
  • Or require showWeights={true} when showTotals={true} (enforced via prop validation / console warning)

Acceptance Criteria

  • readOnlyWeights and readOnlyRatings as separate boolean props
  • readOnly as shorthand for both
  • showWeights and showTotals controllable as props
  • Prop validation: warn when totals shown without weights
  • UI buttons (Hide Weights / Hide Totals) still work when props don't lock them
  • Add/remove rows+cols hidden when readOnlyRatings is true
  • Storybook stories for each combination
  • Update feat/radix-table branch if it diverges from these props

Labels

refactor, api

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment