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