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
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))
+ },
+ }))
+})