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
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