Persist series via POST /series
DB constraints enforce valid interval_unit and positive interval_count
as a safety net behind the app-level validation.

Assisted-by: Claude Opus 4.6 via pi
change npsqrrsvrtylmtkztsunlypnxtzrwuwu
commit 2d29e3b80023da22f27508b66afef7018992d697
author Alpha Chen <alpha@kejadlen.dev>
date
parent qyvylpus
diff --git a/db/migrate/002_create_series.rb b/db/migrate/002_create_series.rb
new file mode 100644
index 0000000..459dd6c
--- /dev/null
+++ b/db/migrate/002_create_series.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+  change do
+    create_table(:series) do
+      primary_key :id
+      foreign_key :user_id, :users, null: false
+      String :note, null: false
+      String :interval_unit, null: false
+      Integer :interval_count, null: false, default: 1
+
+      DateTime :created_at, null: false
+      DateTime :updated_at, null: false
+
+      constraint(:valid_interval_unit) { Sequel.lit("interval_unit IN ('day', 'week', 'month', 'quarter', 'year')") }
+      constraint(:positive_interval_count) { Sequel.lit("interval_count >= 1") }
+    end
+  end
+end
diff --git a/lib/web.rb b/lib/web.rb
index b0f85f2..f484c5f 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -23,11 +23,37 @@ class Web < Roda
     DB[:users].first(login: login)
   end
 
+  INTERVAL_UNITS = %w[day week month quarter year].freeze
+
   route do |r|
     r.halt 403 unless current_user
 
     r.root do
       Views::Series::New.new(current_user:).call
     end
+
+    r.on "series" do
+      r.post do
+        note = r.params["note"].to_s.strip
+        interval_unit = r.params["interval_unit"].to_s
+        interval_count = r.params["interval_count"].to_i
+
+        r.halt 422 if note.empty?
+        r.halt 422 unless INTERVAL_UNITS.include?(interval_unit)
+        r.halt 422 unless interval_count >= 1
+
+        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
+        )
+
+        r.redirect "/"
+      end
+    end
   end
 end
diff --git a/test/test_web.rb b/test/test_web.rb
index 420b484..d52dfbb 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -12,6 +12,10 @@ class TestWeb < Minitest::Test
 
   def app = Web.app
 
+  def setup
+    DB[:series].delete
+  end
+
   def test_root_shows_new_series_form
     get "/", {}, tailscale_headers
     assert last_response.ok?
@@ -44,6 +48,53 @@ class TestWeb < Minitest::Test
     assert_equal 403, last_response.status
   end
 
+  def test_create_series
+    post "/series", { note: "Call Mom", interval_unit: "week", interval_count: "2" }, tailscale_headers
+    assert last_response.redirect?
+
+    series = DB[:series].first
+    assert_equal "Call Mom", series[:note]
+    assert_equal "week", series[:interval_unit]
+    assert_equal 2, series[:interval_count]
+  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")
+
+    series = DB[:series].first
+    user = DB[:users].first(login: "dave@example.com")
+    assert_equal user[:id], series[:user_id]
+  end
+
+  def test_create_series_strips_whitespace
+    post "/series", { note: "  Trim me  ", interval_unit: "day", interval_count: "1" }, 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
+    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
+    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
+    assert_equal 422, last_response.status
+    assert_equal 0, DB[:series].count
+  end
+
+  def test_create_series_requires_tailscale_user
+    post "/series", { note: "Nope", interval_unit: "day", interval_count: "1" }
+    assert_equal 403, last_response.status
+  end
+
   private
 
   def tailscale_headers(