Add agenda week view with overdue column
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/agenda.rb b/lib/ketchup/views/agenda.rb
new file mode 100644
index 0000000..26ee417
--- /dev/null
+++ b/lib/ketchup/views/agenda.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "layout"
+
+module Views
+ class Agenda < Phlex::HTML
+ DAYS = 7
+
+ def initialize(current_user:, csrf:)
+ @current_user = current_user
+ @csrf = csrf
+ end
+
+ def view_template
+ today = Date.today
+ dates = (0...DAYS).map { |i| today + i }
+ end_date = dates.last
+
+ overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
+ upcoming = @current_user.upcoming_tasks
+ .where { due_date <= end_date }
+ .all
+
+ tasks_by_date = {}
+ upcoming.each { |t| (tasks_by_date[t[:due_date]] ||= []) << t }
+
+ render Layout.new(current_user: @current_user, active_view: :agenda) do
+ div(class: "agenda-view") do
+ div(class: "agenda-columns") do
+ render_overdue_column(overdue)
+ dates.each_with_index do |date, i|
+ render_day_column(date, tasks_by_date[date] || [], i == 0)
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def render_overdue_column(tasks)
+ div(class: "agenda-column agenda-overdue") do
+ div(class: "agenda-column-header agenda-column-header--overdue") { "Overdue" }
+ if tasks.empty?
+ p(class: "empty") { "All clear" }
+ else
+ tasks.each { |t| render_task_pill(t, overdue: true) }
+ end
+ end
+ end
+
+ def render_day_column(date, tasks, is_today)
+ label = if is_today
+ "Today"
+ elsif date == Date.today + 1
+ "Tomorrow"
+ else
+ date.strftime("%a %-d")
+ end
+
+ div(class: ["agenda-column", ("agenda-column--today" if is_today)]) do
+ div(class: "agenda-column-header") { label }
+ tasks.each { |t| render_task_pill(t, overdue: false) }
+ end
+ end
+
+ def render_task_pill(task, overdue:)
+ name = task[:note].lines.first&.strip || task[:note]
+ a(
+ href: "/series/#{task[:series_id]}",
+ class: ["agenda-pill", ("agenda-pill--overdue" if overdue)],
+ "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{task[:series_id]} })"
+ ) { name }
+ end
+ end
+end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 4988be4..49a3abe 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -7,6 +7,7 @@ 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_panel"
require_relative "views/user_panel"
@@ -69,6 +70,10 @@ class Web < Roda
Views::Calendar.new(current_user: @user, csrf: method(:csrf_token), date: date).call
end
+ r.get "agenda" do
+ Views::Agenda.new(current_user: @user, csrf: method(:csrf_token)).call
+ end
+
r.on "users", Integer do |user_id|
r.halt 404 unless @user.id == user_id
diff --git a/public/css/app.css b/public/css/app.css
index af2bf0f..476118a 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -869,6 +869,74 @@ button[type="submit"]:not(.complete-btn):not(.panel-action):hover {
color: #c0392b;
}
+/* -------------------- */
+/* Agenda view */
+/* -------------------- */
+
+.agenda-view {
+ padding: var(--space-m);
+ overflow-x: auto;
+}
+
+.agenda-columns {
+ display: grid;
+ grid-template-columns: 120px repeat(7, 1fr);
+ gap: 2px;
+ min-width: 640px;
+}
+
+.agenda-column {
+ background: #fff;
+ border: 1px solid #eee;
+ border-radius: 4px;
+ padding: var(--space-3xs);
+ min-height: 12rem;
+}
+
+.agenda-column--today {
+ background: #f0f7ff;
+ border-color: #4a90d9;
+}
+
+.agenda-overdue {
+ background: #fefafa;
+ border-color: #f0e0de;
+}
+
+.agenda-column-header {
+ font-size: var(--step--2);
+ font-weight: 600;
+ color: #888;
+ text-align: center;
+ padding: var(--space-3xs);
+ margin-bottom: var(--space-3xs);
+}
+
+.agenda-column-header--overdue {
+ color: #c0392b;
+}
+
+.agenda-pill {
+ display: block;
+ font-size: var(--step--2);
+ padding: 3px 6px;
+ background: #e8f5e9;
+ color: #2e7d32;
+ border-radius: 4px;
+ margin-bottom: 3px;
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+}
+
+.agenda-pill--overdue {
+ background: #fde8e6;
+ color: #c0392b;
+ font-weight: 600;
+}
+
/* -------------------- */
/* Mobile responsive */
/* -------------------- */
diff --git a/test/test_web.rb b/test/test_web.rb
index 7414118..1b73b3f 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -614,6 +614,29 @@ class TestWeb < Minitest::Test
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"
+ end
+
private
def csrf_post(path, params = {}, headers = auth_headers)