Kanban Todo Board — Implementation Plan

Location: Ferramentas (Tools) tab Style: Professional Kanban board like Trello/Jira Architecture: 2 models only — TodoBoard + TodoCard


Architecture Overview

2-model architecture. Board configuration (columns, labels) lives as JSON on TodoBoard. Card sub-features (checklists, label assignments, member assignments) live as JSON on TodoCard. This keeps the codebase lean — no join tables, no nested models, no extra controllers.

One board per organization. Auto-created on first visit with default columns ("To Do", "In Progress", "Done").

Family sharing: Cards have belongs_to :membership (creator) and store assignee_ids as a JSON array of membership IDs. Individual view shows cards created by or assigned to current member; family view shows all.

2 models, 2 migrations, 3 controllers, 2 policies, 2 Stimulus controllers.


Data Models

Model DB Columns JSON Fields
TodoBoard organization_id columns (id, name, color, position), labels (id, name, color)
TodoCard todo_board_id, membership_id, column_id (string), title, description, priority, due_date, cover_color, position, archived checklists (title + items), label_ids, assignee_ids

Schema Details

TodoBoard

create_table :todo_boards do |t|
  t.references :organization, null: false, foreign_key: true
  t.json :columns, default: []     # [{id: "col_xxx", name: "To Do", color: "#3b82f6", position: 0}, ...]
  t.json :labels, default: []      # [{id: "lbl_xxx", name: "Bug", color: "#ef4444"}, ...]

  t.timestamps
end

add_index :todo_boards, :organization_id, unique: true

Default columns (seeded on creation):

[
  { "id": "col_1", "name": "To Do",        "color": "#6b7280", "position": 0 },
  { "id": "col_2", "name": "In Progress",  "color": "#f59e0b", "position": 1 },
  { "id": "col_3", "name": "Done",         "color": "#10b981", "position": 2 }
]

TodoCard

create_table :todo_cards do |t|
  t.references :todo_board, null: false, foreign_key: true
  t.references :membership, null: false, foreign_key: true
  t.string :column_id, null: false       # References a column ID from board.columns JSON
  t.string :title, null: false
  t.text :description
  t.string :priority, null: false, default: "medium"
  t.date :due_date
  t.string :cover_color
  t.integer :position
  t.boolean :archived, default: false
  t.datetime :archived_at
  t.json :checklists, default: []        # [{id: "chk_xxx", title: "Checklist", items: [{id: "item_xxx", name: "Task", checked: false}]}]
  t.json :label_ids, default: []         # ["lbl_xxx", "lbl_yyy"] — references board.labels[].id
  t.json :assignee_ids, default: []      # [1, 2] — membership IDs

  t.timestamps
end

add_index :todo_cards, [:todo_board_id, :column_id, :position]
add_index :todo_cards, [:todo_board_id, :archived]
  t.references :membership, null: false, foreign_key: true

  t.timestamps
end

add_index :todo_assignments, [:todo_card_id, :membership_id], unique: true

Model Details

TodoBoard

