---
title: Team Directory with Per-Member Task Backlog
slug: team-task-backlog
category: Data
summary: 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.
tags: [stimulus, sortablejs, drag-and-drop, tasks, tabs, hotwire, turbo]
status: stable
visibility: public
source_project: duke-homes.leo.llamapress.ai
layers: [model, sql, controller, view, stimulus_js]
related:
  - title: High-Quality Inline-Editable Table
    url: /cookbook/inline-editable-table
    summary: The sibling pattern for editing flat rows in place — this guide's card list is the better fit when rows are tasks with a lifecycle.
  - title: Async Action Feedback
    url: /cookbook/async-action-feedback
    summary: Same fetch-with-CSRF pattern this guide's reorder POST uses, with fuller UX feedback treatment.
---

# 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-id`s
   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):

```ruby
# 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:

```ruby
# 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):

```ruby
# 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

```ruby
# 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:

```ruby
# 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:

```ruby
# 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:

```erb
<%# 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:

```erb
<%# 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/`):

```bash
# 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:

```ruby
# 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:

```javascript
// 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: [...] }`.
