Team Directory with Per-Member Task Backlog | LlamaPress Cookbook
LlamaPress
Cookbook
Data Stable

Team Directory with Per-Member Task Backlog

A /team directory of member cards, each linking to a per-member task backlog — Backlog/Completed tabs, priority sort, an inline add form, one-click completion tickboxes, and drag-and-drop reordering persisted via SortableJS + one Stimulus controller.

Proven on duke-homes.leo.llamapress.ai model · sql · controller · view · stimulus_js
Live demo — try it Open full screen

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 User as the team member ("assignee") and a new Task model. Swap the parent for any model — see How to adapt at the bottom.


The 80/20 in one breath

  1. A tasks table: user_id (assignee), description, priority, completed, completed_at, position (manual order), optional deadline + details.
  2. Model scopes that encode the two orderings — manual_order (by position) and by_priority (a SQL CASE on the priority string) — plus a persist_order! class method that rewrites positions from an id array.
  3. Two controllers: TeamController#tasks renders one member's list (reading ?tab= and ?sort= params); TasksController handles create / toggle / destroy / reorder.
  4. 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_with that flips completed.
  5. One Stimulus controller wrapping SortableJS: on drop, collect the data-task-ids top-to-bottom and fetch POST 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.js in app/javascript/, NOT vendor/javascript/. On Leo boxes app/javascript is 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 resolves to: "sortablejs.js" from either location, so the pin line is identical.
  • config/importmap.rb is 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, check docker 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 to position that 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 LAST everywhere you order by position. Rows created before the column existed (or via other code paths) have NULL positions; plain ASC puts NULLs first in Postgres, shoving position-less rows to the top. If you add position to 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 within scope and 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, and redirect_back keeps the user on their current tab/sort. stamp_completion sets/clears completed_at in one place (the model), so the "Completed" list can order by it reliably.
  • The reorder endpoint must return head :ok. The Stimulus fetch expects a bare 2xx; rendering a redirect there makes fetch follow it and pointlessly render HTML. Also note the CSRF token header — without X-CSRF-Token Rails rejects the POST with a 422 (InvalidAuthenticityToken).
  • Raw SQL fragments in order need Arel.sql(...) (NULLS LAST, the priority CASE). 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, and group-open:rotate-90 on 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

  1. Different parent than User: the pattern is parent-has-ordered-checkable-children. Swap user_id for project_id/client_id/etc., rename assigned_tasks, and change the nested route (resources :projects do member { get :tasks } end). Everything else is mechanical.
  2. 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.
  3. No deadline/details? Drop those two columns and their form fields/card fragments; the migration, tickbox, tabs, and sort are independent of them.
  4. More priorities or statuses: extend PRIORITIES and the CASE scope together (they must agree), and add a dot color to the view's dot map.
  5. Auth: the source app ran with open auth. With Devise, set @task.creator = current_user unconditionally, add authenticate_user!, and scope persist_order! as shown in Gotchas.
  6. The sortable Stimulus controller is reusable as-is for any list — it only needs data-task-id on children and a URL that accepts { ids: [...] }.

Related