Three-column layout with checkbox and Miniflux-inspired styling
Single task list was not information-dense enough. Splitting into
overdue/upcoming columns lets you see both at a glance. Dotted
separators and no card borders follow Miniflux's content-first
approach. Completion button moved to a checkbox on the left so its
purpose is immediately recognizable.

Assisted-by: Claude Opus 4.6 via Claude Code
change zqolvquqmmnlpwnyookywnwvqwqltmqy
commit 150305fc5b52101f0a2aaa07e02e7bd33968b15c
author Alpha Chen <alpha@kejadlen.dev>
date
parent rulpsuwx
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index 1b405d8..6a30733 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -6,58 +6,19 @@ require_relative "layout"
 
 module Views
   class Home < Phlex::HTML
-    def initialize(current_user:, tasks:, sort:)
+    def initialize(current_user:, overdue:, upcoming:, sort:)
       @current_user = current_user
-      @tasks = tasks
+      @overdue = overdue
+      @upcoming = upcoming
       @sort = sort
     end
 
     def view_template
       render Layout.new(current_user: @current_user) do
         div(class: "home") do
-          div(class: "panel") do
-            details(class: "new-series") do
-              summary { "New Series" }
-
-              form(method: "post", action: "/series") do
-                div(class: "field") do
-                  label(for: "note") { "Note" }
-                  textarea(id: "note", name: "note", rows: 3, required: true)
-                end
-
-                div(class: "field") do
-                  label(for: "interval_count") { "Repeat every" }
-                  div(class: "interval") do
-                    input(
-                      type: "number", id: "interval_count", name: "interval_count",
-                      min: 1, value: 1, required: true
-                    )
-                    select(id: "interval_unit", name: "interval_unit", required: true) do
-                      option(value: "day") { "day(s)" }
-                      option(value: "week") { "week(s)" }
-                      option(value: "month") { "month(s)" }
-                      option(value: "quarter") { "quarter(s)" }
-                      option(value: "year") { "year(s)" }
-                    end
-                  end
-                end
-
-                div(class: "field") do
-                  label(for: "first_due_date") { "First due date" }
-                  input(
-                    type: "date", id: "first_due_date", name: "first_due_date",
-                    value: Date.today.to_s, required: true
-                  )
-                end
-
-                button(type: "submit") { "Create" }
-              end
-            end
-          end
-
-          div(class: "panel flow") do
-            div(class: "task-header") do
-              h2 { "Tasks" }
+          div(class: "column") do
+            div(class: "column-header") do
+              h2 { "Overdue" }
               nav(class: "sort-toggle") do
                 if @sort == :urgency
                   a(href: "/?sort=date") { "Date" }
@@ -68,17 +29,48 @@ module Views
                 end
               end
             end
+            task_list(@overdue, empty: "Nothing overdue.")
+          end
+
+          div(class: "column") do
+            h2 { "Upcoming" }
+            task_list(@upcoming, empty: "Nothing upcoming.")
+          end
 
-            if @tasks.empty?
-              p(class: "empty") { "No tasks yet." }
-            else
-              ul(class: "task-list") do
-                @tasks.each do |task|
-                  li(class: "task-item") do
-                    task_card(task)
+          div(class: "column column-aside") do
+            h2(class: "aside-heading") { "New Series" }
+            form(method: "post", action: "/series") do
+              div(class: "field") do
+                label(for: "note") { "Note" }
+                textarea(id: "note", name: "note", rows: 2, required: true)
+              end
+
+              div(class: "field") do
+                label(for: "interval_count") { "Repeat every" }
+                div(class: "interval") do
+                  input(
+                    type: "number", id: "interval_count", name: "interval_count",
+                    min: 1, value: 1, required: true
+                  )
+                  select(id: "interval_unit", name: "interval_unit", required: true) do
+                    option(value: "day") { "day(s)" }
+                    option(value: "week") { "week(s)" }
+                    option(value: "month") { "month(s)" }
+                    option(value: "quarter") { "quarter(s)" }
+                    option(value: "year") { "year(s)" }
                   end
                 end
               end
+
+              div(class: "field") do
+                label(for: "first_due_date") { "First due date" }
+                input(
+                  type: "date", id: "first_due_date", name: "first_due_date",
+                  value: Date.today.to_s, required: true
+                )
+              end
+
+              button(type: "submit") { "Create" }
             end
           end
         end
@@ -87,16 +79,31 @@ module Views
 
     private
 
+    def task_list(tasks, empty:)
+      if tasks.empty?
+        p(class: "empty") { empty }
+      else
+        ul(class: "task-list") do
+          tasks.each do |task|
+            li(class: "task-item") { task_card(task) }
+          end
+        end
+      end
+    end
+
     def task_card(task)
       name = task[:note].lines.first&.strip || task[:note]
       overdue = task[:due_date] < Date.today
 
       div(class: ["task-card", ("task-overdue" if overdue)]) do
