---
url: >-
  https://radioactive-labs.github.io/plutonium-core/reference/kanban/positioning.md
---
# Kanban Positioning

::: tip Positioning is not kanban-only
The model concern, the arithmetic and the `position_on` modes on this page are shared with **table and grid drag-to-reorder** — see [Positioning & drag-to-reorder](/reference/resource/positioning) for the index-surface half of the feature (the grip, the `reposition` endpoint, `reposition?`, and board inheritance).
:::

Plutonium uses **decimal fractional positioning** for kanban card ordering. A drop writes a single decimal position (the midpoint between its neighbors), so the common case touches exactly one row — no bulk renumbering. The one exception is rare **rebalancing**: when the same slot has been subdivided ~20 times and the gap between two neighbors shrinks below `1e-6`, Plutonium renumbers that one scope group back to clean integers before inserting (see [Gap exhaustion](#rebalancing)).

## `Plutonium::Positioning::Model` concern

Include this concern in any model you want to position:

```ruby
class Task < ApplicationRecord
  include Plutonium::Positioning::Model

  positioned_on :position, scope: :status
end
```

### `positioned_on(column = :position, scope: nil)`

Configures positional ordering for the model.

| Argument | Description |
|----------|-------------|
| `column` | The `decimal` database column that stores positions. Default: `:position` |
| `scope:` | Group positions by this attribute. Records with different scope values are ordered independently. `nil` = single global ordering across all rows |

After calling `positioned_on`, the model gets:

* A `before_create` callback that assigns the next position in the scope group (appends to end).
* A `reposition!(prev_record:, next_record:)` instance method.
* A `backfill_positions!(order: :created_at)` class method.

### Migration

Use the **`t.position`** helper — it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't get the scale wrong:

```ruby
create_table :tasks do |t|
  t.string :status, null: false, default: "todo"
  t.position                 # decimal :position, precision: 16, scale: 8
  t.timestamps
end
add_index :tasks, [:status, :position]   # match your scope attribute
```

Adding the column to an existing table works the same way in a `change_table` block:

```ruby
class AddPositionToTasks < ActiveRecord::Migration[8.1]
  def change
    change_table(:tasks) { |t| t.position }
    add_index :tasks, [:status, :position]
  end
end
```

`t.position` accepts a custom column name and any `column` options:

```ruby
t.position :sort_order               # custom name
t.position :position, index: true    # also add a single-column index
t.position :position, scale: 10      # override precision/scale
```

::: tip Why the helper picks `scale: 8`
If you write the column by hand, give it at least **two more decimal places than `EPSILON` (`1e-6`)** — i.e. `scale: 8` or higher. Rebalancing triggers when a gap drops below `1e-6`, so a column that can store smaller values still has room to write the final midpoint cleanly. A `scale: 6` column has no headroom: the last subdivision before a rebalance can round to a neighbor and momentarily collide. `t.position` defaults to `scale: 8`, which is safe.
:::

***

## `reposition!(prev_record:, next_record:)`

Moves a record so it sits between `prev_record` and `next_record` in its scope group. Pass `nil` for an end to prepend or append.

```ruby
task.reposition!(prev_record: card_a, next_record: card_b)
task.reposition!(prev_record: nil, next_record: first_card)  # prepend
task.reposition!(prev_record: last_card, next_record: nil)   # append
```

**Arithmetic:**

* Both nil → `0.0` (first item in empty group)
* Only `prev_record` → `prev.position + 1` (append)
* Only `next_record` → `next.position - 1` (prepend)
* Both present → `(prev.position + next.position) / 2.0` (midpoint)

### Gap exhaustion (rebalancing) {#rebalancing}

Each midpoint insert into the *same* slot halves the gap (`1.0 → 0.5 → 0.25 → …`), so after roughly 20 consecutive insertions the gap drops below `EPSILON` (`1e-6`). At that point `reposition!` rebalances **only that scope group** — renumbering every row in the group to fresh integers (`1.0, 2.0, 3.0, …`) in current-position order, inside a transaction — then reloads the two neighbors and writes the new midpoint. Other scope groups are untouched. End moves (a `nil` neighbor) never rebalance: they always have integer room via `prev ± 1`.

***

## `backfill_positions!(order: :created_at)`

Numbers all existing rows per scope group as `1.0, 2.0, 3.0, …` sorted by `order`. Safe to run on an empty table. Use this in a migration or seed task to initialize positions on existing data:

```ruby
# In a migration after adding the column:
Task.backfill_positions!(order: :created_at)
```

***

## `position_on` DSL modes

The `position_on` call inside `kanban do…end` controls how Plutonium persists positions after a drag-and-drop. Three modes are available:

::: tip A board inherits the definition's `position_on`
`position_on` is the **same verb** at both levels. A board resolves its strategy as: its own `position_on`, else the **definition's**, else the historic default (`:position`, Mode A). So a resource whose definition already declares `position_on` for its table and grid needs nothing inside `kanban do…end` — the board picks up the same attribute and the same mode.

Resolution is lazy, so a `kanban do…end` written **above** `position_on` in the class body still sees it.

```ruby
class TaskDefinition < Plutonium::Resource::Definition
  position_on :sort_order       # table, grid AND board

  kanban do
    position_on :board_rank     # …unless the board overrides it
  end
end
```

:::

### Mode A — delegate (default)

```ruby
kanban do
  # Implicit: position_on :position
  # Explicit with custom attribute:
  position_on :sort_order
end
```

On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning::Model` and call `positioned_on`.

### Mode B — BYO block

```ruby
kanban do
  position_on :sort_order do |move|
    # move.record — the dropped record
    # move.column — destination column key (Symbol)
    # move.prev   — record immediately before the slot (or nil)
    # move.next   — record immediately after the slot (or nil)
    # move.index  — 0-based insertion index within the destination column
    move.record.update!(sort_order: my_position(move.prev, move.next))
  end
end
```

Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Positioning::Move` argument (still reachable under its original name, `Plutonium::Kanban::Positioning::Move`) — it is NOT `instance_exec`'d, so `self` is the proc's original binding.

On a table or grid the same block runs with `move.column` set to `nil`, since those surfaces have no columns. See [Mode B](/reference/resource/positioning#mode-b) for a worked `acts_as_list` example.

### Mode C — disabled

```ruby
kanban do
  position_on false
end
```

No ordering is applied (relation is returned unchanged). On drop, `on_exit`/`on_enter` still fire; the position attribute is never touched. Cards render in the relation's default order.

***

## Pure math helpers

Available as module-level methods without an AR instance:

```ruby
Plutonium::Positioning.position_between(1.0, 3.0)    # => 2.0
Plutonium::Positioning.position_between(nil, 5.0)    # => 4.0  (prepend)
Plutonium::Positioning.position_between(5.0, nil)    # => 6.0  (append)
Plutonium::Positioning.position_between(nil, nil)    # => 0.0  (first item)

Plutonium::Positioning.gap_exhausted?(1.0, 1.0)      # => true
Plutonium::Positioning.gap_exhausted?(1.0, 3.0)      # => false
Plutonium::Positioning.gap_exhausted?(nil, 5.0)      # => false
```

`EPSILON = 1e-6` is the minimum gap before `gap_exhausted?` returns `true`.
