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
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