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: :destroyMembership: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:
TodoBoardPolicy—show?,update?(column/label management) requiremembership.present?TodoCardPolicy— all CRUD +archive?,unarchive?,move?requiremembership.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, handlesview_modefor family orgsadd_column/update_column/remove_column/reorder_columns— Turbo Stream responsesadd_label/update_label/remove_label— Turbo Stream responses
TodoCardsController — Card CRUD + all card actions:
show— card detail in Turbo Frame modalnew/create— quick create modal (title + column)edit/update— full edit modaldestroy— with confirmationarchive/unarchive— toggle archived statemove— updatescolumn_id+position(called by Kanban Stimulus controller)toggle_label/toggle_assignee— Turbo Stream updatesadd_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 onEndcallback: sends POST to/todo_cards/:id/movewith{ 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-autofor horizontal column scroll - Dark theme:
bg-[#0d1117]base, columnsbg-[#161b22]withborder-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 :modalfor 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::Filternamespaced 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.erbpartial
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#create→award_tools_action(:create_todo_card)TodoCardsController#archive→award_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-xscroll - 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_listfor 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
-
Real-time sync for family boards — Card moves broadcast via Turbo Streams (ActionCable) so family members see live updates. Use
turbo_stream_from @organization, :kanbanwith existing Solid Cable infrastructure. -
Card activity log — "Card moved from X → Y" history. Could be a JSON field
activity_logon TodoCard. Defer unless explicitly needed. -
Keyboard shortcuts —
n(new card),e(edit), arrow keys for navigation. Progressive enhancement via Stimulus in a future iteration. -
Card templates — Pre-defined card templates for common task types (bug report, feature request, etc.).
-
Due date reminders — Noticed notifications for cards approaching due date. Leverage existing notification infrastructure.
-
Board analytics — Cycle time, throughput, cards per column distribution.
TodoCard::AnalyticsCalculatornamespaced class.