Two-column homepage with task list and new series form
Replaces the standalone new-series page with a single home view.
Tasks column shows active tasks ordered by due date with overdue
styling; form column creates new series.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/lib/views/home.rb b/lib/views/home.rb
new file mode 100644
index 0000000..27622ef
--- /dev/null
+++ b/lib/views/home.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "layout"
+
+module Views
+ class Home < Phlex::HTML
+ def initialize(current_user:, tasks:)
+ @current_user = current_user
+ @tasks = tasks
+ end
+
+ def view_template
+ render Layout.new(current_user: @current_user) do
+ div(class: "home") do
+ div(class: "panel flow") do
+ h2 { "Tasks" }
+
+ if @tasks.empty?
+ p(class: "empty") { "No tasks yet." }
+ else
+ ul(class: "task-list") do
+ @tasks.each do |task|
+ li(class: "task-item") do
+ task_card(task)
+ end
+ end
+ end
+ end
+ end
+
+ div(class: "panel flow") do
+ h2 { "New Series" }
+
+ form(method: "post", action: "/series") do
+ div(class: "field") do
+ label(for: "note") { "Note" }
+ textarea(id: "note", name: "note", rows: 3, required: true)
+ end
+
+ div(class: "field") do
+ label(for: "interval_count") { "Repeat every" }
+ div(class: "interval") do
+ input(
+ type: "number", id: "interval_count", name: "interval_count",
+ min: 1, value: 1, required: true
+ )
+ select(id: "interval_unit", name: "interval_unit", required: true) do
+ option(value: "day") { "day(s)" }
+ option(value: "week") { "week(s)" }
+ option(value: "month") { "month(s)" }
+ option(value: "quarter") { "quarter(s)" }
+ option(value: "year") { "year(s)" }
+ end
+ end
+ end
+
+ div(class: "field") do
+ label(for: "first_due_date") { "First due date" }
+ input(
+ type: "date", id: "first_due_date", name: "first_due_date",
+ value: Date.today.to_s, required: true
+ )
+ end
+
+ button(type: "submit") { "Create" }
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def task_card(task)
+ name = task[:note].lines.first&.strip || task[:note]
+ overdue = task[:due_date] < Date.today
+
+ div(class: ["task-card", ("task-overdue" if overdue)]) do
+ span(class: "task-name") { name }
+ span(class: "task-due") do
+ plain overdue ? "Overdue — due #{task[:due_date]}" : "Due #{task[:due_date]}"
+ end
+ span(class: "task-interval") do
+ count = task[:interval_count]
+ unit = task[:interval_unit]
+ plain "Every #{count} #{count == 1 ? unit : "#{unit}s"}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/web.rb b/lib/web.rb
index 485c3e3..90752eb 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -3,7 +3,7 @@
require "roda"
require_relative "db"
-require_relative "views/series/new"
+require_relative "views/home"
class Web < Roda
plugin :halt
@@ -29,7 +29,22 @@ class Web < Roda
r.halt 403 unless current_user
r.root do
- Views::Series::New.new(current_user:).call
+ tasks = DB[:tasks]
+ .join(:series, id: :series_id)
+ .where(completed_at: nil)
+ .select(
+ Sequel[:tasks][:id],
+ Sequel[:tasks][:due_date],
+ Sequel[:tasks][:completed_at],
+ Sequel[:series][:note],
+ Sequel[:series][:interval_unit],
+ Sequel[:series][:interval_count],
+ Sequel[:series][:user_id]
+ )
+ .order(Sequel[:tasks][:due_date])
+ .all
+
+ Views::Home.new(current_user:, tasks:).call
end
r.on "series" do
diff --git a/public/css/app.css b/public/css/app.css
index 0aba9cd..9c3ba22 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -57,6 +57,78 @@ h1 { font-size: var(--step-2); }
padding: var(--space-l) var(--grid-gutter);
}
+/* -------------------- */
+/* Home layout */
+/* -------------------- */
+
+.home {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-l);
+ padding: var(--space-l) var(--grid-gutter);
+ max-width: 60rem;
+ margin-inline: auto;
+}
+
+@media (min-width: 48rem) {
+ .home {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.panel {
+ padding: var(--space-m);
+}
+
+/* -------------------- */
+/* Task list */
+/* -------------------- */
+
+.task-list {
+ list-style: none;
+ padding: 0;
+ display: grid;
+ gap: var(--space-s);
+}
+
+.task-card {
+ display: grid;
+ gap: var(--space-3xs);
+ padding: var(--space-s);
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ background: #fff;
+}
+
+.task-card.task-overdue {
+ border-color: #c0392b;
+ background: #fdf2f2;
+}
+
+.task-name {
+ font-weight: 600;
+}
+
+.task-due {
+ font-size: var(--step--1);
+ color: #666;
+}
+
+.task-overdue .task-due {
+ color: #c0392b;
+ font-weight: 600;
+}
+
+.task-interval {
+ font-size: var(--step--1);
+ color: #888;
+}
+
+.empty {
+ color: #888;
+ font-style: italic;
+}
+
/* -------------------- */
/* Forms */
/* -------------------- */
diff --git a/test/test_web.rb b/test/test_web.rb
index 782cb0e..db7becb 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -25,6 +25,23 @@ class TestWeb < Minitest::Test
assert_includes last_response.body, 'name="note"'
assert_includes last_response.body, 'name="interval_count"'
assert_includes last_response.body, 'name="interval_unit"'
+ assert_includes last_response.body, 'name="first_due_date"'
+ end
+
+ def test_root_shows_empty_state
+ get "/", {}, tailscale_headers
+ assert_includes last_response.body, "No tasks yet."
+ end
+
+ def test_root_shows_active_tasks
+ post "/series", {
+ note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01"
+ }, tailscale_headers
+
+ get "/", {}, tailscale_headers
+ assert_includes last_response.body, "Call Mom"
+ assert_includes last_response.body, "2026-03-01"
end
def test_root_shows_current_user