Extract Sequel models from raw SQL in web.rb
Routes used hand-built INSERT/SELECT with manual timestamp
management. Consolidating into model classes with the timestamps
plugin fixes the NOT NULL constraint failures on users.updated_at.

Assisted-by: Claude Opus 4.6 via pi
change tpnrumsyvklqprqwnyqxylyzvwokowsk
commit d9b15fc723ea03e765fca7949e8bb841afc35610
author Alpha Chen <alpha@kejadlen.dev>
date
parent mlrrmyyk
diff --git a/Gemfile b/Gemfile
index 734a2bd..e263aa0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -16,5 +16,6 @@ group :development do
   gem "rack-test"
   gem "rake"
   gem "rbs-inline", require: false
+  gem "ruby-lsp"
   gem "steep"
 end
diff --git a/Gemfile.lock b/Gemfile.lock
index a13c2d2..13e292f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,6 +77,10 @@ GEM
       zeitwerk
     roda (3.100.0)
       rack
+    ruby-lsp (0.26.6)
+      language_server-protocol (~> 3.17.0)
+      prism (>= 1.2, < 2.0)
+      rbs (>= 3, < 5)
     securerandom (0.4.1)
     sequel (5.101.0)
       bigdecimal
@@ -140,6 +144,7 @@ DEPENDENCIES
   rake
   rbs-inline
   roda
+  ruby-lsp
   sequel
   sqlite3
   steep
@@ -189,6 +194,7 @@ CHECKSUMS
   rbs-inline (0.12.0) sha256=0b84ec69fa5b3deb89e60f08d9b47966c9e26a222c7c65b5014857a3b10c62b2
   refract (1.1.0) sha256=ee3b9627e39f7692831101e2fedd73e0d09a592ff5d5c05f171d14211fc7a9c7
   roda (3.100.0) sha256=35d36f43c68d2bd1974dc77ade8c873558265a191018e1ada88ca662f1fa8e62
+  ruby-lsp (0.26.6) sha256=1f50b21cb300586d7742190fc8446c18387f488aa0e9d276cf2bc7f3684ccda2
   securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
   sequel (5.101.0) sha256=d2ae3fd997a7c4572e8357918e777869faf90dc19310fcd6332747122aed2b29
   sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473
diff --git a/lib/models.rb b/lib/models.rb
new file mode 100644
index 0000000..6cf1a5a
--- /dev/null
+++ b/lib/models.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require_relative "db"
+
+Sequel::Model.plugin :timestamps, update_on_create: true
+
+class User < Sequel::Model
+  one_to_many :series
+
+  def self.upsert(login:, name:)
+    user = find_or_create(login: login) { |u| u.name = name }
+    user.update(name: name) if user.name != name
+    user
+  end
+end
+
+class Series < Sequel::Model
+  many_to_one :user
+  one_to_many :tasks
+
+  INTERVAL_UNITS = %w[day week month quarter year].freeze
+
+  def active_task
+    tasks_dataset.where(completed_at: nil).first
+  end
+
+  def self.create_with_first_task(user:, note:, interval_unit:, interval_count:, first_due_date:)
+    DB.transaction do
+      series = create(
+        user_id: user.id,
+        note: note,
+        interval_unit: interval_unit,
+        interval_count: interval_count
+      )
+
+      Task.create(
+        series_id: series.id,
+        due_date: first_due_date
+      )
+
+      series
+    end
+  end
+end
+
+class Task < Sequel::Model
+  many_to_one :series
+
+  dataset_module do
+    def active
+      where(completed_at: nil)
+    end
+
+    def for_user(user)
+      join(:series, id: :series_id)
+        .where(Sequel[:series][:user_id] => user.id)
+        .select_all(:tasks)
+        .select_append(
+          Sequel[:series][:note],
+          Sequel[:series][:interval_unit],
+          Sequel[:series][:interval_count]
+        )
+    end
+
+    def by_due_date
+      order(Sequel[:tasks][:due_date])
+    end
+  end
+end
diff --git a/lib/web.rb b/lib/web.rb
index b66ca49..28fe64d 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -2,7 +2,7 @@
 
 require "roda"
 
-require_relative "db"
+require_relative "models"
 require_relative "views/home"
 
 class Web < Roda
@@ -14,27 +14,14 @@ class Web < Roda
     return unless login
 
     name = env["HTTP_TAILSCALE_USER_NAME"]
-    now = Time.now
-
-    DB[:users]
-      .insert_conflict(target: :login, update: { name: name, updated_at: now })
-      .insert(login: login, name: name, created_at: now, updated_at: now)
-
-    DB[:users].first(login: login)
+    User.upsert(login: login, name: name)
   end
 
-  INTERVAL_UNITS = %w[day week month quarter year].freeze
-
   route do |r|
     r.halt 403 unless current_user
 
     r.root do
-      tasks = DB[:tasks]
-        .join(:series, id: :series_id)
-        .where(completed_at: nil, user_id: current_user[:id])
-        .order(Sequel[:tasks][:due_date])
-        .all
-
+      tasks = Task.active.for_user(current_user).by_due_date.all
       Views::Home.new(current_user:, tasks:).call
     end
 
@@ -46,7 +33,7 @@ class Web < Roda
         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 Series::INTERVAL_UNITS.include?(interval_unit)
         r.halt 422 unless interval_count >= 1
 
         begin
@@ -55,24 +42,13 @@ class Web < Roda
           r.halt 422
         end
 
-        now = Time.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
+        Series.create_with_first_task(
+          user: current_user,
+          note: note,
+          interval_unit: interval_unit,
+          interval_count: interval_count,
+          first_due_date: due_date
+        )
 
         r.redirect "/"
       end