+        form(method: "post", action: "/tasks/#{task[:id]}/complete", class: "complete-form") do
+          button(type: "submit", title: "Complete", **{ "aria-label": "Complete #{name}" }) { "✓" }
+        end
         div(class: "task-body") do
           span(class: "task-name") { name }
           div(class: "task-meta") do
             span(class: "task-due") do
-              plain overdue ? "Overdue — due #{task[:due_date]}" : "Due #{task[:due_date]}"
+              plain "Due #{task[:due_date]}"
             end
             span(class: "task-meta-sep") { "\u00B7" }
             span(class: "task-interval") do
@@ -110,9 +117,6 @@ module Views
             end
           end
         end
-        form(method: "post", action: "/tasks/#{task[:id]}/complete", class: "complete-form") do
-          button(type: "submit", title: "Complete", **{ "aria-label": "Complete #{name}" }) { "✓" }
-        end
       end
     end
   end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 8b4b409..cb8fc51 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -22,13 +22,14 @@ class Web < Roda
 
     r.root do
       sort = r.params["sort"] == "date" ? :date : :urgency
-      order = if sort == :urgency
-                ->(t) { [-t.urgency, t[:due_date]] }
-              else
-                ->(t) { t[:due_date] }
-              end
-      tasks = Task.active.for_user(current_user).all.sort_by(&order)
-      Views::Home.new(current_user:, tasks:, sort:).call
+      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)
+      upcoming.sort_by! { |t| t[:due_date] }
+
+      Views::Home.new(current_user:, overdue:, upcoming:, sort:).call
     end
 
     r.on "tasks", Integer do |task_id|
diff --git a/public/css/app.css b/public/css/app.css
index 46627cb..f28a1c2 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -21,10 +21,15 @@ body {
   font-size: var(--step-0);
   line-height: 1.5;
   color: #1a1a1a;
-  background: #fafafa;
+  background: #fff;
+}
+
+a {
+  color: inherit;
 }
 
 h1 { font-size: var(--step-2); }
+h2 { font-size: var(--step-0); font-weight: 600; }
 
 /* -------------------- */
 /* Site header */
@@ -35,7 +40,7 @@ h1 { font-size: var(--step-2); }
   justify-content: space-between;
   align-items: center;
   padding: var(--space-xs) var(--grid-gutter);
