diff --git a/app/models/column.rb b/app/models/column.rb
index 27bfa8a06..7585c39be 100644
--- a/app/models/column.rb
+++ b/app/models/column.rb
@@ -1,4 +1,6 @@
class Column < ApplicationRecord
+ include Positioned
+
belongs_to :collection, touch: true
has_many :cards, dependent: :nullify
diff --git a/app/models/concerns/column/positioned.rb b/app/models/concerns/column/positioned.rb
new file mode 100644
index 000000000..a3864dab0
--- /dev/null
+++ b/app/models/concerns/column/positioned.rb
@@ -0,0 +1,35 @@
+module Column::Positioned
+ extend ActiveSupport::Concern
+
+ included do
+ scope :sorted, -> { order(position: :asc) }
+
+ before_create :set_position
+ end
+
+ def move_left
+ if column = collection.columns.where("position < ?", position).sorted.last
+ swap_position_with(column)
+ end
+ end
+
+ def move_right
+ if column = collection.columns.where("position > ?", position).sorted.first
+ swap_position_with(column)
+ end
+ end
+
+ private
+ def set_position
+ max_position = collection.columns.maximum(:position)
+ self.position = max_position + 1
+ end
+
+ def swap_position_with(other_column)
+ transaction do
+ other_column.position, self.position = self.position, other_column.position
+ other_column.save!
+ save!
+ end
+ end
+end
diff --git a/app/views/collections/show/_columns.html.erb b/app/views/collections/show/_columns.html.erb
index 585093dc6..8c12b164a 100644
--- a/app/views/collections/show/_columns.html.erb
+++ b/app/views/collections/show/_columns.html.erb
@@ -16,7 +16,7 @@
<%= render "collections/show/stream", collection: collection, page: page %>
- <%= render partial: "collections/show/column", collection: collection.columns, cached: true %>
+ <%= render partial: "collections/show/column", collection: collection.columns.sorted, cached: true %>
<%= render "collections/show/closed", collection: collection %>
<%= render "collections/show/menu/columns", collection: collection %>
diff --git a/db/migrate/20251029142418_add_position_to_columns.rb b/db/migrate/20251029142418_add_position_to_columns.rb
new file mode 100644
index 000000000..ba16d7831
--- /dev/null
+++ b/db/migrate/20251029142418_add_position_to_columns.rb
@@ -0,0 +1,9 @@
+class AddPositionToColumns < ActiveRecord::Migration[8.2]
+ def change
+ add_column :columns, :position, :integer, default: 0
+
+ execute "UPDATE columns SET position = 0"
+
+ change_column_null :columns, :position, false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 71c233fcb..cef4d628e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.2].define(version: 2025_10_29_104115) do
+ActiveRecord::Schema[8.2].define(version: 2025_10_29_142418) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -191,6 +191,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_104115) do
t.string "color", null: false
t.datetime "created_at", null: false
t.string "name", null: false
+ t.integer "position", default: 0, null: false
t.datetime "updated_at", null: false
t.index ["collection_id"], name: "index_columns_on_collection_id"
end
diff --git a/db/schema_cache.yml b/db/schema_cache.yml
index f7da6da99..ad614480d 100644
--- a/db/schema_cache.yml
+++ b/db/schema_cache.yml
@@ -562,6 +562,16 @@ columns:
- *5
- *6
- *10
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: position
+ cast_type: *3
+ sql_type_metadata: *4
+ 'null': false
+ default: 0
+ default_function:
+ collation:
+ comment:
- *9
comments:
- *22
@@ -2818,4 +2828,4 @@ indexes:
nulls_not_distinct:
comment:
valid: true
-version: 20251029104115
+version: 20251029142418
diff --git a/test/models/column/positioned_test.rb b/test/models/column/positioned_test.rb
new file mode 100644
index 000000000..4d4355cfc
--- /dev/null
+++ b/test/models/column/positioned_test.rb
@@ -0,0 +1,62 @@
+require "test_helper"
+
+class Column::PositionedTest < ActiveSupport::TestCase
+ test "auto position new columns" do
+ collection = collections(:writebook)
+ max_position = collection.columns.maximum(:position)
+
+ new_column = collection.columns.create!(name: "New Column", color: "#000000")
+
+ assert_equal max_position + 1, new_column.position
+ end
+
+ test "move column to the left" do
+ collection = collections(:writebook)
+ columns = collection.columns.sorted.to_a
+
+ column_a = columns[0]
+ column_b = columns[1]
+ original_position_a = column_a.position
+ original_position_b = column_b.position
+
+ column_b.move_left
+
+ assert_equal original_position_b, column_a.reload.position
+ assert_equal original_position_a, column_b.reload.position
+ end
+
+ test "move left when already at leftmost position" do
+ collection = collections(:writebook)
+ leftmost_column = collection.columns.sorted.first
+ original_position = leftmost_column.position
+
+ leftmost_column.move_left
+
+ assert_equal original_position, leftmost_column.reload.position
+ end
+
+ test "move column to the right" do
+ collection = collections(:writebook)
+ columns = collection.columns.sorted.to_a
+
+ column_a = columns[0]
+ column_b = columns[1]
+ original_position_a = column_a.position
+ original_position_b = column_b.position
+
+ column_a.move_right
+
+ assert_equal original_position_b, column_a.reload.position
+ assert_equal original_position_a, column_b.reload.position
+ end
+
+ test "move right when already at rightmost position" do
+ collection = collections(:writebook)
+ rightmost_column = collection.columns.sorted.last
+ original_position = rightmost_column.position
+
+ rightmost_column.move_right
+
+ assert_equal original_position, rightmost_column.reload.position
+ end
+end