Add agenda week view with overdue column
Assisted-by: Claude Opus 4.6 via Claude Code
change oyloywunowvukkqqnspwyzvqkkotzvmw
commit d81b625bb43e624afe9c777ac03c709f7db1d9df
author Alpha Chen <alpha@kejadlen.dev>
date
parent nqplywzt
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)