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