Create tasks table with first task on series creation
Each series gets an active task (completed_at NULL) with the user-provided
due date. A partial unique index enforces at most one active task per series.

Assisted-by: Claude Opus 4.6 via pi
change qxmtovwltxtltvkrqulqpsmvlmmuoyuv
commit cbf6d6e1138b0864b913837f2458b68e24ac1cb4
author Alpha Chen <alpha@kejadlen.dev>
date
parent npsqrrsv
diff --git a/db/migrate/003_create_tasks.rb b/db/migrate/003_create_tasks.rb
new file mode 100644
index 0000000..cea2468
--- /dev/null
+++ b/db/migrate/003_create_tasks.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+  change do
+    create_table(:tasks) do
+      primary_key :id
+      foreign_key :series_id, :series, null: false
+      Date :due_date, null: false
+      DateTime :completed_at
+      DateTime :created_at, null: false
+      DateTime :updated_at, null: false
+
+      index [:series_id], unique: true, where: Sequel.lit("completed_at IS NULL"), name: :one_active_task_per_series
+    end
+  end
+end
diff --git a/lib/views/series/new.rb b/lib/views/series/new.rb
index da76783..acbb61e 100644
--- a/lib/views/series/new.rb
+++ b/lib/views/series/new.rb
@@ -39,6 +39,15 @@ module Views
                 end
               end
 
+              # Maybe we should update this if we change the interval?
+              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
diff --git a/lib/web.rb b/lib/web.rb
index f484c5f..485c3e3 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -37,20 +37,36 @@ class Web < Roda
         note = r.params["note"].to_s.strip
         interval_unit = r.params["interval_unit"].to_s
         interval_count = r.params["interval_count"].to_i
+        first_due_date = r.params["first_due_date"].to_s
 
         r.halt 422 if note.empty?
         r.halt 422 unless INTERVAL_UNITS.include?(interval_unit)
         r.halt 422 unless interval_count >= 1
 
+        begin
+          due_date = Date.parse(first_due_date)
+        rescue Date::Error
+          r.halt 422
+        end
+
         now = Time.now
-        DB[:series].insert(
-          user_id: current_user[:id],
-          note: note,
-          interval_unit: interval_unit,
-          interval_count: interval_count,
-          created_at: now,
-          updated_at: now
-        )
+        DB.transaction do
+          series_id = DB[:series].insert(
+            user_id: current_user[:id],
+            note: note,
+            interval_unit: interval_unit,
+            interval_count: interval_count,
+            created_at: now,
+            updated_at: now
+          )
+
+          DB[:tasks].insert(
+            series_id: series_id,
+            due_date: due_date,
+            created_at: now,
+            updated_at: now
+          )
+        end
 
         r.redirect "/"
       end
diff --git a/test/test_db.rb b/test/test_db.rb
new file mode 100644
index 0000000..8959ece
--- /dev/null
+++ b/test/test_db.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+ENV["DATABASE_URL"] = ":memory:"
+
+require "minitest/autorun"
+
+require_relative "../lib/db"
+
+class TestDB < Minitest::Test
+  def setup
+    DB[:tasks].delete
+    DB[:series].delete
+    @now = Time.now
+    DB[:users]
+      .insert_conflict(target: :login, update: { name: "Test", updated_at: @now })
+      .insert(login: "test@example.com", name: "Test", created_at: @now, updated_at: @now)
+    @user_id = DB[:users].first(login: "test@example.com")[:id]
+  end
+
+  def test_only_one_active_task_per_series
+    series_id = create_series
+
+    assert_raises(Sequel::UniqueConstraintViolation) do
+      DB[:tasks].insert(series_id: series_id, due_date: Date.new(2026, 3, 15), created_at: @now, updated_at: @now)
+    end
+  end
+
+  def test_completed_task_allows_new_active_task
+    series_id = create_series
+
+    DB[:tasks].where(series_id: series_id).update(completed_at: @now)
+
+    DB[:tasks].insert(series_id: series_id, due_date: Date.new(2026, 3, 15), created_at: @now, updated_at: @now)
+
+    assert_equal 1, DB[:tasks].where(series_id: series_id, completed_at: nil).count
+  end
+
+  private
+
+  def create_series
+    series_id = DB[:series].insert(
+      user_id: @user_id, note: "Call Mom",
+      interval_unit: "week", interval_count: 2,
+      created_at: @now, updated_at: @now
+    )
+    DB[:tasks].insert(series_id: series_id, due_date: Date.new(2026, 3, 1), created_at: @now, updated_at: @now)
+    series_id
+  end
+end
diff --git a/test/test_web.rb b/test/test_web.rb
index d52dfbb..782cb0e 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -12,7 +12,9 @@ class TestWeb < Minitest::Test
 
   def app = Web.app
 