-  border-block-end: 1px solid #e0e0e0;
+  border-block-end: 1px dotted #ccc;
 }
 
 .site-name {
@@ -44,7 +49,7 @@ h1 { font-size: var(--step-2); }
 
 .user {
   font-size: var(--step--1);
-  color: #666;
+  color: #777;
 }
 
 /* -------------------- */
@@ -63,25 +68,63 @@ h1 { font-size: var(--step-2); }
 
 .home {
   display: grid;
-  gap: var(--space-s);
-  padding: var(--space-l) var(--grid-gutter);
-  max-width: 36rem;
+  grid-template-columns: 1fr 1fr auto;
+  gap: var(--space-xl);
+  padding: var(--space-m) var(--grid-gutter);
+  max-width: 72rem;
   margin-inline: auto;
 }
 
-.panel {
+.column {
+  min-width: 0;
 }
 
-/* -------------------- */
-/* Task list */
-/* -------------------- */
-
-.task-header {
+.column-header {
   display: flex;
   justify-content: space-between;
   align-items: baseline;
+  margin-block-end: var(--space-xs);
+  padding-block-end: var(--space-3xs);
+  border-block-end: 1px dotted #ccc;
+}
+
+.column h2 {
+  margin-block-end: var(--space-xs);
+  padding-block-end: var(--space-3xs);
+  border-block-end: 1px dotted #ccc;
+}
+
+.column-header h2 {
+  margin-block-end: 0;
+  padding-block-end: 0;
+  border-block-end: none;
+}
+
+.column-aside {
+  max-width: 16rem;
+}
+
+.aside-heading {
+  font-size: var(--step--1);
+  color: #999;
+}
+
+.column-aside form {
+  font-size: var(--step--1);
 }
 
+.column-aside label {
+  font-size: var(--step--2);
+}
+
+.column-aside button[type="submit"] {
+  font-size: var(--step--1);
+}
+
+/* -------------------- */
+/* Task list */
+/* -------------------- */
+
 .sort-toggle {
   display: flex;
   gap: var(--space-2xs);
@@ -89,7 +132,7 @@ h1 { font-size: var(--step-2); }
 }
 
 .sort-toggle a {
-  color: #888;
+  color: #999;
   text-decoration: none;
 }
 
@@ -104,51 +147,50 @@ h1 { font-size: var(--step-2); }
 .task-list {
   list-style: none;
   padding: 0;
-  display: grid;
-  gap: var(--space-s);
+}
+
+.task-item {
+  padding-block: var(--space-2xs);
+  border-block-end: 1px dotted #ddd;
 }
 
 .task-card {
   display: grid;
-  grid-template-columns: 1fr auto;
-  align-items: center;
-  gap: var(--space-s);
-  padding: var(--space-s);
-  border: 1px solid #e0e0e0;
-  border-radius: 6px;
-  background: #fff;
+  grid-template-columns: auto 1fr;
+  align-items: start;
+  gap: var(--space-2xs);
 }
 
 .task-body {
   display: flex;
   flex-direction: column;
-  gap: var(--space-3xs);
+  gap: 2px;
   min-width: 0;
 }
 
-.task-card.task-overdue {
-  border-color: #c0392b;
-  background: #fdf2f2;
-}
-
 .task-name {
   font-weight: 600;
+  font-size: var(--step--1);
+}
+
+.task-overdue .task-name {
+  color: #c0392b;
 }
 
 .task-meta {
   display: flex;
   align-items: baseline;
   gap: var(--space-3xs);
-  font-size: var(--step--1);
+  font-size: var(--step--2);
+  color: #777;
 }
 
 .task-due {
-  color: #666;
+  color: #777;
 }
 
 .task-overdue .task-due {
   color: #c0392b;
-  font-weight: 600;
 }
 
 .task-meta-sep {
@@ -156,7 +198,7 @@ h1 { font-size: var(--step-2); }
 }
 
 .task-interval {
-  color: #888;
+  color: #999;
 }
 
 .task-urgency {
@@ -165,39 +207,8 @@ h1 { font-size: var(--step-2); }
 }
 
 .empty {
-  color: #888;
-  font-style: italic;
-}
-
-/* -------------------- */
-/* New series (collapsible) */
-/* -------------------- */
-
-.new-series summary {
+  color: #999;
   font-size: var(--step--1);
-  font-weight: 600;
-  color: #666;
-  cursor: pointer;
-  list-style: none;
-}
-
-.new-series summary::before {
-  content: "+ ";
-}
-
-.new-series[open] summary::before {
-  content: "− ";
-}
-
-.new-series[open] summary {
-  margin-block-end: var(--space-s);
-}
-
-.new-series form {
-  padding: var(--space-m);
-  border: 1px solid #e0e0e0;
-  border-radius: 6px;
-  background: #fff;
 }
 
 /* -------------------- */
@@ -206,7 +217,7 @@ h1 { font-size: var(--step-2); }
 
 form {
   display: grid;
-  gap: var(--space-m);
+  gap: var(--space-s);
 }
 
 .field {
@@ -223,9 +234,9 @@ label {
 input,
 textarea,
 select {
-  padding: var(--space-2xs);
+  padding: var(--space-3xs) var(--space-2xs);
   border: 1px solid #ccc;
-  border-radius: 6px;
+  border-radius: 3px;
   font: inherit;
   background: #fff;
 }
@@ -243,9 +254,9 @@ input[type="number"]::-webkit-outer-spin-button {
 input:focus,
 textarea:focus,
 select:focus {
-  outline: 2px solid #1a1a1a;
+  outline: 1px dotted #aaa;
   outline-offset: 1px;
-  border-color: #1a1a1a;
+  border-color: #999;
 }
 
 textarea {
@@ -259,9 +270,9 @@ textarea {
 }
 
 button[type="submit"] {
-  padding: var(--space-2xs) var(--space-m);
-  border: none;
-  border-radius: 6px;
+  padding: var(--space-3xs) var(--space-s);
+  border: 1px solid #1a1a1a;
+  border-radius: 3px;
   background: #1a1a1a;
   color: #fff;
   font: inherit;
@@ -278,14 +289,15 @@ button[type="submit"]:hover {
 }
 
 .complete-form button {
-  width: 2rem;
-  height: 2rem;
+  width: 14px;
+  height: 14px;
+  translate: 0 5px;
   padding: 0;
   border: 1.5px solid #ccc;
-  border-radius: 50%;
+  border-radius: 2px;
   background: transparent;
   color: transparent;
-  font-size: 0.8rem;
+  font-size: 10px;
   line-height: 1;
   cursor: pointer;
   transition: border-color 0.15s, color 0.15s, background 0.15s;
diff --git a/test/test_web.rb b/test/test_web.rb
index 52a62d1..f2a4359 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -30,7 +30,8 @@ class TestWeb < Minitest::Test
 
   def test_root_shows_empty_state
     get "/", {}, tailscale_headers
-    assert_includes last_response.body, "No tasks yet."
+    assert_includes last_response.body, "Nothing overdue."
+    assert_includes last_response.body, "Nothing upcoming."
   end
 
   def test_root_shows_active_tasks