class TodoBoard < ApplicationRecord
  # Associations
  belongs_to :organization
  has_many :todo_cards, dependent: :destroy

  # Attributes
  attribute :columns, :json, default: []
  attribute :labels, :json, default: []

  # Validations
  validates :organization_id, uniqueness: true

  # Column helpers

  def find_column(column_id)
    columns.find { |c| c["id"] == column_id }
  end

  def add_column(name:, color: "#3b82f6")
    new_col = { "id" => "col_#{SecureRandom.hex(4)}", "name" => name, "color" => color, "position" => columns.size }
    self.columns = columns + [new_col]
    save!
    new_col
  end

  def update_column(column_id, **attrs)
    self.columns = columns.map { |c| c["id"] == column_id ? c.merge(attrs.stringify_keys) : c }
    save!
  end

  def remove_column(column_id)
    self.columns = columns.reject { |c| c["id"] == column_id }
    todo_cards.where(column_id: column_id).destroy_all
    save!
  end

  def reorder_columns(ordered_ids)
    self.columns = ordered_ids.each_with_index.map { |id, i| columns.find { |c| c["id"] == id }&.merge("position" => i) }.compact
    save!
  end

  def sorted_columns
    columns.sort_by { |c| c["position"] || 0 }
  end

  # Label helpers

  def find_label(label_id)
    labels.find { |l| l["id"] == label_id }
  end

  def add_label(name:, color:)
    new_label = { "id" => "lbl_#{SecureRandom.hex(4)}", "name" => name, "color" => color }
    self.labels = labels + [new_label]
    save!
    new_label
  end

  def update_label(label_id, **attrs)
    self.labels = labels.map { |l| l["id"] == label_id ? l.merge(attrs.stringify_keys) : l }
    save!
  end

  def remove_label(label_id)
    self.labels = labels.reject { |l| l["id"] == label_id }
    # Clean up label references from cards
    todo_cards.each do |card|
      card.update!(label_ids: card.label_ids - [label_id]) if card.label_ids.include?(label_id)
    end
    save!
  end

  # Default board setup

  def self.find_or_create_for(organization)
    find_by(organization: organization) || create_with_defaults(organization)
  end

  def self.create_with_defaults(organization)
    create!(
      organization: organization,
      columns: [
        { "id" => "col_1", "name" => "To Do",       "color" => "#6b7280", "position" => 0 },
        { "id" => "col_2", "name" => "In Progress", "color" => "#f59e0b", "position" => 1 },
        { "id" => "col_3", "name" => "Done",        "color" => "#10b981", "position" => 2 }
      ],
      labels: [
        { "id" => "lbl_1", "name" => "Bug",     "color" => "#ef4444" },
        { "id" => "lbl_2", "name" => "Feature", "color" => "#3b82f6" },
        { "id" => "lbl_3", "name" => "Urgent",  "color" => "#f97316" }
      ]
    )
  end
end

TodoCard

class TodoCard < ApplicationRecord
  # Associations
  belongs_to :todo_board
  belongs_to :membership

  # Attributes
  attribute :checklists, :json, default: []
  attribute :label_ids, :json, default: []
  attribute :assignee_ids, :json, default: []

  # Enums
  enum :priority, { low: "low", medium: "medium", high: "high", urgent: "urgent" }, default: :medium

  # Normalization
  normalizes :title, with: ->(title) { title.strip }

  # Validations
  validates :title, presence: true
  validates :column_id, presence: true

  # Scopes
  scope :active, -> { where(archived: false) }
  scope :archived_cards, -> { where(archived: true) }
  scope :in_column, ->(column_id) { where(column_id: column_id) }
  scope :overdue, -> { active.where("due_date < ?", Date.current) }
  scope :by_priority, ->(priority) { where(priority: priority) }
  scope :ordered, -> { order(:position) }

  # Card actions

  def archive!
    update!(archived: true, archived_at: Time.current)
  end

  def unarchive!
    update!(archived: false, archived_at: nil)
  end

  def move_to!(column_id, position)
    update!(column_id: column_id, position: position)
  end

  def overdue?
    due_date.present? && due_date < Date.current && !archived?
  end

  # Checklist helpers (all operate on JSON)

  def add_checklist(title: "Checklist")
    new_checklist = { "id" => "chk_#{SecureRandom.hex(4)}", "title" => title, "items" => [] }
    self.checklists = checklists + [new_checklist]
    save!
    new_checklist
  end

  def remove_checklist(checklist_id)
    self.checklists = checklists.reject { |c| c["id"] == checklist_id }
    save!
  end

  def add_checklist_item(checklist_id, name:)
    new_item = { "id" => "item_#{SecureRandom.hex(4)}", "name" => name, "checked" => false }
    self.checklists = checklists.map do |c|
      c["id"] == checklist_id ? c.merge("items" => c["items"] + [new_item]) : c
    end
    save!
    new_item
  end

  def toggle_checklist_item(checklist_id, item_id)
    self.checklists = checklists.map do |c|
      next c unless c["id"] == checklist_id
      c.merge("items" => c["items"].map { |i| i["id"] == item_id ? i.merge("checked" => !i["checked"]) : i })
    end
    save!
  end

  def remove_checklist_item(checklist_id, item_id)
    self.checklists = checklists.map do |c|
      next c unless c["id"] == checklist_id
      c.merge("items" => c["items"].reject { |i| i["id"] == item_id })
    end
    save!
  end

  def checklist_progress
    items = checklists.flat_map { |c| c["items"] || [] }
    return { total: 0, checked: 0, percentage: 0 } if items.empty?

    total = items.size
    checked = items.count { |i| i["checked"] }
    { total: total, checked: checked, percentage: (checked.to_f / total * 100).round }
  end

  # Label helpers

  def card_labels(board = todo_board)
    board.labels.select { |l| label_ids.include?(l["id"]) }
  end

  def toggle_label(label_id)
    self.label_ids = label_ids.include?(label_id) ? label_ids - [label_id] : label_ids + [label_id]
    save!
  end

  # Assignment helpers

  def assigned_to?(membership)
    assignee_ids.include?(membership.id)
  end

  def toggle_assignee(membership_id)
    self.assignee_ids = assignee_ids.include?(membership_id) ? assignee_ids - [membership_id] : assignee_ids + [membership_id]
    save!
  end

  # Delegation
  delegate :organization, to: :todo_board
