Add calendar month view
Assisted-by: Claude Opus 4.6 via Claude Code
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)