Consolidate dashboard into two-column layout
Replace separate List, Focus, Calendar, and Agenda pages with a single
dashboard: overdue tasks on the left (most urgent promoted to a "Next up"
focus section), 7-day upcoming agenda on the right with a mini week strip
showing task counts per day.
Focus/calendar/agenda routes now redirect to root. Series detail, new
series, and user pages remain standalone. New series "Create" button
moved into the section header to match Edit/Save pattern. Series editing
gains a Cancel option.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index e307772..6290e6b 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -3,11 +3,13 @@
require "phlex"
require_relative "layout"
-require_relative "task_list"
+require_relative "task_card"
module Views
INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
+ AGENDA_DAYS = 7
+
class Dashboard < Phlex::HTML
def initialize(current_user:, csrf:)
@current_user = current_user
@@ -15,25 +17,121 @@ module Views
end
def view_template
+ overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
+ upcoming = @current_user.upcoming_tasks
+ .where { due_date <= Date.today + AGENDA_DAYS }
+ .all
+
render Layout.new(current_user: @current_user) do
div(class: "dashboard") do
- render_main_column
+ div(class: "column-overdue") do
+ render_focus(overdue)
+ render_overdue(overdue.drop(1))
+ end
+ div(class: "column-agenda") do
+ render_agenda(upcoming, overdue_count: overdue.size)
+ end
end
end
end
private
- def render_main_column
- overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
- upcoming = @current_user.upcoming_tasks.all
-
- div(class: "main-column") do
- render TaskList.new(
- overdue: overdue,
- upcoming: upcoming,
- csrf: @csrf
- )
+ def render_focus(overdue)
+ if overdue.empty?
+ section(class: "section section--focus") do
+ p(class: "empty") { "All caught up!" }
+ end
+ return
+ end
+
+ task = overdue.first
+ name = task[:note].lines.first&.strip || task[:note]
+ complete_path = "/series/#{task[:series_id]}/tasks/#{task[:id]}/complete"
+
+ section(class: "section section--focus") do
+ div(class: "section-header") do
+ h2(class: "section-title") do
+ span(class: "section-title-text") { "Next up" }
+ end
+ end
+ render TaskCard.new(task: task, csrf: @csrf, overdue: true)
+ end
+ end
+
+ def render_overdue(tasks)
+ return if tasks.empty?
+
+ section(class: "section section--overdue") do
+ div(class: "section-header") do
+ h2(class: "section-title") do
+ span(class: "section-title-text") { "Overdue (#{tasks.size})" }
+ end
+ end
+
+ ul(class: "task-list") do
+ tasks.each do |task|
+ li(class: "task-item") do
+ render TaskCard.new(task: task, csrf: @csrf, overdue: true)
+ end
+ end
+ end
+ end
+ end
+
+ def render_agenda(upcoming, overdue_count: 0)
+ tasks_by_date = {}
+ upcoming.each { |t| (tasks_by_date[t[:due_date]] ||= []) << t }
+
+ today = Date.today
+
+ section(class: "section section--agenda") do
+ div(class: "agenda-week") do
+ if overdue_count > 0
+ div(class: "agenda-week-day agenda-week-day--overdue") do
+ span(class: "agenda-week-label") { "!" }
+ span(class: "agenda-week-count") { overdue_count.to_s }
+ end
+ end
+
+ AGENDA_DAYS.times do |i|
+ date = today + i
+ count = (tasks_by_date[date] || []).size
+ classes = ["agenda-week-day"]
+ classes << "agenda-week-day--today" if i == 0
+ classes << "agenda-week-day--has-tasks" if count > 0
+
+ div(class: classes) do
+ span(class: "agenda-week-label") { date.strftime("%a") }
+ span(class: "agenda-week-count") { count.to_s } if count > 0
+ end
+ end
+ end
+
+ AGENDA_DAYS.times do |i|
+ date = today + i
+ day_tasks = tasks_by_date[date] || []
+ next if day_tasks.empty?
+
+ div(class: ["agenda-day", ("agenda-day--today" if i == 0)]) do
+ div(class: "agenda-day-header") { friendly_day(date, i) }
+ day_tasks.each do |task|
+ task_name = task[:note].lines.first&.strip || task[:note]
+ a(
+ href: "/series/#{task[:series_id]}",
+ class: "agenda-day-pill"
+ ) { task_name }
+ end
+ end
+ end
+ end
+ end
+
+ def friendly_day(date, offset)
+ case offset
+ when 0 then "Today"
+ when 1 then "Tomorrow"
+ else date.strftime("%a, %b %-d")
end
end
end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 3c85f78..5ee369f 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -4,7 +4,7 @@ require "phlex"
module Views
class Layout < Phlex::HTML
- def initialize(current_user:, title: "Ketchup", active_view: :list)
+ def initialize(current_user:, title: "Ketchup", active_view: nil)
@current_user = current_user
@title = title
@active_view = active_view
@@ -40,12 +40,6 @@ module Views
header(class: "site-header") do
a(href: "/", class: "site-name") { "Ketchup" }
nav(class: "site-nav") do
- view_links.each do |path, label, view_key|
- a(
- href: path,
- class: ["view-link", ("view-link--active" if @active_view == view_key)]
- ) { label }
- end
a(
href: "/series/new",
class: ["header-action", ("header-action--active" if @active_view == :new)]
@@ -61,15 +55,6 @@ module Views
private
- def view_links
- [
- ["/", "List", :list],
- ["/focus", "Focus", :focus],
- ["/calendar", "Calendar", :calendar],
- ["/agenda", "Agenda", :agenda],
- ]
- end
-
def render_footer
config = CONFIG
parts = []
diff --git a/lib/ketchup/views/series/new.rb b/lib/ketchup/views/series/new.rb
index 6abc3a4..80d67f3 100644
--- a/lib/ketchup/views/series/new.rb
+++ b/lib/ketchup/views/series/new.rb
@@ -21,9 +21,15 @@ module Views
h2(class: "section-title") do
span(class: "section-title-text") { "New series" }
end
+ button(
+ class: "section-edit-btn",
+ id: "create-series-btn",
+ disabled: true,
+ "x-on:click": "document.getElementById('new-series-form').requestSubmit()"
+ ) { "Create" }
end
- form(method: "post", action: "/series", class: "new-series-form") do
+ form(method: "post", action: "/series", id: "new-series-form", class: "new-series-form") do
input(type: "hidden", name: "_csrf", value: @csrf.call("/series"))
div(class: "field") do
label(for: "series-note-editor") { "Note" }
@@ -50,8 +56,6 @@ module Views
value: Date.today.to_s, required: true
)
end
-
- button(type: "submit", id: "create-series-btn", disabled: true) { "Create" }
end
end
end
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index 19cee2c..19f6cd8 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -31,9 +31,18 @@ module Views
) do
plain "Edit"
end
+ button(
+ class: "section-edit-btn section-edit-btn--cancel",
+ "x-show": "editing",
+ "x-cloak": true,
+ "x-on:click": "editing = false; location.reload()"
+ ) do
+ plain "Cancel"
+ end
button(
class: "section-edit-btn",
"x-show": "editing",
+ "x-cloak": true,
"x-on:click": "editing = false; $dispatch('stop-editing')"
) do
plain "Save"
@@ -41,6 +50,7 @@ module Views
end
div(class: "series-note", id: "series-note-detail",
+ "x-bind:class": "{ 'series-note--editable': editing }",
"data-value": @series.note || "",
"data-series-id": @series.id.to_s)
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 27e6ddc..e9c21de 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -5,9 +5,6 @@ require "roda"
require_relative "models"
require_relative "views/dashboard"
-require_relative "views/focus"
-require_relative "views/calendar"
-require_relative "views/agenda"
require_relative "views/series/new"
require_relative "views/series/show"
require_relative "views/user/show"
@@ -47,31 +44,15 @@ class Web < Roda
end
r.get "focus" do
- overdue = @user.overdue_tasks.all.sort_by { |t| -t.urgency }
- if overdue.empty?
- r.redirect "/"
- else
- Views::Focus.new(
- current_user: @user,
- task: overdue.first,
- csrf: method(:csrf_token),
- position: 1,
- total: overdue.size
- ).call
- end
+ r.redirect "/"
end
r.get "calendar" do
- date = begin
- Date.parse(r.params["date"].to_s)
- rescue Date::Error
- Date.today
- end
- Views::Calendar.new(current_user: @user, csrf: method(:csrf_token), date: date).call
+ r.redirect "/"
end
r.get "agenda" do
- Views::Agenda.new(current_user: @user, csrf: method(:csrf_token)).call
+ r.redirect "/"
end
r.on "users", Integer do |user_id|
diff --git a/public/css/app.css b/public/css/app.css
index f506ab5..6769908 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -128,6 +128,11 @@ h2 { font-size: var(--step-0); font-weight: 600; }
flex: 1;
}
+.dashboard:has(.column-overdue) {
+ max-width: 56rem;
+ margin-inline: auto;
+}
+
.main-column {
flex: 1;
max-width: 32rem;
@@ -135,6 +140,123 @@ h2 { font-size: var(--step-0); font-weight: 600; }
padding: var(--space-m) var(--space-l);
}
+.column-overdue {
+ flex: 1;
+ min-width: 0;
+ overflow-y: auto;
+ padding: var(--space-m) var(--space-l);
+}
+
+.column-agenda {
+ width: 24rem;
+ flex-shrink: 0;
+ overflow-y: auto;
+ padding: var(--space-m) var(--space-l);
+}
+
+/* -------------------- */
+/* Focus row (minimal) */
+/* -------------------- */
+
+.section--focus {
+}
+
+/* -------------------- */
+/* Agenda (day-by-day) */
+/* -------------------- */
+
+.agenda-week {
+ display: flex;
+ margin-block-end: var(--space-m);
+ border: 1px solid #eee;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.agenda-week-day {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1px;
+ padding: var(--space-3xs) 0 var(--space-2xs);
+ border-inline-end: 1px solid #eee;
+}
+
+.agenda-week-day:last-child {
+ border-inline-end: none;
+}
+
+.agenda-week-label {
+ font-size: 9px;
+ color: #aaa;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ padding-block-end: var(--space-3xs);
+ margin-block-end: var(--space-3xs);
+ border-block-end: 1px solid #eee;
+ width: 100%;
+ text-align: center;
+}
+
+.agenda-week-count {
+ font-size: var(--step--1);
+ font-weight: 600;
+ color: #666;
+}
+
+.agenda-week-day--today .agenda-week-count {
+ color: #222;
+}
+
+.agenda-week-day--overdue .agenda-week-label {
+ color: #c00;
+}
+
+.agenda-week-day--overdue .agenda-week-count {
+ color: #c00;
+}
+
+.agenda-day {
+ margin-block-end: var(--space-s);
+}
+
+.agenda-day-header {
+ font-size: var(--step--2);
+ font-weight: 600;
+ color: #888;
+ margin-block-end: var(--space-3xs);
+}
+
+.agenda-day--today .agenda-day-header {
+ color: #222;
+}
+
+.agenda-day-empty {
+ font-size: var(--step--2);
+ color: #ccc;
+ padding-block: 2px;
+}
+
+.agenda-day-pill {
+ display: block;
+ font-size: var(--step--2);
+ padding: 3px 6px;
+ background: #f0f0f0;
+ color: #222;
+ border-radius: 4px;
+ margin-block-end: 3px;
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.agenda-day-pill:hover {
+ background: #e8e8e8;
+}
+
/* -------------------- */
/* Sections (overdue / upcoming) */
/* -------------------- */
@@ -168,17 +290,34 @@ h2 { font-size: var(--step-0); font-weight: 600; }
all: unset;
order: 1;
font-size: var(--step--1);
- color: #999;
+ color: #666;
cursor: pointer;
}
.section-edit-btn:hover {
- color: #444;
+ color: #222;
+}
+
+.section-edit-btn--cancel {
+ color: #aaa;
+}
+
+.section-edit-btn--cancel:hover {
+ color: #666;
+}
+
+.section-edit-btn:disabled {
+ color: #ccc;
+ cursor: default;
+ pointer-events: none;
}
.section-header::after {
content: "";
flex: 1;
+}
+
+.main-column .section-header::after {
height: 1px;
background: #ddd;
}
@@ -220,22 +359,20 @@ h2 { font-size: var(--step-0); font-weight: 600; }
grid-template-columns: auto 1fr auto;
align-items: baseline;
gap: var(--space-xs);
- padding: calc(7px + var(--space-3xs)) 0;
+ padding: var(--space-2xs) var(--space-xs);
+ margin-inline: calc(-1 * var(--space-xs));
cursor: pointer;
transition: background 0.1s;
position: relative;
+ border-radius: 6px;
}
.task-card:hover {
background: #f0f0f0;
- margin-inline: -6px;
- padding-inline: 6px;
}
.task-card--selected {
background: #ebebeb;
- margin-inline: -6px;
- padding-inline: 6px;
}
.task-card--overdue {
@@ -464,9 +601,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
border-color: #999;
}
-.series-note:hover {
- border-color: transparent;
-}
+
.series-note:focus-within {
border-color: #999;
@@ -949,6 +1084,21 @@ button[type="submit"]:not(.complete-btn):not(.panel-action):hover {
padding-inline: var(--space-s);
}
+ .dashboard {
+ flex-direction: column;
+ }
+
+ .column-overdue {
+ padding-inline: var(--space-s);
+ padding-block-end: 0;
+ }
+
+ .column-agenda {
+ width: auto;
+ border-inline-start: none;
+ padding: 0 var(--space-s) var(--space-m);
+ }
+
.panel {
width: 100vw;
}
diff --git a/test/test_web.rb b/test/test_web.rb
index 11b77e4..d1749b6 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -34,27 +34,36 @@ class TestWeb < Minitest::Test
def test_root_shows_empty_state
get "/", {}, auth_headers
assert_includes last_response.body, "All caught up!"
- assert_includes last_response.body, "Nothing upcoming."
end
- def test_root_shows_active_tasks
- create_series(note: "Call Mom", first_due_date: "2026-03-01", interval_unit: "week", interval_count: "2")
+ def test_root_shows_overdue_task_in_focus
+ create_series(note: "Call Mom", first_due_date: (Date.today - 3).to_s,
+ interval_unit: "week", interval_count: "2")
get "/", {}, auth_headers
assert_includes last_response.body, "Call Mom"
- assert_includes last_response.body, "Mar 1"
+ assert_includes last_response.body, "Next up"
+ end
+
+ def test_root_shows_upcoming_task_in_agenda
+ create_series(note: "Call Mom", first_due_date: Date.today.to_s,
+ interval_unit: "week", interval_count: "2")
+
+ get "/", {}, auth_headers
+ assert_includes last_response.body, "Call Mom"
+ assert_includes last_response.body, "agenda-day-pill"
end
def test_root_only_shows_own_tasks
create_series(
note: "Alice task", interval_unit: "week", interval_count: "1",
- first_due_date: "2026-03-01",
+ first_due_date: (Date.today - 1).to_s,
headers: auth_headers(login: "alice@example.com")
)
create_series(
note: "Bob task", interval_unit: "day", interval_count: "1",
- first_due_date: "2026-03-01",
+ first_due_date: (Date.today - 1).to_s,
headers: auth_headers(login: "bob@example.com")
)
@@ -164,7 +173,7 @@ class TestWeb < Minitest::Test
def test_complete_task
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01")
+ first_due_date: (Date.today - 3).to_s)
task = DB[:tasks].first
series = DB[:series].first
@@ -207,7 +216,7 @@ class TestWeb < Minitest::Test
def test_complete_task_requires_own_task
create_series(
note: "Alice task", interval_unit: "day", interval_count: "1",
- first_due_date: "2026-03-01",
+ first_due_date: (Date.today - 1).to_s,
headers: auth_headers(login: "alice@example.com")
)
@@ -235,7 +244,7 @@ class TestWeb < Minitest::Test
def test_task_card_links_to_series
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01")
+ first_due_date: (Date.today - 3).to_s)
series = DB[:series].first
get "/", {}, auth_headers
@@ -245,17 +254,17 @@ class TestWeb < Minitest::Test
def test_task_card_has_complete_form
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01")
+ first_due_date: (Date.today - 3).to_s)
task = DB[:tasks].first
- get "/", {}, auth_headers
series = DB[:series].first
+ get "/", {}, auth_headers
assert_includes last_response.body, "action=\"/series/#{series[:id]}/tasks/#{task[:id]}/complete\""
end
def test_task_card_has_csrf_token
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: "2026-03-01")
+ first_due_date: (Date.today - 3).to_s)
get "/", {}, auth_headers
assert_includes last_response.body, 'name="_csrf"'
@@ -291,7 +300,7 @@ class TestWeb < Minitest::Test
def test_patch_note_saves_on_completed_task
create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
- first_due_date: "2026-03-01")
+ first_due_date: (Date.today - 3).to_s)
task = DB[:tasks].first
series = DB[:series].first
@@ -319,7 +328,7 @@ class TestWeb < Minitest::Test
def test_patch_note_requires_own_task
create_series(
note: "Alice task", interval_unit: "day", interval_count: "1",
- first_due_date: "2026-03-01",
+ first_due_date: (Date.today - 1).to_s,
headers: auth_headers(login: "alice@example.com")
)
@@ -473,17 +482,10 @@ class TestWeb < Minitest::Test
assert_includes last_response.body, "site-footer"
end
- def test_header_shows_view_nav
+ def test_header_links_home_and_new
get "/", {}, auth_headers
assert_includes last_response.body, 'href="/"'
- assert_includes last_response.body, 'href="/focus"'
- assert_includes last_response.body, 'href="/calendar"'
- assert_includes last_response.body, 'href="/agenda"'
- end
-
- def test_header_highlights_active_view
- get "/", {}, auth_headers
- assert_includes last_response.body, "view-link--active"
+ assert_includes last_response.body, 'href="/series/new"'
end
def test_csrf_rejects_post_without_token
@@ -495,84 +497,56 @@ class TestWeb < Minitest::Test
assert_equal 403, last_response.status
end
- def test_focus_view_shows_overdue_task
- create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: (Date.today - 3).to_s)
-
+ def test_focus_redirects_to_root
get "/focus", {}, auth_headers
- assert last_response.ok?
- assert_includes last_response.body, "Call Mom"
- assert_includes last_response.body, "focus-task"
+ assert last_response.redirect?
+ assert_includes last_response["Location"], "/"
end
- def test_focus_view_redirects_when_caught_up
- get "/focus", {}, auth_headers
+ def test_calendar_redirects_to_root
+ get "/calendar", {}, auth_headers
assert last_response.redirect?
assert_includes last_response["Location"], "/"
end
- def test_focus_view_shows_most_urgent_first
+ def test_agenda_redirects_to_root
+ get "/agenda", {}, auth_headers
+ assert last_response.redirect?
+ assert_includes last_response["Location"], "/"
+ end
+
+ def test_dashboard_shows_most_urgent_in_focus
create_series(note: "Low urgency", interval_unit: "month", interval_count: "1",
first_due_date: (Date.today - 1).to_s)
create_series(note: "High urgency", interval_unit: "day", interval_count: "1",
first_due_date: (Date.today - 10).to_s)
- get "/focus", {}, auth_headers
- assert_includes last_response.body, "High urgency"
+ get "/", {}, auth_headers
+ body = last_response.body
+ # Focus section (first task card) should be the most urgent
+ focus_pos = body.index("Next up")
+ high_pos = body.index("High urgency")
+ low_pos = body.index("Low urgency")
+ assert focus_pos, "Expected Next up section"
+ assert high_pos, "Expected High urgency task"
+ assert low_pos, "Expected Low urgency task"
+ assert high_pos < low_pos, "High urgency should appear before Low urgency"
+ end
+
+ def test_dashboard_agenda_shows_week_strip
+ get "/", {}, auth_headers
+ assert_includes last_response.body, "agenda-week"
end
- def test_complete_from_focus_redirects_to_focus
+ def test_complete_from_dashboard_redirects_to_series
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
first_due_date: (Date.today - 3).to_s)
task = DB[:tasks].first
series = DB[:series].first
- csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete",
- { "return_to" => "/focus" }, auth_headers
+ csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
assert last_response.redirect?
- assert_equal "/focus", last_response["Location"]
- end
-
- def test_calendar_view_renders
- get "/calendar", {}, auth_headers
- assert last_response.ok?
- assert_includes last_response.body, "calendar-grid"
- end
-
- def test_calendar_shows_tasks_on_due_date
- create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: Date.today.to_s)
-
- get "/calendar", {}, auth_headers
- assert_includes last_response.body, "Call Mom"
- end
-
- def test_calendar_view_highlights_active
- get "/calendar", {}, auth_headers
- assert_includes last_response.body, "view-link--active"
- end
-
- def test_agenda_view_renders
- get "/agenda", {}, auth_headers
- assert last_response.ok?
- assert_includes last_response.body, "agenda-view"
- end
-
- def test_agenda_shows_overdue_column
- create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
- first_due_date: (Date.today - 3).to_s)
-
- get "/agenda", {}, auth_headers
- assert_includes last_response.body, "Call Mom"
- assert_includes last_response.body, "agenda-overdue"
- end
-
- def test_agenda_shows_upcoming_on_day
- create_series(note: "Haircut", interval_unit: "week", interval_count: "6",
- first_due_date: Date.today.to_s)
-
- get "/agenda", {}, auth_headers
- assert_includes last_response.body, "Haircut"
+ assert_includes last_response["Location"], "/series/#{series[:id]}"
end
private