end

Also add to existing models:

  • Organization: has_one :todo_board, dependent: :destroy
  • Membership: has_many :todo_cards, dependent: :destroy

Implementation Phases

Phase 1: Core Board (MVP — columns, cards, drag-and-drop)

Step 1 — Database Migrations

Create 2 migrations: todo_boards and todo_cards.

Step 2 — Models

Create TodoBoard and TodoCard following the code above. Add associations to Organization and Membership.

Step 3 — Policies

Both inherit Organization::BasePolicy:

  • TodoBoardPolicyshow?, update? (column/label management) require membership.present?
  • TodoCardPolicy — all CRUD + archive?, unarchive?, move? require membership.present?

Step 4 — Routes

Add inside scope module: :organizations in config/routes.rb:

# Kanban Board
resource :todo_board, only: %i[show], path: "tasks" do
  post :add_column
  patch :update_column
  delete :remove_column
  patch :reorder_columns
  post :add_label
  patch :update_label
  delete :remove_label
end
resources :todo_cards do
  member do
    post :archive
    post :unarchive
    post :move
    post :toggle_label
    post :toggle_assignee
    post :add_checklist
    delete :remove_checklist
    post :add_checklist_item
    post :toggle_checklist_item
    delete :remove_checklist_item
  end
end

Step 5 — Controllers

3 thin controllers:

TodoBoardController — Board view + column/label management:

  • show — finds or creates board, loads cards grouped by column, handles view_mode for family orgs
  • add_column / update_column / remove_column / reorder_columns — Turbo Stream responses
  • add_label / update_label / remove_label — Turbo Stream responses

TodoCardsController — Card CRUD + all card actions:

  • show — card detail in Turbo Frame modal
  • new/create — quick create modal (title + column)
  • edit/update — full edit modal
  • destroy — with confirmation
  • archive/unarchive — toggle archived state
  • move — updates column_id + position (called by Kanban Stimulus controller)
  • toggle_label / toggle_assignee — Turbo Stream updates
  • add_checklist / remove_checklist / add_checklist_item / toggle_checklist_item / remove_checklist_item — Turbo Stream updates

(Optional) TodoCard::Filter — Namespaced class for advanced server-side filtering (Phase 3).

Step 6 — Kanban Stimulus Controller

app/javascript/controllers/kanban_controller.js

  • Uses SortableJS directly (already installed as dependency of @stimulus-components/sortable)
  • static targets = ["column", "columnContainer"]
  • static values = { moveCardUrl: String, reorderColumnsUrl: String }
  • On connect: initializes SortableJS on each column target with group: "kanban" for cross-column drag
  • onEnd callback: sends POST to /todo_cards/:id/move with { column_id, position }
  • Column reordering: separate Sortable instance on the column container
  • Optimistic UI: moves card DOM immediately, reverts on server error
  • Touch-friendly: SortableJS has native touch support for mobile

Step 7 — Board View

app/views/organizations/todo_board/show.html.erb

Layout structure:

