Simplify overdue list headings and make cards fully clickable
Remove sort toggle (urgency/date) and Alpine sortable component.
Inline overdue count as "Overdue (4)" instead of separate badge.
Bump heading size and contrast for readability. Add "due" prefix
to overdue age text. Use stretched-link pattern so the entire
card area opens the series panel.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index ab3adcb..1e8ae8d 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -27,8 +27,19 @@ module Views
div(class: "task-body") do
a(
href: "/series/#{@task[:series_id]}",
- class: "task-name",
- "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{@task[:series_id]} })"
+ class: "task-name stretched-link",
+ "x-on:click.prevent": "
+ const panel = Alpine.$data(document.getElementById('panel'));
+ if (panel.open && panel.currentSeriesId === '#{@task[:series_id]}') {
+ panel.close();
+ history.pushState(null, '', '/');
+ } else {
+ panel.show('#{@task[:series_id]}');
+ history.pushState(null, '', '/series/#{@task[:series_id]}');
+ }
+ document.querySelectorAll('.task-card--selected').forEach(el => el.classList.remove('task-card--selected'));
+ if (panel.open) $el.closest('.task-card').classList.add('task-card--selected');
+ "
) { name }
if @overdue
span(class: "task-meta") do
@@ -37,8 +48,7 @@ module Views
end
end
if @overdue && @task.urgency > 0
- span(class: "task-urgency", "x-show": "sort === 'urgency'") { "#{format("%.1f", @task.urgency)}×" }
- span(class: "task-date", "x-show": "sort === 'date'") { @task[:due_date].to_s }
+ span(class: "task-urgency") { "#{format("%.1f", @task.urgency)}×" }
end
end
end
@@ -70,7 +80,7 @@ module Views
"#{months} #{months == 1 ? "month" : "months"} ago"
end
- "every #{interval} · #{ago}"
+ "every #{interval} · due #{ago}"
end
end
end
diff --git a/lib/ketchup/views/task_list.rb b/lib/ketchup/views/task_list.rb
index 7d84e37..620527b 100644
--- a/lib/ketchup/views/task_list.rb
+++ b/lib/ketchup/views/task_list.rb
@@ -14,7 +14,7 @@ module Views
end
def view_template
- div(class: "task-list-container", "x-data": "sortable") do
+ div(class: "task-list-container", "x-data": "") do
render_overdue
render_upcoming
render_completed_today
@@ -27,21 +27,13 @@ module Views
section(class: "section section--overdue") do
div(class: "section-header") do
h2(class: "section-title") do
- span(class: "section-title-text") { "Overdue" }
- span(class: "section-count") { @overdue.size.to_s } unless @overdue.empty?
+ span(class: "section-title-text") do
+ plain "Overdue"
+ unless @overdue.empty?
+ plain " (#{@overdue.size})"
+ end
+ end
end
- nav(class: "sort-toggle") do
- button(
- "x-on:click": "sort = 'urgency'",
- "x-bind:class": "sort === 'urgency' && 'sort-active'",
- "x-bind:disabled": "sort === 'urgency'"
- ) { "Urgency" }
- button(
- "x-on:click": "sort = 'date'",
- "x-bind:class": "sort === 'date' && 'sort-active'",
- "x-bind:disabled": "sort === 'date'"
- ) { "Date" }
- end unless @overdue.empty?
end
if @overdue.empty?
@@ -49,11 +41,7 @@ module Views
else
ul(class: "task-list") do
@overdue.each do |task|
- li(
- class: "task-item",
- "data-urgency": format("%.4f", task.urgency),
- "data-due-date": task[:due_date].to_s
- ) do
+ li(class: "task-item") do
render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task), overdue: true)
end
end
diff --git a/public/css/app.css b/public/css/app.css
index 5b4cba0..a044a57 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -124,6 +124,8 @@ h2 { font-size: var(--step-0); font-weight: 600; }
max-width: 32rem;
margin-inline: auto;
padding: var(--space-m) var(--space-l);
+ position: relative;
+ z-index: 100;
}
/* -------------------- */
@@ -148,56 +150,24 @@ h2 { font-size: var(--step-0); font-weight: 600; }
}
.section-title-text {
- font-size: 10px;
- font-weight: 500;
+ font-size: var(--step--1);
+ font-weight: 600;
text-transform: uppercase;
- letter-spacing: 0.12em;
- color: #999;
+ letter-spacing: 0.08em;
+ color: #666;
}
.section-header::after {
content: "";
flex: 1;
height: 1px;
- background: #f0f0f0;
-}
-
-.section-count {
- font-size: 10px;
- font-weight: 500;
- color: #c00;
- font-variant-numeric: tabular-nums;
+ background: #ddd;
}
.section-header--overdue .section-title-text {
color: #c00;
}
-/* -------------------- */
-/* Sort toggle */
-/* -------------------- */
-
-.sort-toggle {
- display: flex;
- gap: var(--space-2xs);
- font-size: var(--step--1);
-}
-
-.sort-toggle button {
- all: unset;
- color: #999;
- cursor: default;
-}
-
-.sort-toggle button:hover {
- color: #1a1a1a;
-}
-
-.sort-toggle button.sort-active {
- color: #1a1a1a;
- font-weight: 600;
-}
-
/* -------------------- */
/* Task list */
/* -------------------- */
@@ -208,7 +178,6 @@ h2 { font-size: var(--step-0); font-weight: 600; }
}
.task-item {
- padding-block: var(--space-3xs);
}
.date-header {
@@ -241,20 +210,20 @@ h2 { font-size: var(--step-0); font-weight: 600; }
grid-template-columns: auto 1fr auto;
align-items: baseline;
gap: var(--space-xs);
- padding: 7px 0;
+ padding: calc(7px + var(--space-3xs)) 0;
cursor: pointer;
transition: background 0.1s;
position: relative;
}
.task-card:hover {
- background: #f8f8f8;
+ background: #f0f0f0;
margin-inline: -6px;
padding-inline: 6px;
}
.task-card--selected {
- background: #f5f5f5;
+ background: #ebebeb;
margin-inline: -6px;
padding-inline: 6px;
}
@@ -264,7 +233,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
}
.task-card--overdue:hover {
- background: #fff5f5;
+ background: #ffe5e5;
}
.task-body {
@@ -276,6 +245,13 @@ h2 { font-size: var(--step-0); font-weight: 600; }
display: block;
}
+.stretched-link::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+}
+
.task-card--overdue .task-name {
color: #c00;
font-weight: 500;
@@ -311,14 +287,14 @@ h2 { font-size: var(--step-0); font-weight: 600; }
}
.complete-btn {
- width: 9px;
- height: 9px;
+ width: 18px;
+ height: 18px;
padding: 0;
border: 1.5px solid #ccc;
- border-radius: 50%;
+ border-radius: 3px;
background: transparent;
color: transparent;
- font-size: 7px;
+ font-size: 14px;
line-height: 1;
cursor: pointer;
transition: all 0.15s;
@@ -328,17 +304,18 @@ h2 { font-size: var(--step-0); font-weight: 600; }
flex-shrink: 0;
position: relative;
top: -1px;
+ z-index: 2;
}
.complete-btn:hover {
border-color: #090;
color: #090;
background: #f0fff0;
+ font-weight: 900;
+ -webkit-text-stroke: 1px;
}
.task-card--overdue .complete-btn {
- width: 10px;
- height: 10px;
border-color: #c00;
}
diff --git a/public/js/app.js b/public/js/app.js
index 3351986..f82a75a 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -127,13 +127,14 @@ document.addEventListener("alpine:init", () => {
if (userId) this.showUser(userId)
}
- // Listen for custom event from task cards
+ // Listen for custom event from other views (agenda, calendar)
window.addEventListener("open-panel", (e) => {
this.show(e.detail.seriesId)
})
},
async show(seriesId) {
+ this.currentSeriesId = String(seriesId)
this.loading = true
this.open = true
try {
@@ -147,6 +148,7 @@ document.addEventListener("alpine:init", () => {
},
async showUser(userId) {
+ this.currentSeriesId = null
this.loading = true
this.open = true
try {
@@ -159,6 +161,7 @@ document.addEventListener("alpine:init", () => {
},
close() {
+ this.currentSeriesId = null
this.open = false
setTimeout(() => {
if (this.$refs.content) this.$refs.content.innerHTML = ""
@@ -166,30 +169,6 @@ document.addEventListener("alpine:init", () => {
},
}))
- Alpine.data("sortable", () => ({
- sort: Alpine.$persist("urgency"),
-
- init() {
- this.$watch("sort", () => 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))
- },
- }))
-
Alpine.data("intervalEditor", (seriesId, initialCount, initialUnit) => ({
count: initialCount,
unit: initialUnit,