Add calendar month view
Assisted-by: Claude Opus 4.6 via Claude Code
change nqplywztzqrlqlskzxnvovlvrqluvlov
commit 90891c74ea58532b6af9a5f29b80da164c300bed
author Alpha Chen <alpha@kejadlen.dev>
date
parent nuylymoz
diff --git a/lib/ketchup/views/calendar.rb b/lib/ketchup/views/calendar.rb
new file mode 100644
index 0000000..a76e88c
--- /dev/null
+++ b/lib/ketchup/views/calendar.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require "phlex"
+require "date"
+
+require_relative "layout"
+
+module Views
+  class Calendar < Phlex::HTML
+    def initialize(current_user:, csrf:, date: Date.today)
+      @current_user = current_user
+      @csrf = csrf
+      @date = date
+    end
+
+    def view_template
+      first_of_month = Date.new(@date.year, @date.month, 1)
+      last_of_month = Date.new(@date.year, @date.month, -1)
+      prev_month = first_of_month << 1
+      next_month = first_of_month >> 1
+
+      overdue = @current_user.overdue_tasks.all
+      upcoming = @current_user.upcoming_tasks
+        .where { due_date <= last_of_month }
+        .all
+
+      tasks_by_date = {}
+      overdue.each { |t| (tasks_by_date[Date.today] ||= []) << [t, true] }
+      upcoming.each { |t| (tasks_by_date[t[:due_date]] ||= []) << [t, false] }
+
+      render Layout.new(current_user: @current_user, active_view: :calendar) do
+        div(class: "calendar-view") do
+          div(class: "calendar-header") do
+            a(href: "/calendar?date=#{prev_month}", class: "calendar-nav") { "\u2190" }
+            h2(class: "calendar-month-title") { first_of_month.strftime("%B %Y") }
+            a(href: "/calendar?date=#{next_month}", class: "calendar-nav") { "\u2192" }
+          end
+
+          div(class: "calendar-grid") do
+            %w[Mon Tue Wed Thu Fri Sat Sun].each do |day_name|
+              div(class: "calendar-day-name") { day_name }
+            end
+
+            start_dow = (first_of_month.wday - 1) % 7
+            start_dow.times { div(class: "calendar-day calendar-day--empty") }
+
+            (1..last_of_month.day).each do |day_num|
+              date = Date.new(@date.year, @date.month, day_num)
+              is_today = date == Date.today
+              day_tasks = tasks_by_date[date] || []
+
+              div(class: ["calendar-day", ("calendar-day--today" if is_today)]) do
+                span(class: "calendar-day-num") { day_num.to_s }
+                day_tasks.each do |task, is_overdue|
+                  task_name = task[:note].lines.first&.strip || task[:note]
+                  a(
+                    href: "/series/#{task[:series_id]}",
+                    class: ["calendar-pill", ("calendar-pill--overdue" if is_overdue)],
+                    "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{task[:series_id]} })"
+                  ) { task_name }
+                end
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index bd0c023..4988be4 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -6,6 +6,7 @@ require "roda"
 require_relative "models"
 require_relative "views/dashboard"
 require_relative "views/focus"
+require_relative "views/calendar"
 require_relative "views/series/new"
 require_relative "views/series_panel"
 require_relative "views/user_panel"
@@ -59,6 +60,15 @@ class Web < Roda
       end
     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
+    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 c94ef95..af2bf0f 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -769,6 +769,106 @@ button[type="submit"]:not(.complete-btn):not(.panel-action):hover {
   background: #333;
 }
 
+/* -------------------- */
+/* Calendar view */
+/* -------------------- */
+
+.calendar-view {
+  padding: var(--space-m) var(--space-m);
+  max-width: 56rem;
+  margin: 0 auto;
+}
+
+.calendar-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: var(--space-s);
+}
+
+.calendar-month-title {
+  font-size: var(--step-1);
+  font-weight: 700;
+}
+
+.calendar-nav {
+  font-size: var(--step-0);
+  color: #555;
+  text-decoration: none;
+  padding: var(--space-3xs) var(--space-2xs);
+  border-radius: 4px;
+}
+
+.calendar-nav:hover {
+  background: #f5f5f5;
+}
+
+.calendar-grid {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 2px;
+}
+
+.calendar-day-name {
+  text-align: center;
+  font-size: var(--step--2);
+  font-weight: 600;
+  color: #999;
+  padding: var(--space-3xs);
+}
+
+.calendar-day {
+  min-height: 5rem;
+  padding: var(--space-3xs);
+  border: 1px solid #eee;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.calendar-day--empty {
+  background: #fafafa;
+  border-color: transparent;
+}
+
+.calendar-day--today {
+  background: #f0f7ff;
+  border-color: #4a90d9;
+  border-width: 2px;
+}
+
+.calendar-day-num {
+  font-size: var(--step--2);
+  font-weight: 400;
+  color: #555;
+  display: block;
+  margin-bottom: 2px;
+}
+
+.calendar-day--today .calendar-day-num {
+  font-weight: 700;
+  color: #4a90d9;
+}
+
+.calendar-pill {
+  display: block;
+  font-size: 10px;
+  padding: 1px 4px;
+  background: #e8f5e9;
+  color: #2e7d32;
+  border-radius: 3px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-top: 1px;
+  text-decoration: none;
+  cursor: pointer;
+}
+
+.calendar-pill--overdue {
+  background: #fde8e6;
+  color: #c0392b;
+}
+
 /* -------------------- */
 /* Mobile responsive */
 /* -------------------- */
diff --git a/test/test_web.rb b/test/test_web.rb
index 95778ef..7414118 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -595,6 +595,25 @@ class TestWeb < Minitest::Test
     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
+
   private
 
   def csrf_post(path, params = {}, headers = auth_headers)