Calendar view for upcoming tasks
Upcoming column now groups tasks by date with friendly headings.
Alpine "Calendar" toggle shows/hides empty days within a 91-day
horizon, with month dividers, weekend highlights, and a horizon
marker.

Assisted-by: Claude Opus 4.6 via Claude Code
change uxlwnpmxkvnuosvovlkyquyolzzqmyqs
commit 4208a9db8ac279a07a833babf6ab6e718332452b
author Alpha Chen <alpha@kejadlen.dev>
date
parent wrrvpkpy
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index a28bf9e..a51699c 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -34,9 +34,17 @@ module Views
             task_list(@overdue, empty: "Nothing overdue.", sortable: true)
           end
 
-          div(class: "column") do
-            h2 { "Upcoming" }
-            task_list(@upcoming, empty: "Nothing upcoming.")
+          div(class: "column", "x-data": "upcoming") do
+            div(class: "column-header") do
+              h2 { "Upcoming" }
+              nav(class: "sort-toggle") do
+                button(
+                  "x-on:click": "showEmpty = !showEmpty",
+                  "x-bind:class": "showEmpty && 'sort-active'"
+                ) { "Calendar" }
+              end
+            end
+            upcoming_list(@upcoming)
           end
 
           div(class: "column column-aside") do
@@ -98,6 +106,62 @@ module Views
       end
     end
 
+    def upcoming_list(tasks)
+      if tasks.empty?
+        p(class: "empty") { "Nothing upcoming." }
+      else
+        tasks_by_date = tasks.group_by { |t| t[:due_date] }
+
+        ul(class: "task-list") do
+          current_month = Date.today.month
+          horizon = Date.today + 91
+          past_horizon = false
+          last_date = [tasks.last[:due_date], horizon].max
+          (Date.today..last_date).each do |date|
+            if date.month != current_month
+              current_month = date.month
+              li(class: "calendar-month", "x-show": "showEmpty") if date <= horizon
+            end
+            day_tasks = tasks_by_date[date]
+            empty = day_tasks.nil?
+            if empty && date > horizon
+              unless past_horizon
+                past_horizon = true
+                li(class: "calendar-horizon", "x-show": "showEmpty") do
+                  span { "3 months" }
+                end
+              end
+              next
+            end
+            weekend = date.saturday? || date.sunday?
+            day_attrs = { class: ["calendar-day", ("calendar-day-empty" if empty), ("calendar-day-weekend" if weekend)] }
+            day_attrs[:"x-show"] = "showEmpty" if empty
+            li(**day_attrs) do
+              span(class: "calendar-date") { friendly_date(date) }
+            end
+            next if empty
+            day_tasks.each do |task|
+              li(class: ["task-item", ("calendar-day-weekend" if weekend)]) { task_card(task) }
+            end
+          end
+        end
+      end
+    end
+
+    def friendly_date(date)
+      today = Date.today
+      case date
+      when today then "Today"
+      when today + 1 then "Tomorrow"
+      else
+        if date < today + 7
+          date.strftime("%A")
+        else
+          date.strftime("%b %-d")
+        end
+      end
+    end
+
     def task_card(task)
       name = task[:note].lines.first&.strip || task[:note]
       overdue = task[:due_date] < Date.today
@@ -109,10 +173,6 @@ module Views
         div(class: "task-body") do
           span(class: "task-name") { name }
           div(class: "task-meta") do
-            span(class: "task-due") do
-              plain "Due #{task[:due_date]}"
-            end
-            span(class: "task-meta-sep") { "\u00B7" }
             span(class: "task-interval") do
               count = task[:interval_count]
               unit = task[:interval_unit]
diff --git a/public/css/app.css b/public/css/app.css
index 1ff2132..add4363 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -158,7 +158,6 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 
 .task-item {
   padding-block: var(--space-2xs);
-  border-block-end: 1px dotted #ddd;
 }
 
 .task-card {
@@ -213,6 +212,52 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   font-weight: 600;
 }
 
+.calendar-horizon {
+  padding-block: var(--space-s) var(--space-2xs);
+  border-block-end: 1px solid #ccc;
+  font-size: var(--step--2);
+  color: #999;
+  text-align: center;
+}
+
+.calendar-month {
+  padding-block: var(--space-2xs) 0;
+  border-block-end: 1px dotted #ccc;
+}
+
+.calendar-day {
+  padding-block: var(--space-2xs);
+  border-block-end: none;
+}
+
+.calendar-day-empty + .calendar-day-empty,
+.calendar-day-empty:first-child {
+  padding-block: var(--space-3xs);
+}
+
+.calendar-date {
+  font-size: var(--step--1);
+  font-weight: 600;
+  color: #444;
+}
+
+.calendar-day-empty .calendar-date {
+  font-weight: 400;
+  color: #bbb;
+}
+
+.calendar-day-weekend {
+  background: #f8f8f8;
+  margin-inline: calc(-1 * var(--space-2xs));
+  padding-inline: var(--space-2xs);
+}
+
+.calendar-day-weekend + .calendar-month {
+  background: #f8f8f8;
+  margin-inline: calc(-1 * var(--space-2xs));
+  padding-inline: var(--space-2xs);
+}
+
 .empty {
   color: #999;
   font-size: var(--step--1);
diff --git a/public/js/app.js b/public/js/app.js
index 1cb00c5..6f1a7d8 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -25,4 +25,14 @@ document.addEventListener("alpine:init", () => {
       items.forEach((li) => ul.appendChild(li))
     },
   }))
+
+  Alpine.data("upcoming", () => ({
+    showEmpty: localStorage.getItem("upcoming-show-empty") !== "false",
+
+    init() {
+      this.$watch("showEmpty", (value) => {
+        localStorage.setItem("upcoming-show-empty", value)
+      })
+    },
+  }))
 })
diff --git a/test/test_web.rb b/test/test_web.rb
index f2a4359..cdcd030 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -42,7 +42,7 @@ class TestWeb < Minitest::Test
 
     get "/", {}, tailscale_headers
     assert_includes last_response.body, "Call Mom"
-    assert_includes last_response.body, "2026-03-01"
+    assert_includes last_response.body, "Mar 1"
   end
 
   def test_root_only_shows_own_tasks