Add focus view for processing overdue tasks one at a time
Assisted-by: Claude Opus 4.6 via Claude Code
change nuylymozrxzwwrklnvotoompvtswwkxq
commit 8a82f5076a9af39ac0fce2fa05f3e1146470897c
author Alpha Chen <alpha@kejadlen.dev>
date
parent rnlrzzly
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)