+  # TODO Wrap tests in a transaction or something?
   def setup
+    DB[:tasks].delete
     DB[:series].delete
   end
 
@@ -49,7 +51,10 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_series
-    post "/series", { note: "Call Mom", interval_unit: "week", interval_count: "2" }, tailscale_headers
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
     assert last_response.redirect?
 
     series = DB[:series].first
@@ -58,9 +63,23 @@ class TestWeb < Minitest::Test
     assert_equal 2, series[:interval_count]
   end
 
+  def test_create_series_creates_first_task
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    series = DB[:series].first
+    task = DB[:tasks].first(series_id: series[:id])
+    assert_equal Date.new(2026, 3, 1), task[:due_date]
+    assert_nil task[:completed_at]
+  end
+
   def test_create_series_belongs_to_current_user
-    post "/series", { note: "Dentist", interval_unit: "quarter", interval_count: "1" },
-      tailscale_headers(login: "dave@example.com", name: "Dave")
+    post "/series", {
+      note: "Dentist", interval_unit: "quarter", interval_count: "1",
+      first_due_date: "2026-06-01"
+    }, tailscale_headers(login: "dave@example.com", name: "Dave")
 
     series = DB[:series].first
     user = DB[:users].first(login: "dave@example.com")
@@ -68,30 +87,55 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_series_strips_whitespace
-    post "/series", { note: "  Trim me  ", interval_unit: "day", interval_count: "1" }, tailscale_headers
+    post "/series", {
+      note: "  Trim me  ", interval_unit: "day", interval_count: "1",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
     assert_equal "Trim me", DB[:series].first[:note]
   end
 
   def test_create_series_rejects_empty_note
-    post "/series", { note: "  ", interval_unit: "day", interval_count: "1" }, tailscale_headers
+    post "/series", {
+      note: "  ", interval_unit: "day", interval_count: "1",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
     assert_equal 422, last_response.status
     assert_equal 0, DB[:series].count
   end
 
   def test_create_series_rejects_invalid_interval_unit
-    post "/series", { note: "Nope", interval_unit: "fortnight", interval_count: "1" }, tailscale_headers
+    post "/series", {
+      note: "Nope", interval_unit: "fortnight", interval_count: "1",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
     assert_equal 422, last_response.status
     assert_equal 0, DB[:series].count
   end
 
   def test_create_series_rejects_zero_interval_count
-    post "/series", { note: "Nope", interval_unit: "day", interval_count: "0" }, tailscale_headers
+    post "/series", {
+      note: "Nope", interval_unit: "day", interval_count: "0",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
     assert_equal 422, last_response.status
     assert_equal 0, DB[:series].count
   end
 
+  def test_create_series_rejects_invalid_due_date
+    post "/series", {
+      note: "Nope", interval_unit: "day", interval_count: "1",
+      first_due_date: "not-a-date"
+    }, tailscale_headers
+    assert_equal 422, last_response.status
+    assert_equal 0, DB[:series].count
+    assert_equal 0, DB[:tasks].count
+  end
+
   def test_create_series_requires_tailscale_user
-    post "/series", { note: "Nope", interval_unit: "day", interval_count: "1" }
+    post "/series", {
+      note: "Nope", interval_unit: "day", interval_count: "1",
+      first_due_date: "2026-03-01"
+    }
     assert_equal 403, last_response.status
   end