Client-side sort toggle with Alpine.js and localStorage
Server always renders overdue tasks by urgency; Alpine.js reorders
the DOM when the user picks a different sort. Preference stored in
localStorage instead of a cookie — no round-trip needed.

Assisted-by: Claude Opus 4.6 via Claude Code
change wrrvpkpyountkosywozyrsnzvskzlktq
commit 8767b430f861a9d888f904347328b0d1e8f50a10
author Alpha Chen <alpha@kejadlen.dev>
date
parent zqolvquq
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index 6a30733..a28bf9e 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -6,30 +6,32 @@ require_relative "layout"
 
 module Views
   class Home < Phlex::HTML
-    def initialize(current_user:, overdue:, upcoming:, sort:)
+    def initialize(current_user:, overdue:, upcoming:)
       @current_user = current_user
       @overdue = overdue
       @upcoming = upcoming
-      @sort = sort
     end
 
     def view_template
       render Layout.new(current_user: @current_user) do
         div(class: "home") do
-          div(class: "column") do
+          div(class: "column", "x-data": "sortable") do
             div(class: "column-header") do
               h2 { "Overdue" }
               nav(class: "sort-toggle") do
-                if @sort == :urgency
-                  a(href: "/?sort=date") { "Date" }
-                  span(class: "sort-active") { "Urgency" }
-                else
-                  span(class: "sort-active") { "Date" }
-                  a(href: "/?sort=urgency") { "Urgency" }
-                end
+                button(
+                  "x-on:click": "sort = 'date'",
+                  "x-bind:class": "sort === 'date' && 'sort-active'",
+                  "x-bind:disabled": "sort === 'date'"
+                ) { "Date" }
+                button(
+                  "x-on:click": "sort = 'urgency'",
+                  "x-bind:class": "sort === 'urgency' && 'sort-active'",
+                  "x-bind:disabled": "sort === 'urgency'"
+                ) { "Urgency" }
               end
             end
-            task_list(@overdue, empty: "Nothing overdue.")
+            task_list(@overdue, empty: "Nothing overdue.", sortable: true)
           end
 
           div(class: "column") do
@@ -79,13 +81,18 @@ module Views
 
     private
 
-    def task_list(tasks, empty:)
+    def task_list(tasks, empty:, sortable: false)
       if tasks.empty?
         p(class: "empty") { empty }
       else
         ul(class: "task-list") do
           tasks.each do |task|
-            li(class: "task-item") { task_card(task) }
+            attrs = { class: "task-item" }
+            if sortable
+              attrs[:"data-urgency"] = format("%.4f", task.urgency)
+              attrs[:"data-due-date"] = task[:due_date].to_s
+            end
+            li(**attrs) { task_card(task) }
           end
         end
       end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 2c3fa7f..c8342f5 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -19,6 +19,8 @@ module Views
           link(rel: "stylesheet", href: "/css/reset.css")
           link(rel: "stylesheet", href: "/css/utopia.css")
           link(rel: "stylesheet", href: "/css/app.css")
+          script(src: "/js/app.js", defer: true)
+          script(src: "https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js", defer: true)
         end
         body do
           header(class: "site-header") do
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index cb8fc51..e634a27 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -21,15 +21,13 @@ class Web < Roda
     r.halt 403 unless current_user
 
     r.root do
-      sort = r.params["sort"] == "date" ? :date : :urgency
       all_tasks = Task.active.for_user(current_user).all
       overdue, upcoming = all_tasks.partition { |t| t.urgency > 0 }
 
-      order = sort == :urgency ? ->(t) { -t.urgency } : ->(t) { t[:due_date] }
-      overdue.sort_by!(&order)
+      overdue.sort_by! { |t| -t.urgency }
       upcoming.sort_by! { |t| t[:due_date] }
 
-      Views::Home.new(current_user:, overdue:, upcoming:, sort:).call
+      Views::Home.new(current_user:, overdue:, upcoming:).call
     end
 
     r.on "tasks", Integer do |task_id|
diff --git a/public/css/app.css b/public/css/app.css
index f28a1c2..1ff2132 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -131,15 +131,22 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   font-size: var(--step--1);
 }
 
-.sort-toggle a {
+.sort-toggle button {
+  all: unset;
   color: #999;
-  text-decoration: none;
+  cursor: pointer;
 }
 
-.sort-toggle a:hover {
+.sort-toggle button:hover {
   color: #1a1a1a;
 }
 
+.sort-toggle button.sort-active {
+  color: #1a1a1a;
+  font-weight: 600;
+  cursor: default;
+}
+
 .sort-active {
   font-weight: 600;
 }
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..1cb00c5
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,28 @@
+document.addEventListener("alpine:init", () => {
+  Alpine.data("sortable", () => ({
+    sort: localStorage.getItem("sort") || "urgency",
+
+    init() {
+      this.$watch("sort", (value) => {
+        localStorage.setItem("sort", value)
+        this.reorder()
+      })
+      if (this.sort !== "urgency") {
+        this.$nextTick(() => this.reorder())
+      }
+    },
+
+    reorder() {
+      const ul = this.$el.querySelector(".task-list")
+      if (!ul) return
+
+      const items = [...ul.querySelectorAll("li[data-urgency]")]
+      if (this.sort === "urgency") {
+        items.sort((a, b) => parseFloat(b.dataset.urgency) - parseFloat(a.dataset.urgency))
+      } else {
+        items.sort((a, b) => a.dataset.dueDate.localeCompare(b.dataset.dueDate))
+      }
+      items.forEach((li) => ul.appendChild(li))
+    },
+  }))
+})