┌─────────────────────────────────────────────────────────┐
│ Header: "Tasks" title  |  Filter bar  |  + Add Column   │
├──────────┬──────────┬──────────┬────────────────────────┤
│ To Do    │ In Prog  │ Done     │  (horizontal scroll →) │
│ (3)      │ (2)      │ (5)      │                        │
├──────────┼──────────┼──────────┤                        │
│ ┌──────┐ │ ┌──────┐ │ ┌──────┐ │                        │
│ │ Card │ │ │ Card │ │ │ Card │ │                        │
│ │ ──── │ │ │ ──── │ │ │ ──── │ │                        │
│ │ Pri  │ │ │ Due  │ │ │ Done │ │                        │
│ │ Tags │ │ │ Tags │ │ │      │ │                        │
│ └──────┘ │ └──────┘ │ └──────┘ │                        │
│ ┌──────┐ │ ┌──────┐ │ ┌──────┐ │                        │
│ │ Card │ │ │ Card │ │ │ Card │ │                        │
│ └──────┘ │ └──────┘ │ └──────┘ │                        │
│          │          │          │                        │
│ + Add    │ + Add    │ + Add    │                        │
│  card    │  card    │  card    │                        │
└──────────┴──────────┴──────────┴────────────────────────┘

Design specs:

  • Full-width board with overflow-x-auto for horizontal column scroll
  • Dark theme: bg-[#0d1117] base, columns bg-[#161b22] with border-white/[0.06]
  • Column headers: name, card count badge, edit/delete dropdown menu
  • Cards: rounded-xl, shadow, hover effect, priority color indicator on left border
  • Card compact view shows: title, priority badge, due date chip, label color dots, assignee avatars, checklist progress bar
  • turbo_frame_tag :modal for card detail overlays
  • Empty board state: illustration + "Create your first column" CTA

Step 8 — Card Detail Modal Views

Reuse existing turbo_frame_tag :modal pattern from market_lists/notes:

Card Detail (show.html.erb):

┌──────────────────────────────────────┐
│ ✕ Close                              │
│                                      │
│ [Title — inline editable]            │
│ in column: [To Do ▾]                 │
│                                      │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Priority │ │Due Date │ │  Cover  │ │
│ │ ▾ High  │ │ Mar 15  │ │ 🎨 Blue │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│                                      │
│ Labels: [Bug] [Frontend] [+ Add]     │
│                                      │
│ Description:                         │
│ ┌──────────────────────────────────┐ │
│ │ Textarea with markdown...        │ │
│ └──────────────────────────────────┘ │
│                                      │
│ Checklists:                          │
│ ☑ Subtask 1              ██████ 66% │
│ ☑ Subtask 2                         │
│ ☐ Subtask 3                         │
│ [+ Add item]                         │
│ [+ Add checklist]                    │
│                                      │
│ Assignees: 👤 👤 [+ Assign]         │
│                                      │
│ ─────────────────────────────────── │
│ Created by John · 2 days ago         │
│ [Archive]              [Delete]      │
└──────────────────────────────────────┘

Phase 2: Filters, Archive & Polish

Step 9 — Advanced Filter Bar

app/javascript/controllers/todo_filter_controller.js

  • Priority filter: dropdown with Low/Medium/High/Urgent options
  • Label filter: multi-select with label color chips
  • Assignee filter: dropdown with member avatars (family orgs)
  • Due date filter: options for Overdue / Today / This Week / No Date
  • Search: debounced text input for card title matching
  • Active filters shown as removable chips below the filter bar
  • Client-side CSS data-* attribute filtering for instant feedback (no server round-trip)
  • Server-side TodoCard::Filter namespaced class as fallback for large datasets

Step 10 — Archive View

  • Toggle button "Show Archive" in board header
  • Archived cards shown in a flat list grouped by original column name
  • Each archived card shows: title, archived date, original column
  • "Unarchive" action restores card to its original column
  • _archive.html.erb partial

Step 11 — Navigation Integration

Add "Tasks" to all navigation surfaces:

Sidebar (_sidebar.html.erb) — Tools section:

<%= navbar_nav_item t("nav.tasks"), todo_board_path, icon: "tasks" %>

Footer nav More drawer (_footer_nav.html.erb) — Tools grid:

<%= more_drawer_item t("nav.tasks"), todo_board_path, icon: "tasks" %>

Desktop sidebar (_sidebar_links.html.erb):

<%= nav_link t("nav.tasks"), todo_board_path %>

Icon — Add "tasks" case to sidebar_icon in application_helper.rb: Kanban-style SVG (three columns with cards icon) using currentColor.

Step 12 — Gamification XP

Add to Gamification::XpAwarder:

CREATE_TODO_CARD_XP = 5
COMPLETE_TODO_CARD_XP = 10

# In award_tools_action xp_map:
create_todo_card: [CREATE_TODO_CARD_XP, "Created a task"],
complete_todo_card: [COMPLETE_TODO_CARD_XP, "Completed a task! ✅"]

Wire into:

  • TodoCardsController#createaward_tools_action(:create_todo_card)
  • TodoCardsController#archiveaward_tools_action(:complete_todo_card)

Step 13 — i18n

Add to config/locales/en.yml:

nav:
  tasks: Tasks

todo_board:
  title: Tasks
  subtitle: Organize your tasks with a Kanban board
  empty:
    title: No columns yet
    description: Create your first column to get started
    cta: Create Column
  columns:
    new: New Column
    edit: Edit Column
    delete: Delete Column
    delete_confirm: Delete this column and all its cards?
    default_names:
      todo: To Do
      in_progress: In Progress
      done: Done
    created: Column created!
    updated: Column updated!
    destroyed: Column deleted!
  cards:
    new: New Card
    edit: Edit Card
    delete: Delete Card
    delete_confirm: Delete this card?
    created: Card created!
    updated: Card updated!
    destroyed: Card deleted!
    archived: Card archived!
    unarchived: Card restored!
    moved: Card moved!
    no_cards: No cards in this column
    add_card: "+ Add Card"
  priority:
    low: Low
    medium: Medium
    high: High
    urgent: Urgent
  labels:
    title: Labels
    new: New Label
    manage: Manage Labels
    add: Add Label
    no_labels: No labels yet
  checklists:
    title: Checklists
    new: Add Checklist
    add_item: Add Item
    progress: "%{checked} of %{total}"
  assignments:
    title: Assignees
    assign: Assign Member
    no_assignees: No assignees
  archive:
    title: Archived Cards
    show: Show Archive
    hide: Hide Archive
    empty: No archived cards
    restore: Restore
  filters:
    title: Filters
    search: Search cards...
    priority: Priority
    label: Label
    assignee: Assignee
    due_date: Due Date
    overdue: Overdue
    today: Due Today
    this_week: This Week
    no_date: No Date
    clear: Clear Filters
  fields:
    title: Title
    description: Description
    priority: Priority
    due_date: Due Date
    cover_color: Cover Color
    column: Column

Phase 3: Responsive & Mobile

Step 14 — Mobile Board Layout

  • < 768px: Columns display as horizontal-swipeable strip with snap-x scroll
  • Each column takes ~85% viewport width for peek-ahead effect
  • Card detail modal goes full-screen on mobile
  • Touch drag-and-drop via SortableJS native touch support
  • Bottom sheet pattern for card action menus (archive, delete, move)
  • Column switcher tabs above the board for quick navigation

Files Summary

New Files to Create

Category Files
Migrations (2) create_todo_boards, create_todo_cards
Models (2) app/models/todo_board.rb, app/models/todo_card.rb
Namespaced Class (1) app/models/todo_card/filter.rb (Phase 2)
Controllers (2) app/controllers/organizations/todo_board_controller.rb, app/controllers/organizations/todo_cards_controller.rb
Policies (2) app/policies/todo_board_policy.rb, app/policies/todo_card_policy.rb
Stimulus (2) kanban_controller.js, todo_filter_controller.js
Views (~8) todo_board/show.html.erb, todo_board/_archive.html.erb, todo_board/_column.html.erb, todo_cards/show.html.erb, todo_cards/new.html.erb, todo_cards/edit.html.erb, todo_cards/_form.html.erb, todo_cards/_card.html.erb
Tests (~4) Model tests + controller tests + fixtures for todo_boards, todo_cards

Existing Files to Modify

File Change
config/routes.rb Add todo board + card routes
app/models/organization.rb Add has_one :todo_board, dependent: :destroy
app/models/membership.rb Add has_many :todo_cards, dependent: :destroy
app/views/shared/_sidebar.html.erb Add Tasks nav item to Tools section
app/views/shared/_sidebar_links.html.erb Add Tasks desktop link
app/views/shared/_footer_nav.html.erb Add Tasks to More drawer Tools grid
app/helpers/application_helper.rb Add "tasks" icon to sidebar_icon method
app/models/gamification/xp_awarder.rb Add todo XP constants + award actions
config/locales/en.yml Add todo_board section + nav.tasks key

What Went Into JSON (and Why)

Previously a Model Now JSON On Why It Works
TodoColumn TodoBoard.columns Columns are board config — rarely > 10. No need for individual queries. JSON array with id/name/color/position.
TodoLabel TodoBoard.labels Labels are board config — rarely > 20. Referenced by ID from cards. CRUD via board methods.
TodoCardLabel (join) TodoCard.label_ids Simple array of label IDs. No join table overhead. Toggle on/off with array operations.
TodoChecklist + TodoChecklistItem TodoCard.checklists Checklists are card-scoped. Nested JSON: [{title, items: [{name, checked}]}]. All mutations via card instance methods.
TodoAssignment (join) TodoCard.assignee_ids Simple array of membership IDs. No join table needed for a small set.

Trade-offs accepted:

  • Can't query "all cards with label X" efficiently via SQL → Filter in Ruby (dataset is small per org, typically < 500 cards)
  • No ActiveRecord validations on checklist items → Validated in card instance methods
  • No acts_as_list for columns → Manual position management in JSON (simpler for < 10 columns)

Verification Checklist

  • Unit tests: bin/rails test test/models/todo_board_test.rb test/models/todo_card_test.rb
  • Controller tests: bin/rails test test/controllers/organizations/todo_board_controller_test.rb test/controllers/organizations/todo_cards_controller_test.rb
  • Drag-and-drop: Drag card from "To Do" → "In Progress" → page reload → card persisted in new column
  • Column reorder: Drag columns left/right → order persists on reload
  • Mobile: 375px viewport → horizontal column scroll, tap to open card, touch drag works
  • Authorization: Non-member → Pundit returns 403 on board access
  • Family view: Toggle individual/family → card visibility changes correctly
  • Archive flow: Archive card → disappears → open archive → visible → unarchive → returns to board
  • Filters: Cards with varied priorities/labels → apply filters → correct cards shown/hidden
  • Checklists: Add checklist → add items → toggle items → progress bar updates on board card
  • Labels: Create label on board → assign to card → label dot appears on card → filter by label works
  • Full CI: bin/ci — rubocop, brakeman, all tests green

Key Decisions

Decision Rationale
2 models only (TodoBoard + TodoCard) Columns, labels, checklists, assignments all fit naturally as JSON. Eliminates 5 models, 5 migrations, 4 controllers, 3 policies.
TodoBoard holds columns + labels as JSON Board config changes rarely. JSON avoids join tables and extra controllers. Instance methods provide clean API.
TodoCard holds checklists + label_ids + assignee_ids as JSON All card sub-features are card-scoped. JSON keeps mutations local to one model.
SecureRandom.hex(4) for JSON element IDs Unique enough for per-board/per-card scope. Avoids auto-increment complexity in JSON.
column_id as string field on TodoCard References board JSON column ID. Enables scope :in_column queries.
SortableJS directly (not @stimulus-components/sortable) Existing library doesn't support multi-column groups; SortableJS already installed as dependency.
find_or_create_for pattern on TodoBoard Zero-config. Board auto-created with defaults on first visit.
resource :todo_board (singular) One board per org. Cleaner URL: /tasks instead of /tasks/:id.
Turbo modal for card detail Consistent with existing market_lists/notes edit pattern.
Client-side filtering first Instant feedback on small datasets; server-side TodoCard::Filter as fallback.

Future Considerations

  1. Real-time sync for family boards — Card moves broadcast via Turbo Streams (ActionCable) so family members see live updates. Use turbo_stream_from @organization, :kanban with existing Solid Cable infrastructure.

  2. Card activity log — "Card moved from X → Y" history. Could be a JSON field activity_log on TodoCard. Defer unless explicitly needed.

  3. Keyboard shortcutsn (new card), e (edit), arrow keys for navigation. Progressive enhancement via Stimulus in a future iteration.

  4. Card templates — Pre-defined card templates for common task types (bug report, feature request, etc.).

  5. Due date reminders — Noticed notifications for cards approaching due date. Leverage existing notification infrastructure.

  6. Board analytics — Cycle time, throughput, cards per column distribution. TodoCard::AnalyticsCalculator namespaced class.