Team Directory with Per-Member Task Backlog
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
A lightweight, iPhone-Reminders-style task system hung off a team directory. /team
shows a card grid of members (avatar, title, open-task count badge, a Tasks button);
/team/:id/tasks is that member's backlog: an inline "Add a task…" form, task cards
with a completion tickbox, priority dot, optional deadline and expandable details, a
Backlog / Completed tab toggle, a Manual / Priority sort toggle, and
drag-and-drop reordering that persists across reloads.
When to use: any "who's doing what" screen — team to-dos, per-client action items, per-project checklists. The pattern generalizes to any parent with an ordered list of checkable children. When not to: full project management (dependencies, statuses beyond done/not-done, multiple assignees per task) — that wants real state machines, not a boolean.
The example uses
Useras the team member ("assignee") and a newTaskmodel. Swap the parent for any model — see How to adapt at the bottom.
The 80/20 in one breath
- A
taskstable:user_id(assignee),description,priority,completed,completed_at,position(manual order), optionaldeadline+details. - Model scopes that encode the two orderings —
manual_order(byposition) andby_priority(a SQLCASEon the priority string) — plus apersist_order!class method that rewrites positions from an id array. - Two controllers:
TeamController#tasksrenders one member's list (reading?tab=and?sort=params);TasksControllerhandles create / toggle / destroy / reorder. - One view with the tab + sort toggles as plain GET links (server-side state, no JS),
the add form, and the card list. The tickbox is a one-field
form_withthat flipscompleted. - One Stimulus controller wrapping SortableJS: on drop, collect the
data-task-ids top-to-bottom andfetchPOST them to/tasks/reorder.
Steps 1–4 are a complete task system with no JavaScript at all. Step 5 adds drag-and-drop.
Layer 1 — Model & SQL
The migration (one migration; the source project shipped it as two):
# db/migrate/XXXX_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[7.2]
def change
create_table :tasks do |t|
# Who the task is assigned to (whose backlog it shows up in).
t.references :user, null: false, foreign_key: true
# Who created it (optional if your app has open auth).
t.references :creator, foreign_key: { to_table: :users }
t.text :description, null: false
t.string :priority, null: false, default: "medium"
t.boolean :completed, null: false, default: false
t.datetime :completed_at
t.integer :position # manual drag-and-drop order (per assignee)
t.datetime :deadline # optional due date
t.text :details # optional longer notes, revealed on expand
t.timestamps
end
add_index :tasks, %i[user_id completed]
add_index :tasks, %i[user_id position]
end
end
The model — the ordering scopes and the reorder writer are the heart of the pattern:
# app/models/task.rb
class Task < ApplicationRecord
PRIORITIES = %w[low medium high].freeze
belongs_to :user # assignee
belongs_to :creator, class_name: "User", optional: true
validates :description, presence: true
validates :priority, inclusion: { in: PRIORITIES }
before_validation :default_priority
before_create :assign_position
before_save :stamp_completion
scope :incomplete, -> { where(completed: false) }
# Manual (drag-and-drop) order: by persisted position; newest first as a
# tiebreaker for any rows without a position yet.
scope :manual_order, -> { order(Arel.sql("position ASC NULLS LAST, created_at DESC")) }
# Priority order: high → medium → low, then manual position within a priority.
scope :by_priority, lambda {
order(
Arel.sql("CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END ASC"),
Arel.sql("position ASC NULLS LAST")
)
}
# Finished items, most-recently-completed first.
scope :done, -> { where(completed: true).order(Arel.sql("completed_at DESC NULLS LAST, updated_at DESC")) }
# Persist a new drag-and-drop order. `ordered_ids` is the full id list
# top-to-bottom; each row's position is rewritten to match. Ids outside
# `scope` are ignored so a stray/foreign id can't touch other rows.
def self.persist_order!(ordered_ids, scope: all)
ids = Array(ordered_ids).map(&:to_i)
owned = scope.where(id: ids).pluck(:id).to_set
transaction do
ids.select { |id| owned.include?(id) }.each_with_index do |id, index|
where(id: id).update_all(position: index + 1)
end
end
end
private
def default_priority
self.priority = "medium" if priority.blank?
end
# Append new tasks to the bottom of the assignee's list.
def assign_position
return if position.present?
self.position = (user.assigned_tasks.maximum(:position) || 0) + 1
end
def stamp_completion
return unless completed_changed?
self.completed_at = completed? ? Time.current : nil
end
end
And the assignee side (plus a display-name fallback the views rely on):
# app/models/user.rb (additions)
has_many :assigned_tasks, class_name: "Task", foreign_key: :user_id, dependent: :destroy
scope :team, -> { order(Arel.sql("full_name IS NULL, full_name ASC, email ASC")) }
def display_name
full_name.presence || email.split("@").first.titleize
end
Layer 2 — Routes & Controllers
# config/routes.rb
resources :team, only: [:index, :show], controller: "team" do
member { get :tasks } # /team/:id/tasks — one member's backlog
end
resources :tasks, only: [:create, :update, :destroy] do
collection { post :reorder } # persist drag-and-drop ordering
end
TeamController owns the read side. Tab and sort are URL params, whitelisted to known
values — the page state is shareable and back-button friendly, and no client state
machinery is needed:
# app/controllers/team_controller.rb
class TeamController < ApplicationController
def index
@members = User.team.includes(:assigned_tasks)
end
# A member's task backlog. ?tab=completed switches lists;
# ?sort=priority orders high→low instead of the manual drag order.
def tasks
@member = User.find(params[:id])
@tab = params[:tab] == "completed" ? "completed" : "backlog"
@sort = params[:sort] == "priority" ? "priority" : "manual"
if @tab == "completed"
@tasks = @member.assigned_tasks.done
else
incomplete = @member.assigned_tasks.incomplete
@tasks = @sort == "priority" ? incomplete.by_priority : incomplete.manual_order
end
@open_count = @member.assigned_tasks.incomplete.count
@done_count = @member.assigned_tasks.done.count
end
end
TasksController owns the write side. Note redirect_back on update/destroy — it
preserves whatever ?tab=/?sort= the user was viewing:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
before_action :set_task, only: %i[update destroy]
def create
@member = User.find(params.dig(:task, :user_id))
@task = @member.assigned_tasks.new(create_params)
@task.creator = current_user if respond_to?(:current_user) && current_user
@task.save
redirect_to tasks_team_path(@member)
end
def update
@task.update(update_params)
redirect_back fallback_location: tasks_team_path(@task.user)
end
def destroy
member = @task.user
@task.destroy
redirect_back fallback_location: tasks_team_path(member)
end
# POST /tasks/reorder — persist a drag-and-drop reordering.
# Body: { ids: [3, 1, 2] } top-to-bottom. Responds :ok for the JS fetch.
def reorder
Task.persist_order!(params[:ids])
head :ok
end
private
def set_task
@task = Task.find(params[:id])
end
def create_params
params.require(:task).permit(:description, :priority, :deadline, :details)
end
# The tickbox submits only :completed; the add form submits the rest.
def update_params
params.require(:task).permit(:description, :priority, :completed, :deadline, :details)
end
end
Layer 3 — The Views
The directory (/team) — a card grid; each card carries a Tasks button with an
open-count badge:
<%# app/views/team/index.html.erb %>
<h1 class="text-2xl font-bold text-slate-900 mb-6">Team</h1>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<% @members.each do |member| %>
<% open_tasks = member.assigned_tasks.count { |t| !t.completed? } %>
<div class="bg-white rounded-xl border border-slate-200 p-5 flex flex-col hover:border-indigo-300 hover:shadow-sm transition">
<%= link_to team_path(member), class: "flex items-center gap-4" do %>
<span class="h-14 w-14 rounded-full bg-indigo-600 text-white flex items-center justify-center font-semibold"><%= member.display_name.first %></span>
<div>
<div class="font-semibold text-slate-900"><%= member.display_name %></div>
<div class="text-sm text-slate-500"><%= member.title.presence || "Team member" %></div>
</div>
<% end %>
<div class="mt-4 pt-4 border-t border-slate-100 flex justify-end">
<%= link_to tasks_team_path(member),
class: "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-slate-100 text-slate-700 hover:bg-indigo-50 hover:text-indigo-700 transition" do %>
<i class="fa-solid fa-list-check" aria-hidden="true"></i> Tasks
<% if open_tasks.positive? %>
<span class="ml-0.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-indigo-600 text-white text-xs font-semibold"><%= open_tasks %></span>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
The backlog (/team/:id/tasks) — trimmed to the load-bearing parts; every UI state
(tab, sort) is just a link that sets a param:
<%# app/views/team/tasks.html.erb %>
<div class="max-w-2xl mx-auto">
<%= link_to "← Team", team_index_path, class: "text-sm text-slate-500 hover:text-slate-800" %>
<h1 class="text-2xl font-bold text-slate-900 mt-3 mb-5"><%= @member.display_name %> · Tasks</h1>
<%# Backlog / Completed tab toggle — plain GET links, server decides %>
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
<div class="inline-flex p-1 bg-slate-100 rounded-xl text-sm font-medium">
<% tab_base = "px-4 py-1.5 rounded-lg transition" %>
<%= link_to tasks_team_path(@member, tab: "backlog"),
class: "#{tab_base} #{@tab == 'backlog' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}" do %>
Backlog <span class="text-slate-400">(<%= @open_count %>)</span>
<% end %>
<%= link_to tasks_team_path(@member, tab: "completed"),
class: "#{tab_base} #{@tab == 'completed' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}" do %>
Completed <span class="text-slate-400">(<%= @done_count %>)</span>
<% end %>
</div>
<%# Sort toggle — backlog only %>
<% if @tab == "backlog" %>
<div class="inline-flex items-center gap-2 text-sm">
<span class="text-slate-400">Sort</span>
<div class="inline-flex p-1 bg-slate-100 rounded-xl font-medium">
<% sort_base = "px-3 py-1.5 rounded-lg transition" %>
<%= link_to "Manual", tasks_team_path(@member, sort: "manual"),
class: "#{sort_base} #{@sort == 'manual' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'}" %>
<%= link_to "Priority", tasks_team_path(@member, sort: "priority"),
class: "#{sort_base} #{@sort == 'priority' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'}" %>
</div>
</div>
<% end %>
</div>
<%# Add form — backlog only %>
<% if @tab == "backlog" %>
<%= form_with model: Task.new, url: tasks_path, class: "bg-white rounded-xl border border-slate-200 p-3 mb-5" do |f| %>
<%= f.hidden_field :user_id, value: @member.id %>
<div class="flex flex-col sm:flex-row gap-2">
<%= f.text_field :description, required: true, placeholder: "Add a task…",
class: "flex-1 rounded-lg border border-slate-300 px-3 py-2.5 text-sm focus:ring-2 focus:ring-indigo-400" %>
<div class="flex gap-2">
<%= f.select :priority, Task::PRIORITIES.map { |p| [p.capitalize, p] }, { selected: "medium" },
class: "rounded-lg border border-slate-300 px-3 py-2.5 text-sm" %>
<%= f.submit "Add", class: "px-4 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 cursor-pointer" %>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2 mt-2">
<label class="flex items-center gap-2 text-xs text-slate-500 shrink-0">
Deadline <%= f.datetime_local_field :deadline, class: "rounded-lg border border-slate-300 px-2 py-1.5 text-sm" %>
</label>
<%= f.text_area :details, rows: 2, placeholder: "Details (optional)…",
class: "flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm" %>
</div>
<% end %>
<% end %>
<%# Task list — draggable only in Backlog + Manual mode %>
<% draggable = @tab == "backlog" && @sort == "manual" %>
<ul class="space-y-2"
<% if draggable %>data-controller="sortable" data-sortable-url-value="<%= reorder_tasks_path %>"<% end %>>
<% @tasks.each do |task| %>
<% dot = { "high" => "bg-red-500", "medium" => "bg-amber-400", "low" => "bg-emerald-400" }[task.priority] || "bg-slate-300" %>
<li class="relative bg-white rounded-xl border border-slate-200 p-4 flex items-start gap-3" data-task-id="<%= task.id %>">
<%# Drag handle — the ONLY grabbable zone (see Gotchas) %>
<% if draggable %>
<span data-sortable-handle class="shrink-0 pt-1 cursor-grab text-slate-300 hover:text-slate-500" title="Drag to reorder" aria-hidden="true">
<i class="fa-solid fa-grip-vertical text-xs"></i>
</span>
<% end %>
<%# Tickbox: a one-field form that flips `completed` %>
<%= form_with model: task, class: "shrink-0 pt-0.5" do |f| %>
<%= f.hidden_field :completed, value: task.completed? ? "0" : "1" %>
<button type="submit" aria-label="<%= task.completed? ? 'Mark as not done' : 'Mark as done' %>"
class="h-6 w-6 rounded-full border-2 flex items-center justify-center transition
<%= task.completed? ? 'bg-indigo-600 border-indigo-600 text-white' : 'border-slate-300 text-transparent hover:border-indigo-400' %>">
<i class="fa-solid fa-check text-[11px]" aria-hidden="true"></i>
</button>
<% end %>
<%# Description; native <details> gives expand/collapse with zero JS %>
<div class="min-w-0 flex-1 pr-5">
<% if task.details.present? %>
<details class="group">
<summary class="list-none cursor-pointer text-sm text-slate-900 break-words flex items-center gap-1.5 <%= 'line-through text-slate-400' if task.completed? %>">
<i class="fa-solid fa-chevron-right text-[9px] text-slate-300 transition group-open:rotate-90" aria-hidden="true"></i>
<span><%= task.description %></span>
</summary>
<p class="text-sm text-slate-600 whitespace-pre-line mt-2 ml-4"><%= task.details %></p>
</details>
<% else %>
<p class="text-sm text-slate-900 break-words <%= 'line-through text-slate-400' if task.completed? %>"><%= task.description %></p>
<% end %>
<p class="text-xs text-slate-400 mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
<span class="capitalize"><%= task.priority %></span>
<span>· Created <%= task.created_at.strftime("%b %-d, %Y") %></span>
<% if task.deadline.present? %><span class="text-amber-600">· Due <%= task.deadline.strftime("%b %-d, %Y") %></span><% end %>
<% if task.completed? && task.completed_at.present? %><span class="text-emerald-600">· Completed <%= task.completed_at.strftime("%b %-d, %Y") %></span><% end %>
</p>
</div>
<%# Priority dot (top-right) + delete (bottom-right) %>
<span class="absolute top-3 right-3 h-2.5 w-2.5 rounded-full <%= dot %>" title="<%= task.priority.capitalize %> priority"></span>
<%= button_to task_path(task), method: :delete,
class: "absolute bottom-3 right-3 text-slate-300 hover:text-red-500 text-xs",
form: { data: { turbo_confirm: "Delete this task?" } } do %>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<% end %>
</li>
<% end %>
</ul>
<% if @tasks.empty? %>
<div class="bg-white rounded-xl border border-slate-200 px-5 py-12 text-center">
<p class="text-slate-600 font-medium"><%= @tab == "completed" ? "No completed tasks yet" : "No tasks in the backlog" %></p>
</div>
<% end %>
</div>
Layer 4 — Stimulus + SortableJS
First, vendor SortableJS (Leo boxes have no npm and can't add gems). Download the ESM
build into app/javascript/ (see Gotchas for why not vendor/javascript/):
# run inside the Rails container (docker compose exec llamapress bash)
curl -sL "https://ga.jspm.io/npm:sortablejs@1.15.6/modular/sortable.esm.js" \
-o app/javascript/sortablejs.js
Pin it:
# config/importmap.rb (add this line)
pin "sortablejs", to: "sortablejs.js", preload: true
The Stimulus controller — SortableJS does the dragging; on drop we read the DOM's
current data-task-id order and POST it:
// app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
// Drag-and-drop reordering for the task backlog.
// Usage on the <ul>:
// data-controller="sortable"
// data-sortable-url-value="<%= reorder_tasks_path %>"
// Each <li> carries data-task-id. On drop we POST the new top-to-bottom id
// order so it persists across reloads.
export default class extends Controller {
static values = { url: String }
connect() {
this.sortable = Sortable.create(this.element, {
animation: 150,
handle: "[data-sortable-handle]",
ghostClass: "opacity-40",
onEnd: () => this.persist(),
})
}
disconnect() {
if (this.sortable) this.sortable.destroy()
}
persist() {
const ids = Array.from(this.element.querySelectorAll("[data-task-id]"))
.map((el) => el.dataset.taskId)
fetch(this.urlValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this.csrfToken,
},
body: JSON.stringify({ ids }),
})
}
get csrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]')
return meta ? meta.content : ""
}
}
Gotchas (the hard-won stuff)
- Put the vendored
sortablejs.jsinapp/javascript/, NOTvendor/javascript/. On Leo boxesapp/javascriptis part of the bind-mounted overlay and survives container recreates and image updates;vendor/lives only inside the container, so a file dropped there silently vanishes on the next update/recreate and every drag interaction breaks with a 404 on the module. (The source project learned this the fragile way.) The importmap resolvesto: "sortablejs.js"from either location, so the pin line is identical. config/importmap.rbis a single-file bind mount — verify the edit landed in the container. Atomic-write editors swap the host file's inode and detach it from the mount, so the running app keeps reading the old file and your pin silently no-ops. After editing, checkdocker compose exec -T llamapress grep sortable /rails/config/importmap.rb; if it's missing, rewrite in place:docker compose exec -T llamapress sh -c 'cat > /rails/config/importmap.rb' < rails/config/importmap.rb.- Only offer dragging in Backlog + Manual mode (
draggable = @tab == "backlog" && @sort == "manual"). Dragging while sorted by priority would persist an order the user isn't actually seeing — confusing writes topositionthat only surface later in Manual mode. - Use a drag
handle, not whole-card dragging. The card contains a tickbox button, a<details>toggle, a delete button, and selectable text — whole-card drag hijacks all of them.handle: "[data-sortable-handle]"scopes grabbing to the grip icon. position ASC NULLS LASTeverywhere you order by position. Rows created before the column existed (or via other code paths) haveNULLpositions; plainASCputs NULLs first in Postgres, shoving position-less rows to the top. If you addpositionto an existing table, backfill per assignee:UPDATE tasks SET position = seq.rn FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn FROM tasks) seq WHERE tasks.id = seq.id.- Scope
persist_order!writes to ids you actually own. The method plucks the ids that exist withinscopeand ignores the rest, so a tampered POST body can't renumber another member's (or another tenant's) rows. If you have auth, tighten the call site:Task.persist_order!(params[:ids], scope: current_user.visible_tasks). - The tickbox is a form, not JS.
f.hidden_field :completed, value: task.completed? ? "0" : "1"plus a submit button = toggle with zero JavaScript, andredirect_backkeeps the user on their current tab/sort.stamp_completionsets/clearscompleted_atin one place (the model), so the "Completed" list can order by it reliably. - The reorder endpoint must return
head :ok. The Stimulusfetchexpects a bare 2xx; rendering a redirect there makes fetch follow it and pointlessly render HTML. Also note the CSRF token header — withoutX-CSRF-TokenRails rejects the POST with a 422 (InvalidAuthenticityToken). - Raw SQL fragments in
orderneedArel.sql(...)(NULLS LAST, the priorityCASE). Passing the string directly raises Rails' unsafe-SQL error. - Priority sort ranks in SQL, not Ruby. The
CASE priority WHEN 'high' THEN 0 …scope keeps ordering in the database (composable with pagination/limits) instead of loading and sorting in memory. - Font Awesome icons (
fa-grip-vertical,fa-check,fa-trash,fa-chevron-right) are used throughout — available on most Leo boxes but not guaranteed. Unicode fallbacks work fine:⋮⋮for the grip,✓for the check,▸for the chevron. <details>/<summary>gives expandable notes for free — no Stimulus, works inside the draggable list, andgroup-open:rotate-90on the chevron is pure Tailwind.
Files this pattern touches
db/migrate/XXXX_create_tasks.rb
app/models/task.rb
app/models/user.rb (association + team scope)
app/controllers/team_controller.rb
app/controllers/tasks_controller.rb
app/views/team/index.html.erb
app/views/team/tasks.html.erb
app/javascript/controllers/sortable_controller.js
app/javascript/sortablejs.js (vendored ESM build)
config/importmap.rb (one pin line)
config/routes.rb
How to adapt to your schema
- Different parent than User: the pattern is parent-has-ordered-checkable-children.
Swap
user_idforproject_id/client_id/etc., renameassigned_tasks, and change the nested route (resources :projects do member { get :tasks } end). Everything else is mechanical. - No manual ordering needed? Drop
position,persist_order!, the reorder route, the Stimulus controller, and the SortableJS vendoring — you lose one file of JS and the whole feature still works with priority/created-at ordering. - No deadline/details? Drop those two columns and their form fields/card fragments; the migration, tickbox, tabs, and sort are independent of them.
- More priorities or statuses: extend
PRIORITIESand theCASEscope together (they must agree), and add a dot color to the view'sdotmap. - Auth: the source app ran with open auth. With Devise, set
@task.creator = current_userunconditionally, addauthenticate_user!, and scopepersist_order!as shown in Gotchas. - The sortable Stimulus controller is reusable as-is for any list — it only needs
data-task-idon children and a URL that accepts{ ids: [...] }.