Add focus view for processing overdue tasks one at a time
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/focus.rb b/lib/ketchup/views/focus.rb
new file mode 100644
index 0000000..6a86ca4
--- /dev/null
+++ b/lib/ketchup/views/focus.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "layout"
+
+module Views
+ class Focus < Phlex::HTML
+ def initialize(current_user:, task:, csrf:, position:, total:)
+ @current_user = current_user
+ @task = task
+ @csrf = csrf
+ @position = position
+ @total = total
+ end
+
+ def view_template
+ render Layout.new(current_user: @current_user, active_view: :focus) do
+ div(class: "focus-view") do
+ render_progress
+ render_task
+ render_complete_button
+ end
+ end
+ end
+
+ private
+
+ def render_progress
+ div(class: "focus-progress") do
+ plain "#{@position} of #{@total} overdue"
+ end
+ end
+
+ def render_task
+ name = @task[:note].lines.first&.strip || @task[:note]
+ interval_count = @task[:interval_count] || @task.series.interval_count
+ interval_unit = @task[:interval_unit] || @task.series.interval_unit
+ interval = "#{interval_count} #{interval_count == 1 ? interval_unit : "#{interval_unit}s"}"
+
+ div(class: "focus-task") do
+ div(class: "focus-urgency") do
+ plain "#{format("%.1f", @task.urgency)}× overdue"
+ end
+ h1(class: "focus-name") { name }
+ p(class: "focus-interval") { "every #{interval}" }
+ end
+ end
+
+ def render_complete_button
+ complete_path = "/series/#{@task[:series_id]}/tasks/#{@task[:id]}/complete"
+ form(method: "post", action: complete_path, class: "focus-form") do
+ input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
+ input(type: "hidden", name: "return_to", value: "/focus")
+ button(type: "submit", class: "focus-complete-btn") do
+ span { "\u2713" }
+ plain " Done"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 40ca6ba..bd0c023 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -5,6 +5,7 @@ require "roda"
require_relative "models"
require_relative "views/dashboard"
+require_relative "views/focus"
require_relative "views/series/new"
require_relative "views/series_panel"
require_relative "views/user_panel"
@@ -43,6 +44,21 @@ class Web < Roda
Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token)).call
end
+ r.get "focus" do
+ overdue = @user.overdue_tasks.all.sort_by { |t| -t.urgency }
+ if overdue.empty?
+ r.redirect "/"
+ else
+ Views::Focus.new(
+ current_user: @user,
+ task: overdue.first,
+ csrf: method(:csrf_token),
+ position: 1,
+ total: overdue.size
+ ).call
+ end
+ end
+
r.on "users", Integer do |user_id|
r.halt 404 unless @user.id == user_id
@@ -155,7 +171,12 @@ class Web < Roda
r.halt 422 unless @task[:completed_at].nil?
@task.complete!(today: Date.today)
- r.redirect "/series/#{series_id}"
+ return_to = r.params["return_to"]
+ if return_to && return_to.start_with?("/")
+ r.redirect return_to
+ else
+ r.redirect "/series/#{series_id}"
+ end
end
r.patch "note" do
diff --git a/public/css/app.css b/public/css/app.css
index 393de28..c94ef95 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -701,6 +701,74 @@ button[type="submit"]:not(.complete-btn):not(.panel-action):hover {
background: #333;
}
+/* -------------------- */
+/* Focus view */
+/* -------------------- */
+
+.focus-view {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ padding: var(--space-xl) var(--space-m);
+ text-align: center;
+}
+
+.focus-progress {
+ font-size: var(--step--1);
+ color: #999;
+ margin-bottom: var(--space-l);
+}
+
+.focus-task {
+ margin-bottom: var(--space-l);
+}
+
+.focus-urgency {
+ font-size: var(--step--2);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #c0392b;
+ margin-bottom: var(--space-2xs);
+}
+
+.focus-name {
+ font-size: var(--step-3);
+ font-weight: 700;
+ line-height: 1.2;
+ margin-bottom: var(--space-3xs);
+}
+
+.focus-interval {
+ font-size: var(--step-0);
+ color: #888;
+}
+
+.focus-form {
+ display: inline-flex;
+}
+
+.focus-complete-btn {
+ all: unset;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2xs);
+ padding: var(--space-xs) var(--space-xl);
+ background: #1a1a1a;
+ color: #fff;
+ border-radius: 99px;
+ font-size: var(--step-1);
+ font-weight: 600;
+ transition: background 0.15s;
+}
+
+.focus-complete-btn:hover {
+ background: #333;
+}
+
/* -------------------- */
/* Mobile responsive */
/* -------------------- */
diff --git a/test/test_web.rb b/test/test_web.rb
index bf3a430..95778ef 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -557,6 +557,44 @@ class TestWeb < Minitest::Test
assert_equal 403, last_response.status
end
+ def test_focus_view_shows_overdue_task
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: (Date.today - 3).to_s)
+
+ get "/focus", {}, auth_headers
+ assert last_response.ok?
+ assert_includes last_response.body, "Call Mom"
+ assert_includes last_response.body, "focus-task"
+ end
+
+ def test_focus_view_redirects_when_caught_up
+ get "/focus", {}, auth_headers
+ assert last_response.redirect?
+ assert_includes last_response["Location"], "/"
+ end
+
+ def test_focus_view_shows_most_urgent_first
+ create_series(note: "Low urgency", interval_unit: "month", interval_count: "1",
+ first_due_date: (Date.today - 1).to_s)
+ create_series(note: "High urgency", interval_unit: "day", interval_count: "1",
+ first_due_date: (Date.today - 10).to_s)
+
+ get "/focus", {}, auth_headers
+ assert_includes last_response.body, "High urgency"
+ end
+
+ def test_complete_from_focus_redirects_to_focus
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: (Date.today - 3).to_s)
+
+ task = DB[:tasks].first
+ series = DB[:series].first
+ csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete",
+ { "return_to" => "/focus" }, auth_headers
+ assert last_response.redirect?
+ assert_equal "/focus", last_response["Location"]
+ end
+
private
def csrf_post(path, params = {}, headers = auth_headers)