Move all classes and constants under the Ketchup module
Wraps Config, CONFIG, DB, models, views, and Web in `module Ketchup`.
Ruby constant lookup resolves internal cross-references automatically,
so only external files (config.ru, Rakefile, tests) need qualified names.

Assisted-by: Claude Opus 4.6 via pi
change ykkwwmmzsxolnopwrznwvrxztxtnrlnx
commit 39c66a31bc21076fd8937fea3c962453cd56e2f6
author Alpha Chen <alpha@kejadlen.dev>
date
parent sqnwprlq
diff --git a/Rakefile b/Rakefile
index bc8effe..319ab37 100644
--- a/Rakefile
+++ b/Rakefile
@@ -25,10 +25,10 @@ desc "Seed database with sample series and tasks"
 task :seed do
   require "ketchup/seed"
 
-  DB[:tasks].delete
-  DB[:series].delete
+  Ketchup::DB[:tasks].delete
+  Ketchup::DB[:series].delete
 
-  user = User.first || abort("No users yet — visit the app first to create one")
+  user = Ketchup::User.first || abort("No users yet — visit the app first to create one")
 
   Ketchup::Seed.call(user: user, series: Ketchup::Seed::DATA)
   puts "Seeded #{Ketchup::Seed::DATA.length} series for #{user.login}"
diff --git a/config.ru b/config.ru
index edddd06..2ce7855 100644
--- a/config.ru
+++ b/config.ru
@@ -10,7 +10,7 @@ end
 
 require_relative "lib/ketchup/config"
 
-if CONFIG.otel
+if Ketchup::CONFIG.otel
   require "opentelemetry/sdk"
   require "opentelemetry/exporter/otlp"
   require "opentelemetry/instrumentation/rack"
@@ -23,26 +23,26 @@ if CONFIG.otel
   use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args)
 end
 
-$stderr.puts CONFIG
+$stderr.puts Ketchup::CONFIG
 
-if CONFIG.sentry
+if Ketchup::CONFIG.sentry
   require "sentry-ruby"
 
   Sentry.init do |config|
-    config.dsn = CONFIG.sentry.dsn
-    config.environment = CONFIG.sentry.env if CONFIG.sentry.env
+    config.dsn = Ketchup::CONFIG.sentry.dsn
+    config.environment = Ketchup::CONFIG.sentry.env if Ketchup::CONFIG.sentry.env
     config.send_default_pii = true
   end
 
   use Sentry::Rack::CaptureExceptions
 end
 
-if CONFIG.default_user
+if Ketchup::CONFIG.default_user
   require_relative "lib/ketchup/dev_auth"
-  use Ketchup::DevAuth, CONFIG.default_user
+  use Ketchup::DevAuth, Ketchup::CONFIG.default_user
 
   require_relative "lib/ketchup/seed"
-  user = User.find_or_create(login: CONFIG.default_user)
+  user = Ketchup::User.find_or_create(login: Ketchup::CONFIG.default_user)
   if user.series_dataset.empty?
     Ketchup::Seed.call(user: user, series: Ketchup::Seed::DATA)
     $stderr.puts "Seeded #{Ketchup::Seed::DATA.length} series for #{user.login}"
@@ -50,4 +50,4 @@ if CONFIG.default_user
 end
 
 require_relative "lib/ketchup/web"
-run Web.freeze.app
+run Ketchup::Web.freeze.app
diff --git a/docs/plans/2026-03-01-ketchup-module-extraction.md b/docs/plans/2026-03-01-ketchup-module-extraction.md
new file mode 100644
index 0000000..d746c3b
--- /dev/null
+++ b/docs/plans/2026-03-01-ketchup-module-extraction.md
@@ -0,0 +1,453 @@
+# Ketchup Module Extraction — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Move all application classes and constants under a `Ketchup` module.
+
+**Architecture:** Wrap each source file's definitions in `module Ketchup ... end`. Ruby's constant lookup resolves unqualified references within the enclosing module, so internal cross-references (e.g., `User` from `Web`, `CONFIG` from `Layout`) continue to work without explicit `Ketchup::` prefixes. Only files outside the module (config.ru, Rakefile, tests) need their references qualified.
+
+**Tech Stack:** Ruby, Sequel, Roda, Phlex, Minitest
+
+---
+
+## Key insight
+
+Files already inside `module Ketchup` (dev_auth.rb, seed.rb, snapshots.rb) reference top-level constants like `User`, `Series`, `Task`, `Web`, `DB`, `CONFIG`. Once those constants move into `module Ketchup`, Ruby's constant lookup finds them in the enclosing module first. So these files need *no reference changes at all* — the resolution shifts automatically.
+
+Likewise, files that get newly wrapped in `module Ketchup` keep their internal references unchanged.
+
+The only reference updates are in top-level files: `config.ru`, `Rakefile`, and `test/*.rb`.
+
+## Sequel model table names
+
+Sequel derives table names from the last segment of the class name. `Ketchup::User` maps to `users`, `Ketchup::Series` to `series`, `Ketchup::Task` to `tasks`. No `set_dataset` calls needed.
+
+## DB escape hatch
+
+If Sequel rejects `Ketchup::DB` anywhere (plugin registration, migration context), restore `DB` as a top-level constant. That means removing the `module Ketchup` wrapper from `db.rb` only, and replacing `Ketchup::DB` with `DB` in config.ru, Rakefile, and tests. The lib/ files that are inside `module Ketchup` would need a top-level `DB` reference, which Ruby resolves by falling through to the top-level constant.
+
+---
+
+### Task 1: Wrap core modules (config.rb, db.rb)
+
+**Files:**
+- Modify: `lib/ketchup/config.rb`
+- Modify: `lib/ketchup/db.rb`
+
+**Step 1: Wrap config.rb**
+
+Add `module Ketchup` around all definitions. The file currently defines `Config` (Data class) and `CONFIG` constant at top level. Wrap them:
+
+```ruby
+# rbs_inline: enabled
+# frozen_string_literal: true
+
+require "securerandom"
+
+module Ketchup
+  Config = Data.define(
+    :database_url,   #: String
+    :session_secret, #: String
+    :auth_header,    #: String
+    :sentry,         #: SentryConfig?
+    :otel,           #: OtelConfig?
+    :default_user,   #: String?
+    :commit_sha,     #: String?
+    :change_id,      #: String?
+    :build_date,     #: String?
+  )
+
+  class Config
+    SentryConfig = Data.define(
+      :dsn, #: String
+      :env, #: String?
+    )
+
+    OtelConfig = Data.define(
+      :endpoint, #: String
+    )
+
+    #: () -> String
+    def to_s
+      parts = ["database=#{database_url}"]
+      parts << "auth=#{auth_header}"
+      parts << "sentry=#{sentry.env || "on"}" if sentry
+      parts << "otel=on" if otel
+      parts << "default_user=#{default_user}" if default_user
+      parts << "commit=#{commit_sha}" if commit_sha
+      parts << "change=#{change_id}" if change_id
+      parts << "built=#{build_date}" if build_date
+      "Config(#{parts.join(", ")})"
+    end
+
+    #: (?Hash[String, String] env) -> Config
+    def self.from_env(env = ENV)
+      sentry_dsn = env["SENTRY_DSN"]
+      otel_endpoint = env["OTEL_EXPORTER_OTLP_ENDPOINT"]
+      new(
+        database_url: env.fetch("DATABASE_URL") { "db/ketchup.db" },
+        session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
+        auth_header: env.fetch("AUTH_HEADER", "Remote-User"),
+        sentry: sentry_dsn ? SentryConfig.new(dsn: sentry_dsn, env: env["SENTRY_ENV"]) : nil,
+        otel: otel_endpoint ? OtelConfig.new(endpoint: otel_endpoint) : nil,
+        default_user: env["DEFAULT_USER"],
+        commit_sha: env["COMMIT_SHA"],
+        change_id: env["CHANGE_ID"]&.slice(0, 8),
+        build_date: env["BUILD_DATE"]
+      )
+    end
+  end
+
+  CONFIG = Config.from_env
+end
+```
+
+**Step 2: Wrap db.rb**
+
+The file defines `DB` and runs migrations. `CONFIG` resolves within `module Ketchup`:
+
+```ruby
+# frozen_string_literal: true
+
+require "sequel"
+
+require_relative "config"
+
+module Ketchup
+  DB = Sequel.sqlite(CONFIG.database_url)
+  Sequel.extension :migration
+  Sequel::Migrator.run(DB, File.expand_path("../../db/migrate", __dir__))
+end
+```
+
+---
+
+### Task 2: Wrap models.rb
+
+**Files:**
+- Modify: `lib/ketchup/models.rb`
+
+**Step 1: Wrap models.rb**
+
+The Sequel plugin calls (`Sequel::Model.plugin`) are global configuration — they work inside or outside the module. `DB` resolves to `Ketchup::DB` within the module. Model cross-references (`Series`, `Task`, `User`) resolve within the module.
+
+```ruby
+# frozen_string_literal: true
+
+require_relative "db"
+
+module Ketchup
+  Sequel::Model.plugin :timestamps, update_on_create: true
+  Sequel::Model.plugin :sole
+  Sequel::Model.plugin :many_through_many
+
+  class User < Sequel::Model
+    one_to_many :series
+    many_through_many :tasks, [[:series, :user_id, :id]], right_primary_key: :series_id
+
+    def active_tasks
+      tasks_dataset
+        .where(completed_at: nil)
+        .select_all(:tasks)
+        .select_append(
+          Sequel[:series][:note],
+          Sequel[:series][:interval_unit],
+          Sequel[:series][:interval_count]
+        )
+    end
+
+    def overdue_tasks
+      active_tasks.where { due_date < Date.today }
+    end
+
+    def upcoming_tasks
+      active_tasks.where { due_date >= Date.today }.order(:due_date)
+    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 completed_tasks
+      tasks_dataset
+        .exclude(completed_at: nil)
+        .order(Sequel.desc(:completed_at))
+        .select(:id, :due_date, :completed_at, :note)
+        .all
+    end
+
+    def completion_stats
+      completed = completed_tasks
+      return { streak: 0, on_time_pct: 100, total: 0 } if completed.empty?
+
+      streak = 0
+      on_time = 0
+      completed.each_with_index do |t, i|
+        on_time += 1 if t[:completed_at].to_date <= t[:due_date]
+        streak += 1 if i == streak && t[:completed_at].to_date <= t[:due_date]
+      end
+
+      { streak: streak, on_time_pct: (on_time * 100.0 / completed.size).round, total: completed.size }
+    end
+
+    def next_due_date(completed_on)
+      case interval_unit
+      when "day"
+        completed_on + interval_count
+      when "week"
+        completed_on + (7 * interval_count)
+      when "month"
+        completed_on >> interval_count
+      when "quarter"
+        completed_on >> (3 * interval_count)
+      when "year"
+        completed_on >> (12 * interval_count)
+      else
+        fail
+      end
+    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
+
+    INTERVAL_DAYS = {
+      "day" => 1, "week" => 7, "month" => 30, "quarter" => 91, "year" => 365
+    }.freeze
+
+    def urgency
+      days_overdue = Date.today - self[:due_date]
+      return 0 if days_overdue <= 0
+
+      count = self[:interval_count] || series.interval_count
+      unit = self[:interval_unit] || series.interval_unit
+      interval = count * INTERVAL_DAYS.fetch(unit)
+      days_overdue.to_f / interval
+    end
+
+    def complete!(completed_on:)
+      DB.transaction do
+        update(completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day))
+        Task.create(series_id: series.id, due_date: series.next_due_date(completed_on))
+      end
+    end
+
+    def undo_complete!
+      DB.transaction do
+        next_task = series.active_task
+        next_task.destroy if next_task
+        update(completed_at: nil)
+      end
+    end
+  end
+end
+```
+
+---
+
+### Task 3: Wrap views
+
+**Files:**
+- Modify: `lib/ketchup/views/layout.rb`
+- Modify: `lib/ketchup/views/dashboard.rb`
+- Modify: `lib/ketchup/views/task_card.rb`
+- Modify: `lib/ketchup/views/series/new.rb`
+- Modify: `lib/ketchup/views/series/show.rb`
+- Modify: `lib/ketchup/views/user/show.rb`
+
+**Step 1: Wrap each view file in `module Ketchup`**
+
+Add `module Ketchup` as the outermost wrapper. The existing `module Views` nesting stays. Internal references (`CONFIG` in layout, `Series::INTERVAL_UNITS` in dashboard, `Layout` / `TaskCard` cross-refs) all resolve within `module Ketchup`.
+
+For each file, the change is identical: add `module Ketchup` after the requires/frozen_string_literal and indent the existing code one level, then close with `end`.
+
+layout.rb:
+```ruby
+# frozen_string_literal: true
+
+require "digest"
+require "phlex"
+
+module Ketchup
+  module Views
+    # ... existing Layout class unchanged ...
+  end
+end
+```
+
+dashboard.rb:
+```ruby
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "layout"
+require_relative "task_card"
+
+module Ketchup
+  module Views
+    INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
+
+    AGENDA_DAYS = 7
+
+    class Dashboard < Phlex::HTML
+      # ... existing code unchanged ...
+    end
+  end
+end
+```
+
+task_card.rb, series/new.rb, series/show.rb, user/show.rb: same pattern — wrap existing `module Views` in `module Ketchup`.
+
+---
+
+### Task 4: Wrap web.rb
+
+**Files:**
+- Modify: `lib/ketchup/web.rb`
+
+**Step 1: Wrap web.rb**
+
+`CONFIG`, `User`, `Series`, `Task`, `DB`, `Views::*` all resolve within `module Ketchup`:
+
+```ruby
+# frozen_string_literal: true
+
+require "json"
+require "roda"
+
+require_relative "models"
+require_relative "views/dashboard"
+require_relative "views/series/new"
+require_relative "views/series/show"
+require_relative "views/user/show"
+
+module Ketchup
+  class Web < Roda
+    # ... existing code unchanged ...
+  end
+end
+```
+
+---
+
+### Task 5: Update config.ru
+
+**Files:**
+- Modify: `config.ru`
+
+**Step 1: Update all references from top-level to `Ketchup::`**
+
+Replace:
+- `CONFIG` → `Ketchup::CONFIG` (6 occurrences)
+- `Web` → `Ketchup::Web` (1 occurrence, last line)
+- `User` → `Ketchup::User` (1 occurrence)
+
+`Ketchup::DevAuth` and `Ketchup::Seed` references already use the `Ketchup::` prefix — no change.
+
+---
+
+### Task 6: Update Rakefile
+
+**Files:**
+- Modify: `Rakefile`
+
+**Step 1: Update references in `seed` task**
+
+Replace:
+- `DB[:tasks]` → `Ketchup::DB[:tasks]`
+- `DB[:series]` → `Ketchup::DB[:series]`
+- `User.first` → `Ketchup::User.first`
+
+The `Ketchup::Seed` and `Ketchup::Snapshots` references already use the `Ketchup::` prefix — no change needed.
+
+---
+
+### Task 7: Update tests
+
+**Files:**
+- Modify: `test/test_web.rb`
+- Modify: `test/test_db.rb`
+- Modify: `test/test_seed.rb`
+- Modify: `test/test_sole.rb`
+
+**Step 1: Update test_web.rb**
+
+Replace:
+- `Web.app` → `Ketchup::Web.app`
+- `DB[` → `Ketchup::DB[` (all occurrences)
+- `Task.first` → `Ketchup::Task.first`
+- `Task.where` → `Ketchup::Task.where`
+
+**Step 2: Update test_db.rb**
+
+Replace:
+- `DB[` → `Ketchup::DB[`
+
+**Step 3: Update test_seed.rb**
+
+Replace:
+- `User.create` → `Ketchup::User.create`
+- `Series.count` → `Ketchup::Series.count`
+- `Task.count` → `Ketchup::Task.count`
+- `Series.first` → `Ketchup::Series.first`
+- `Task.first` → `Ketchup::Task.first`
+- `Task.exclude` → `Ketchup::Task.exclude`
+
+`Ketchup::Seed` already uses the `Ketchup::` prefix — no change.
+
+**Step 4: Update test_sole.rb**
+
+Replace:
+- `User.create` → `Ketchup::User.create`
+- `User.where` → `Ketchup::User.where`
+- `User.dataset` → `Ketchup::User.dataset`
+
+---
+
+### Task 8: Run tests and verify
+
+**Step 1: Run the full test suite**
+
+Run: `rake test`
+Expected: all tests pass
+
+**Step 2: Run type checker**
+
+Run: `rake check`
+Expected: passes (rbs-inline + steep)
+
+If `Ketchup::DB` causes Sequel issues, execute the DB escape hatch: unwrap `DB` from `module Ketchup` in db.rb, change `Ketchup::DB` back to `DB` in config.ru, Rakefile, and tests.
+
+---
+
+### Task 9: Commit
+
+Run: jj commit (use jj-commit skill)
+
+Message: "Move all classes and constants under the Ketchup module"
diff --git a/lib/ketchup/config.rb b/lib/ketchup/config.rb
index 07f29dd..bf3f77c 100644
--- a/lib/ketchup/config.rb
+++ b/lib/ketchup/config.rb
@@ -3,57 +3,59 @@
 
 require "securerandom"
 
-Config = Data.define(
-  :database_url,   #: String
-  :session_secret, #: String
-  :auth_header,    #: String
-  :sentry,         #: SentryConfig?
-  :otel,           #: OtelConfig?
-  :default_user,   #: String?
-  :commit_sha,     #: String?
-  :change_id,      #: String?
-  :build_date,     #: String?
-)
-
-class Config
-  SentryConfig = Data.define(
-    :dsn, #: String
-    :env, #: String?
-  )
-
-  OtelConfig = Data.define(
-    :endpoint, #: String
+module Ketchup
+  Config = Data.define(
+    :database_url,   #: String
+    :session_secret, #: String
+    :auth_header,    #: String
+    :sentry,         #: SentryConfig?
+    :otel,           #: OtelConfig?
+    :default_user,   #: String?
+    :commit_sha,     #: String?
+    :change_id,      #: String?
+    :build_date,     #: String?
   )
 
-  #: () -> String
-  def to_s
-    parts = ["database=#{database_url}"]
-    parts << "auth=#{auth_header}"
-    parts << "sentry=#{sentry.env || "on"}" if sentry
-    parts << "otel=on" if otel
-    parts << "default_user=#{default_user}" if default_user
-    parts << "commit=#{commit_sha}" if commit_sha
-    parts << "change=#{change_id}" if change_id
-    parts << "built=#{build_date}" if build_date
-    "Config(#{parts.join(", ")})"
-  end
+  class Config
+    SentryConfig = Data.define(
+      :dsn, #: String
+      :env, #: String?
+    )
 
-  #: (?Hash[String, String] env) -> Config
-  def self.from_env(env = ENV)
-    sentry_dsn = env["SENTRY_DSN"]
-    otel_endpoint = env["OTEL_EXPORTER_OTLP_ENDPOINT"]
-    new(
-      database_url: env.fetch("DATABASE_URL") { "db/ketchup.db" },
-      session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
-      auth_header: env.fetch("AUTH_HEADER", "Remote-User"),
-      sentry: sentry_dsn ? SentryConfig.new(dsn: sentry_dsn, env: env["SENTRY_ENV"]) : nil,
-      otel: otel_endpoint ? OtelConfig.new(endpoint: otel_endpoint) : nil,
-      default_user: env["DEFAULT_USER"],
-      commit_sha: env["COMMIT_SHA"],
-      change_id: env["CHANGE_ID"]&.slice(0, 8),
-      build_date: env["BUILD_DATE"]
+    OtelConfig = Data.define(
+      :endpoint, #: String
     )
+
+    #: () -> String
+    def to_s
+      parts = ["database=#{database_url}"]
+      parts << "auth=#{auth_header}"
+      parts << "sentry=#{sentry.env || "on"}" if sentry
+      parts << "otel=on" if otel
+      parts << "default_user=#{default_user}" if default_user
+      parts << "commit=#{commit_sha}" if commit_sha
+      parts << "change=#{change_id}" if change_id
+      parts << "built=#{build_date}" if build_date
+      "Config(#{parts.join(", ")})"
+    end
+
+    #: (?Hash[String, String] env) -> Config
+    def self.from_env(env = ENV)
+      sentry_dsn = env["SENTRY_DSN"]
+      otel_endpoint = env["OTEL_EXPORTER_OTLP_ENDPOINT"]
+      new(
+        database_url: env.fetch("DATABASE_URL") { "db/ketchup.db" },
+        session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
+        auth_header: env.fetch("AUTH_HEADER", "Remote-User"),
+        sentry: sentry_dsn ? SentryConfig.new(dsn: sentry_dsn, env: env["SENTRY_ENV"]) : nil,
+        otel: otel_endpoint ? OtelConfig.new(endpoint: otel_endpoint) : nil,
+        default_user: env["DEFAULT_USER"],
+        commit_sha: env["COMMIT_SHA"],
+        change_id: env["CHANGE_ID"]&.slice(0, 8),
+        build_date: env["BUILD_DATE"]
+      )
+    end
   end
-end
 
-CONFIG = Config.from_env
+  CONFIG = Config.from_env
+end
diff --git a/lib/ketchup/db.rb b/lib/ketchup/db.rb
index 7deb168..2a74ce0 100644
--- a/lib/ketchup/db.rb
+++ b/lib/ketchup/db.rb
@@ -4,6 +4,8 @@ require "sequel"
 
 require_relative "config"
 
-DB = Sequel.sqlite(CONFIG.database_url)
-Sequel.extension :migration
-Sequel::Migrator.run(DB, File.expand_path("../../db/migrate", __dir__))
+module Ketchup
+  DB = Sequel.sqlite(CONFIG.database_url)
+  Sequel.extension :migration
+  Sequel::Migrator.run(DB, File.expand_path("../../db/migrate", __dir__))
+end
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index a627cad..cfbfce6 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -2,135 +2,137 @@
 
 require_relative "db"
 
-Sequel::Model.plugin :timestamps, update_on_create: true
-Sequel::Model.plugin :sole
-Sequel::Model.plugin :many_through_many
-
-class User < Sequel::Model
-  one_to_many :series
-  many_through_many :tasks, [[:series, :user_id, :id]], right_primary_key: :series_id
-
-  def active_tasks
-    tasks_dataset
-      .where(completed_at: nil)
-      .select_all(:tasks)
-      .select_append(
-        Sequel[:series][:note],
-        Sequel[:series][:interval_unit],
-        Sequel[:series][:interval_count]
-      )
-  end
+module Ketchup
+  Sequel::Model.plugin :timestamps, update_on_create: true
+  Sequel::Model.plugin :sole
+  Sequel::Model.plugin :many_through_many
+
+  class User < Sequel::Model
+    one_to_many :series
+    many_through_many :tasks, [[:series, :user_id, :id]], right_primary_key: :series_id
+
+    def active_tasks
+      tasks_dataset
+        .where(completed_at: nil)
+        .select_all(:tasks)
+        .select_append(
+          Sequel[:series][:note],
+          Sequel[:series][:interval_unit],
+          Sequel[:series][:interval_count]
+        )
+    end
 
-  def overdue_tasks
-    active_tasks.where { due_date < Date.today }
-  end
+    def overdue_tasks
+      active_tasks.where { due_date < Date.today }
+    end
 
-  def upcoming_tasks
-    active_tasks.where { due_date >= Date.today }.order(:due_date)
+    def upcoming_tasks
+      active_tasks.where { due_date >= Date.today }.order(:due_date)
+    end
   end
-end
 
-class Series < Sequel::Model
-  many_to_one :user
-  one_to_many :tasks
+  class Series < Sequel::Model
+    many_to_one :user
+    one_to_many :tasks
 
-  INTERVAL_UNITS = %w[day week month quarter year].freeze
+    INTERVAL_UNITS = %w[day week month quarter year].freeze
 
-  def active_task
-    tasks_dataset.where(completed_at: nil).first
-  end
+    def active_task
+      tasks_dataset.where(completed_at: nil).first
+    end
 
-  def completed_tasks
-    tasks_dataset
-      .exclude(completed_at: nil)
-      .order(Sequel.desc(:completed_at))
-      .select(:id, :due_date, :completed_at, :note)
-      .all
-  end
+    def completed_tasks
+      tasks_dataset
+        .exclude(completed_at: nil)
+        .order(Sequel.desc(:completed_at))
+        .select(:id, :due_date, :completed_at, :note)
+        .all
+    end
 
-  def completion_stats
-    completed = completed_tasks
-    return { streak: 0, on_time_pct: 100, total: 0 } if completed.empty?
+    def completion_stats
+      completed = completed_tasks
+      return { streak: 0, on_time_pct: 100, total: 0 } if completed.empty?
 
-    streak = 0
-    on_time = 0
-    completed.each_with_index do |t, i|
-      on_time += 1 if t[:completed_at].to_date <= t[:due_date]
-      streak += 1 if i == streak && t[:completed_at].to_date <= t[:due_date]
-    end
+      streak = 0
+      on_time = 0
+      completed.each_with_index do |t, i|
+        on_time += 1 if t[:completed_at].to_date <= t[:due_date]
+        streak += 1 if i == streak && t[:completed_at].to_date <= t[:due_date]
+      end
 
-    { streak: streak, on_time_pct: (on_time * 100.0 / completed.size).round, total: completed.size }
-  end
+      { streak: streak, on_time_pct: (on_time * 100.0 / completed.size).round, total: completed.size }
+    end
 
-  def next_due_date(completed_on)
-    case interval_unit
-    when "day"
-      completed_on + interval_count
-    when "week"
-      completed_on + (7 * interval_count)
-    when "month"
-      completed_on >> interval_count
-    when "quarter"
-      completed_on >> (3 * interval_count)
-    when "year"
-      completed_on >> (12 * interval_count)
-    else
-      fail
+    def next_due_date(completed_on)
+      case interval_unit
+      when "day"
+        completed_on + interval_count
+      when "week"
+        completed_on + (7 * interval_count)
+      when "month"
+        completed_on >> interval_count
+      when "quarter"
+        completed_on >> (3 * interval_count)
+      when "year"
+        completed_on >> (12 * interval_count)
+      else
+        fail
+      end
     end
-  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
+    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
-end
 
-class Task < Sequel::Model
-  many_to_one :series
-
-  # Fixed day approximations for urgency scoring. complete! uses calendar
-  # month arithmetic (Date#>>) for advancement so "1 month" lands on the
-  # same day-of-month. Urgency only needs a rough ratio, so fixed counts
-  # are fine and avoid coupling to a specific start date.
-  INTERVAL_DAYS = {
-    "day" => 1, "week" => 7, "month" => 30, "quarter" => 91, "year" => 365
-  }.freeze
-
-  def urgency
-    days_overdue = Date.today - self[:due_date]
-    return 0 if days_overdue <= 0
-
-    count = self[:interval_count] || series.interval_count
-    unit = self[:interval_unit] || series.interval_unit
-    interval = count * INTERVAL_DAYS.fetch(unit)
-    days_overdue.to_f / interval
-  end
+  class Task < Sequel::Model
+    many_to_one :series
+
+    # Fixed day approximations for urgency scoring. complete! uses calendar
+    # month arithmetic (Date#>>) for advancement so "1 month" lands on the
+    # same day-of-month. Urgency only needs a rough ratio, so fixed counts
+    # are fine and avoid coupling to a specific start date.
+    INTERVAL_DAYS = {
+      "day" => 1, "week" => 7, "month" => 30, "quarter" => 91, "year" => 365
+    }.freeze
+
+    def urgency
+      days_overdue = Date.today - self[:due_date]
+      return 0 if days_overdue <= 0
+
+      count = self[:interval_count] || series.interval_count
+      unit = self[:interval_unit] || series.interval_unit
+      interval = count * INTERVAL_DAYS.fetch(unit)
+      days_overdue.to_f / interval
+    end
 
-  def complete!(completed_on:)
-    DB.transaction do
-      update(completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day))
-      Task.create(series_id: series.id, due_date: series.next_due_date(completed_on))
+    def complete!(completed_on:)
+      DB.transaction do
+        update(completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day))
+        Task.create(series_id: series.id, due_date: series.next_due_date(completed_on))
+      end
     end
-  end
 
-  def undo_complete!
-    DB.transaction do
-      next_task = series.active_task
-      next_task.destroy if next_task
-      update(completed_at: nil)
+    def undo_complete!
+      DB.transaction do
+        next_task = series.active_task
+        next_task.destroy if next_task
+        update(completed_at: nil)
+      end
     end
   end
 end
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 450ca6a..9c692d1 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -5,136 +5,138 @@ require "phlex"
 require_relative "layout"
 require_relative "task_card"
 
-module Views
-  INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
+module Ketchup
+  module Views
+    INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
 
-  AGENDA_DAYS = 7
+    AGENDA_DAYS = 7
 
-  class Dashboard < Phlex::HTML
-    def initialize(current_user:, csrf:, flash: nil)
-      @current_user = current_user
-      @csrf = csrf
-      @flash = flash
-    end
+    class Dashboard < Phlex::HTML
+      def initialize(current_user:, csrf:, flash: nil)
+        @current_user = current_user
+        @csrf = csrf
+        @flash = flash
+      end
 
-    def view_template
-      overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
-      upcoming = @current_user.upcoming_tasks.all
+      def view_template
+        overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
+        upcoming = @current_user.upcoming_tasks.all
 
-      render Layout.new(current_user: @current_user, flash: @flash) do
-        div(class: "dashboard") do
-          div(class: "column-overdue") do
-            render_focus(overdue)
-            render_overdue(overdue.drop(1))
-          end
-          div(class: "column-agenda") do
-            render_agenda(upcoming, overdue_count: overdue.size)
+        render Layout.new(current_user: @current_user, flash: @flash) do
+          div(class: "dashboard") do
+            div(class: "column-overdue") do
+              render_focus(overdue)
+              render_overdue(overdue.drop(1))
+            end
+            div(class: "column-agenda") do
+              render_agenda(upcoming, overdue_count: overdue.size)
+            end
           end
         end
       end
-    end
 
-    private
+      private
 
-    def render_focus(overdue)
-      if overdue.empty?
-        section(class: "section section--focus") do
-          p(class: "empty") { "All caught up!" }
+      def render_focus(overdue)
+        if overdue.empty?
+          section(class: "section section--focus") do
+            p(class: "empty") { "All caught up!" }
+          end
+          return
         end
-        return
-      end
 
-      task = overdue.first
+        task = overdue.first
 
-      section(class: "section section--focus") do
-        div(class: "section-header") do
-          h2(class: "section-title") do
-            span(class: "section-title-text") { "Next up" }
+        section(class: "section section--focus") do
+          div(class: "section-header") do
+            h2(class: "section-title") do
+              span(class: "section-title-text") { "Next up" }
+            end
           end
+          render TaskCard.new(task: task, csrf: @csrf, overdue: true)
         end
-        render TaskCard.new(task: task, csrf: @csrf, overdue: true)
       end
-    end
 
-    def render_overdue(tasks)
-      return if tasks.empty?
+      def render_overdue(tasks)
+        return if tasks.empty?
 
-      section(class: "section section--overdue") do
-        div(class: "section-header") do
-          h2(class: "section-title") do
-            span(class: "section-title-text") { "Overdue (#{tasks.size})" }
+        section(class: "section section--overdue") do
+          div(class: "section-header") do
+            h2(class: "section-title") do
+              span(class: "section-title-text") { "Overdue (#{tasks.size})" }
+            end
           end
-        end
 
-        ul(class: "task-list") do
-          tasks.each do |task|
-            li(class: "task-item") do
-              render TaskCard.new(task: task, csrf: @csrf, overdue: true)
+          ul(class: "task-list") do
+            tasks.each do |task|
+              li(class: "task-item") do
+                render TaskCard.new(task: task, csrf: @csrf, overdue: true)
+              end
             end
           end
         end
       end
-    end
 
-    def render_agenda(upcoming, overdue_count: 0)
-      tasks_by_date = {}
-      upcoming.each { |t| (tasks_by_date[t[:due_date]] ||= []) << t }
+      def render_agenda(upcoming, overdue_count: 0)
+        tasks_by_date = {}
+        upcoming.each { |t| (tasks_by_date[t[:due_date]] ||= []) << t }
 
-      today = Date.today
+        today = Date.today
 
-      section(class: "section section--agenda") do
-        div(class: "agenda-week") do
-          if overdue_count > 0
-            div(class: "agenda-week-day agenda-week-day--overdue") do
-              span(class: "agenda-week-label") { "!" }
-              span(class: "agenda-week-count") { overdue_count.to_s }
+        section(class: "section section--agenda") do
+          div(class: "agenda-week") do
+            if overdue_count > 0
+              div(class: "agenda-week-day agenda-week-day--overdue") do
+                span(class: "agenda-week-label") { "!" }
+                span(class: "agenda-week-count") { overdue_count.to_s }
+              end
             end
-          end
 
-          AGENDA_DAYS.times do |i|
-            date = today + i
-            count = (tasks_by_date[date] || []).size
-            classes = ["agenda-week-day"]
-            classes << "agenda-week-day--today" if i == 0
-            classes << "agenda-week-day--has-tasks" if count > 0
+            AGENDA_DAYS.times do |i|
+              date = today + i
+              count = (tasks_by_date[date] || []).size
+              classes = ["agenda-week-day"]
+              classes << "agenda-week-day--today" if i == 0
+              classes << "agenda-week-day--has-tasks" if count > 0
 
-            div(class: classes) do
-              span(class: "agenda-week-label") { date.strftime("%a") }
-              span(class: "agenda-week-count") { count.to_s } if count > 0
+              div(class: classes) do
+                span(class: "agenda-week-label") { date.strftime("%a") }
+                span(class: "agenda-week-count") { count.to_s } if count > 0
+              end
             end
           end
-        end
 
-        tasks_by_date.keys.sort.each do |date|
-          day_tasks = tasks_by_date[date]
-          offset = (date - today).to_i
-
-          div(class: ["agenda-day", ("agenda-day--today" if offset == 0)]) do
-            div(class: "agenda-day-header") { friendly_day(date, offset) }
-            day_tasks.each do |task|
-              task_name = task[:note].lines.first&.strip || task[:note]
-              complete_path = "/series/#{task[:series_id]}/tasks/#{task[:id]}/complete"
-
-              div(class: "agenda-task") do
-                form(method: "post", action: complete_path, class: "complete-form") do
-                  input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
-                  input(type: "hidden", name: "return_to", value: "/")
-                  button(type: "submit", title: "Complete", class: "complete-btn",
-                         **{ "aria-label": "Complete #{task_name}" }) { "\u2713" }
+          tasks_by_date.keys.sort.each do |date|
+            day_tasks = tasks_by_date[date]
+            offset = (date - today).to_i
+
+            div(class: ["agenda-day", ("agenda-day--today" if offset == 0)]) do
+              div(class: "agenda-day-header") { friendly_day(date, offset) }
+              day_tasks.each do |task|
+                task_name = task[:note].lines.first&.strip || task[:note]
+                complete_path = "/series/#{task[:series_id]}/tasks/#{task[:id]}/complete"
+
+                div(class: "agenda-task") do
+                  form(method: "post", action: complete_path, class: "complete-form") do
+                    input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
+                    input(type: "hidden", name: "return_to", value: "/")
+                    button(type: "submit", title: "Complete", class: "complete-btn",
+                           **{ "aria-label": "Complete #{task_name}" }) { "\u2713" }
+                  end
+                  a(href: "/series/#{task[:series_id]}", class: "agenda-day-pill") { task_name }
                 end
-                a(href: "/series/#{task[:series_id]}", class: "agenda-day-pill") { task_name }
               end
             end
           end
         end
       end
-    end
 
-    def friendly_day(date, offset)
-      case offset
-      when 0 then "Today"
-      when 1 then "Tomorrow"
-      else date.strftime("%a, %b %-d")
+      def friendly_day(date, offset)
+        case offset
+        when 0 then "Today"
+        when 1 then "Tomorrow"
+        else date.strftime("%a, %b %-d")
+        end
       end
     end
   end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index dd1ad9b..0832a24 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -3,113 +3,115 @@
 require "digest"
 require "phlex"
 
-module Views
-  class Layout < Phlex::HTML
-    ASSET_VERSIONS = begin
-      root = File.expand_path("../../../public", __dir__)
-      %w[
-        /css/reset.css
-        /css/utopia.css
-        /css/app.css
-        /js/app.js
-      ].to_h {|path|
-        [path, Digest::MD5.file(File.join(root, path)).hexdigest[0, 10]]
-      }.freeze
-    end
+module Ketchup
+  module Views
+    class Layout < Phlex::HTML
+      ASSET_VERSIONS = begin
+        root = File.expand_path("../../../public", __dir__)
+        %w[
+          /css/reset.css
+          /css/utopia.css
+          /css/app.css
+          /js/app.js
+        ].to_h {|path|
+          [path, Digest::MD5.file(File.join(root, path)).hexdigest[0, 10]]
+        }.freeze
+      end
 
-    def initialize(current_user:, title: "Ketchup", active_view: nil, flash: nil)
-      @current_user = current_user
-      @title = title
-      @active_view = active_view
-      @flash = flash
-    end
+      def initialize(current_user:, title: "Ketchup", active_view: nil, flash: nil)
+        @current_user = current_user
+        @title = title
+        @active_view = active_view
+        @flash = flash
+      end
 
-    def view_template(&)
-      doctype
-      html(lang: "en") do
-        head do
-          meta(charset: "utf-8")
-          meta(name: "viewport", content: "width=device-width, initial-scale=1")
-          title { @title }
-          link(rel: "icon", href: "/favicon.svg", type: "image/svg+xml")
-          link(rel: "stylesheet", href: asset_path("/css/reset.css"))
-          link(rel: "stylesheet", href: asset_path("/css/utopia.css"))
-          link(rel: "stylesheet", href: asset_path("/css/app.css"))
-          link(rel: "preconnect", href: "https://fonts.googleapis.com")
-          link(rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: true)
-          link(rel: "stylesheet",
-               href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap")
-          script(src: "https://unpkg.com/overtype@2.3.4/dist/overtype.min.js",
-                 integrity: "sha384-oO6wSYxEDXeZSOcEf28Yv/b18PqYxmhTbhc9Qfn8PSQJxv82nH/6Awq3eCof6VcA",
-                 crossorigin: "anonymous")
-          script(src: asset_path("/js/app.js"), defer: true)
-          script(src: "https://cdn.jsdelivr.net/npm/alpinejs@3.15.8/dist/cdn.min.js",
-                 integrity: "sha384-LXWjKwDZz29o7TduNe+r/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi",
-                 crossorigin: "anonymous", defer: true)
-        end
-        body do
-          header(class: "site-header") do
-            a(href: "/", class: "site-name") { "Ketchup" }
-            nav(class: "site-nav") do
-              a(
-                href: "/series/new",
-                class: ["header-action", ("header-action--active" if @active_view == :new)]
-              ) { "+New" }
+      def view_template(&)
+        doctype
+        html(lang: "en") do
+          head do
+            meta(charset: "utf-8")
+            meta(name: "viewport", content: "width=device-width, initial-scale=1")
+            title { @title }
+            link(rel: "icon", href: "/favicon.svg", type: "image/svg+xml")
+            link(rel: "stylesheet", href: asset_path("/css/reset.css"))
+            link(rel: "stylesheet", href: asset_path("/css/utopia.css"))
+            link(rel: "stylesheet", href: asset_path("/css/app.css"))
+            link(rel: "preconnect", href: "https://fonts.googleapis.com")
+            link(rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: true)
+            link(rel: "stylesheet",
+                 href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap")
+            script(src: "https://unpkg.com/overtype@2.3.4/dist/overtype.min.js",
+                   integrity: "sha384-oO6wSYxEDXeZSOcEf28Yv/b18PqYxmhTbhc9Qfn8PSQJxv82nH/6Awq3eCof6VcA",
+                   crossorigin: "anonymous")
+            script(src: asset_path("/js/app.js"), defer: true)
+            script(src: "https://cdn.jsdelivr.net/npm/alpinejs@3.15.8/dist/cdn.min.js",
+                   integrity: "sha384-LXWjKwDZz29o7TduNe+r/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi",
+                   crossorigin: "anonymous", defer: true)
+          end
+          body do
+            header(class: "site-header") do
+              a(href: "/", class: "site-name") { "Ketchup" }
+              nav(class: "site-nav") do
+                a(
+                  href: "/series/new",
+                  class: ["header-action", ("header-action--active" if @active_view == :new)]
+                ) { "+New" }
+              end
+              a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
             end
-            a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
+            yield
+            render_flash if @flash
+            render_footer
           end
-          yield
-          render_flash if @flash
-          render_footer
         end
       end
-    end
 
-    private
+      private
 
-    def render_flash
-      undo_path = @flash["undo_path"]
+      def render_flash
+        undo_path = @flash["undo_path"]
 
-      div(class: "flash-bar", data: { undo_path: undo_path }.compact) do
-        button(class: "flash-undo-btn", hidden: !undo_path) { "Undo" }
-        span(class: "flash-message") { @flash["message"] }
-        button(class: "flash-close-btn", **{ "aria-label": "Dismiss" }) { "\u00d7" }
-        script { raw safe(flash_script) }
+        div(class: "flash-bar", data: { undo_path: undo_path }.compact) do
+          button(class: "flash-undo-btn", hidden: !undo_path) { "Undo" }
+          span(class: "flash-message") { @flash["message"] }
+          button(class: "flash-close-btn", **{ "aria-label": "Dismiss" }) { "\u00d7" }
+          script { raw safe(flash_script) }
+        end
       end
-    end
 
-    def flash_script
-      <<~JS
-        (function() {
-          var bar = document.currentScript.parentElement;
-          var undo = bar.querySelector('.flash-undo-btn');
-          var close = bar.querySelector('.flash-close-btn');
-          var path = bar.dataset.undoPath;
-          function dismiss() { bar.remove(); }
-          if (undo && path) {
-            undo.addEventListener('click', function() {
-              fetch(path, {method: 'DELETE'}).then(function() { location.reload(); });
-            });
-          }
-          close.addEventListener('click', dismiss);
-          setTimeout(dismiss, 8000);
-        })();
-      JS
-    end
+      def flash_script
+        <<~JS
+          (function() {
+            var bar = document.currentScript.parentElement;
+            var undo = bar.querySelector('.flash-undo-btn');
+            var close = bar.querySelector('.flash-close-btn');
+            var path = bar.dataset.undoPath;
+            function dismiss() { bar.remove(); }
+            if (undo && path) {
+              undo.addEventListener('click', function() {
+                fetch(path, {method: 'DELETE'}).then(function() { location.reload(); });
+              });
+            }
+            close.addEventListener('click', dismiss);
+            setTimeout(dismiss, 8000);
+          })();
+        JS
+      end
 
-    def asset_path(path)
-      version = ASSET_VERSIONS[path]
-      version ? "#{path}?v=#{version}" : path
-    end
+      def asset_path(path)
+        version = ASSET_VERSIONS[path]
+        version ? "#{path}?v=#{version}" : path
+      end
 
-    def render_footer
-      config = CONFIG
-      parts = []
-      parts << config.change_id if config.change_id
-      parts << config.commit_sha if config.commit_sha
-      parts << config.build_date if config.build_date
-      footer(class: "site-footer") do
-        plain parts.join(" \u00b7 ")
+      def render_footer
+        config = CONFIG
+        parts = []
+        parts << config.change_id if config.change_id
+        parts << config.commit_sha if config.commit_sha
+        parts << config.build_date if config.build_date
+        footer(class: "site-footer") do
+          plain parts.join(" \u00b7 ")
+        end
       end
     end
   end
diff --git a/lib/ketchup/views/series/new.rb b/lib/ketchup/views/series/new.rb
index 76d2fcc..25471fb 100644
--- a/lib/ketchup/views/series/new.rb
+++ b/lib/ketchup/views/series/new.rb
@@ -4,56 +4,58 @@ require "phlex"
 
 require_relative "../layout"
 
-module Views
-  module Series
-    class New < Phlex::HTML
-      def initialize(current_user:, csrf:)
-        @current_user = current_user
-        @csrf = csrf
-      end
+module Ketchup
+  module Views
+    module Series
+      class New < Phlex::HTML
+        def initialize(current_user:, csrf:)
+          @current_user = current_user
+          @csrf = csrf
+        end
 
-      def view_template
-        render Layout.new(current_user: @current_user, title: "New Series — Ketchup", active_view: :new) do
-          div(class: "dashboard") do
-            div(class: "main-column") do
-              section(class: "section") do
-                div(class: "section-header") do
-                  h2(class: "section-title") do
-                    span(class: "section-title-text") { "New series" }
+        def view_template
+          render Layout.new(current_user: @current_user, title: "New Series — Ketchup", active_view: :new) do
+            div(class: "dashboard") do
+              div(class: "main-column") do
+                section(class: "section") do
+                  div(class: "section-header") do
+                    h2(class: "section-title") do
+                      span(class: "section-title-text") { "New series" }
+                    end
+                    button(
+                      class: "section-edit-btn",
+                      id: "create-series-btn",
+                      disabled: true
+                    ) { "Create" }
                   end
-                  button(
-                    class: "section-edit-btn",
-                    id: "create-series-btn",
-                    disabled: true
-                  ) { "Create" }
-                end
 
-                form(method: "post", action: "/series", id: "new-series-form", class: "form new-series-form", novalidate: true) do
-                  input(type: "hidden", name: "_csrf", value: @csrf.call("/series"))
-                  div(class: "field") do
-                    label(for: "series-note-editor") { "Note" }
-                    div(id: "series-note-editor", class: "series-note series-note--editable")
-                  end
+                  form(method: "post", action: "/series", id: "new-series-form", class: "form new-series-form", novalidate: true) do
+                    input(type: "hidden", name: "_csrf", value: @csrf.call("/series"))
+                    div(class: "field") do
+                      label(for: "series-note-editor") { "Note" }
+                      div(id: "series-note-editor", class: "series-note series-note--editable")
+                    end
 
-                  div(class: "field") do
-                    label(for: "interval_count") { "Repeat every" }
-                    div(class: "interval") do
-                      input(
-                        type: "number", id: "interval_count", name: "interval_count",
-                        min: 1, value: 1, required: true
-                      )
-                      select(id: "interval_unit", name: "interval_unit", required: true) do
-                        Views::INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
+                    div(class: "field") do
+                      label(for: "interval_count") { "Repeat every" }
+                      div(class: "interval") do
+                        input(
+                          type: "number", id: "interval_count", name: "interval_count",
+                          min: 1, value: 1, required: true
+                        )
+                        select(id: "interval_unit", name: "interval_unit", required: true) do
+                          Views::INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
+                        end
                       end
                     end
-                  end
 
-                  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
-                    )
+                    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
                   end
                 end
               end
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index 8af2139..fe83f3b 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -4,172 +4,174 @@ require "phlex"
 
 require_relative "../layout"
 
-module Views
-  module Series
-    class Show < Phlex::HTML
-      def initialize(series:, current_user:, csrf:)
-        @series = series
-        @current_user = current_user
-        @csrf = csrf
-      end
-
-      def view_template
-        active_task = @series.active_task
-
-        render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil) do
-          div(class: "dashboard") do
-            div(class: "main-column") do
-              section(class: "section", "x-data": "{ editing: false }") do
-                div(class: "section-header") do
-                  h2(class: "section-title") do
-                    span(class: "section-title-text") { "Series" }
-                  end
-                  button(
-                    class: "section-edit-btn",
-                    "x-show": "!editing",
-                    "x-on:click": "editing = true; $dispatch('start-editing')"
-                  ) do
-                    plain "Edit"
-                  end
-                  button(
-                    class: "section-edit-btn section-edit-btn--cancel",
-                    "x-show": "editing",
-                    "x-cloak": true,
-                    "x-on:click": "editing = false; location.reload()"
-                  ) do
-                    plain "Cancel"
-                  end
-                  button(
-                    class: "section-edit-btn",
-                    "x-show": "editing",
-                    "x-cloak": true,
-                    "x-on:click": "editing = false; $dispatch('stop-editing')"
-                  ) do
-                    plain "Save"
-                  end
-                end
+module Ketchup
+  module Views
+    module Series
+      class Show < Phlex::HTML
+        def initialize(series:, current_user:, csrf:)
+          @series = series
+          @current_user = current_user
+          @csrf = csrf
+        end
 
-                div(class: "series-note", id: "series-note-detail",
-                    "x-bind:class": "{ 'series-note--editable': editing }",
-                    "data-value": @series.note || "",
-                    "data-series-id": @series.id.to_s)
+        def view_template
+          active_task = @series.active_task
 
-                dl(class: "detail-fields") do
-                  dt { "Repeat every" }
-                  dd("x-show": "!editing") do
-                    plain interval_text(@series.interval_count, @series.interval_unit)
-                  end
-                  dd(
-                    class: "detail-edit-interval",
-                    "x-show": "editing",
-                    "x-cloak": true,
-                    "x-data": "intervalEditor(#{@series.id}, #{@series.interval_count}, '#{@series.interval_unit}')"
-                  ) do
-                    input(
-                      type: "number",
-                      class: "detail-input detail-input-count",
-                      min: 1,
-                      "x-model.number": "count",
-                      "x-on:change": "save()"
-                    )
-                    select(
-                      class: "detail-input detail-input-unit",
-                      "x-model": "unit",
-                      "x-on:change": "save()"
+          render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil) do
+            div(class: "dashboard") do
+              div(class: "main-column") do
+                section(class: "section", "x-data": "{ editing: false }") do
+                  div(class: "section-header") do
+                    h2(class: "section-title") do
+                      span(class: "section-title-text") { "Series" }
+                    end
+                    button(
+                      class: "section-edit-btn",
+                      "x-show": "!editing",
+                      "x-on:click": "editing = true; $dispatch('start-editing')"
                     ) do
-                      INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
+                      plain "Edit"
+                    end
+                    button(
+                      class: "section-edit-btn section-edit-btn--cancel",
+                      "x-show": "editing",
+                      "x-cloak": true,
+                      "x-on:click": "editing = false; location.reload()"
+                    ) do
+                      plain "Cancel"
+                    end
+                    button(
+                      class: "section-edit-btn",
+                      "x-show": "editing",
+                      "x-cloak": true,
+                      "x-on:click": "editing = false; $dispatch('stop-editing')"
+                    ) do
+                      plain "Save"
                     end
                   end
 
-                end
+                  div(class: "series-note", id: "series-note-detail",
+                      "x-bind:class": "{ 'series-note--editable': editing }",
+                      "data-value": @series.note || "",
+                      "data-series-id": @series.id.to_s)
 
-                if active_task
-                  complete_path = "/series/#{@series.id}/tasks/#{active_task.id}/complete"
-                  div(class: "current-task") do
-                    div(class: "section-header") do
-                      h2(class: "section-title") do
-                        span(class: "section-title-text") { "Current task" }
-                      end
-                      form(method: "post", action: complete_path, class: "complete-form") do
-                        input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
-                        input(type: "hidden", name: "return_to", value: "/series/#{@series.id}")
-                        button(type: "submit", class: "section-edit-btn") { "Complete" }
+                  dl(class: "detail-fields") do
+                    dt { "Repeat every" }
+                    dd("x-show": "!editing") do
+                      plain interval_text(@series.interval_count, @series.interval_unit)
+                    end
+                    dd(
+                      class: "detail-edit-interval",
+                      "x-show": "editing",
+                      "x-cloak": true,
+                      "x-data": "intervalEditor(#{@series.id}, #{@series.interval_count}, '#{@series.interval_unit}')"
+                    ) do
+                      input(
+                        type: "number",
+                        class: "detail-input detail-input-count",
+                        min: 1,
+                        "x-model.number": "count",
+                        "x-on:change": "save()"
+                      )
+                      select(
+                        class: "detail-input detail-input-unit",
+                        "x-model": "unit",
+                        "x-on:change": "save()"
+                      ) do
+                        INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
                       end
                     end
-                    dl(class: "detail-fields", "x-data": "dueDateEditor(#{@series.id}, #{active_task[:id]}, '#{active_task[:due_date]}')") do
-                      dt { "Due date" }
-                      dd do
-                        span(
-                          class: "task-history-date",
-                          "x-show": "!editingDate",
-                          "x-on:click": "editingDate = true; $nextTick(() => $refs.dateInput.focus())",
-                          "x-text": "new Date(dueDate + 'T00:00').toLocaleDateString()"
-                        ) { active_task[:due_date].to_s }
-                        input(
-                          type: "date",
-                          class: "detail-input detail-input-date",
-                          "x-show": "editingDate",
-                          "x-cloak": true,
-                          "x-model": "dueDate",
-                          "x-ref": "dateInput",
-                          "x-on:change": "save()",
-                          "x-on:keydown.escape": "dueDate = '#{active_task[:due_date]}'; editingDate = false"
-                        )
+
+                  end
+
+                  if active_task
+                    complete_path = "/series/#{@series.id}/tasks/#{active_task.id}/complete"
+                    div(class: "current-task") do
+                      div(class: "section-header") do
+                        h2(class: "section-title") do
+                          span(class: "section-title-text") { "Current task" }
+                        end
+                        form(method: "post", action: complete_path, class: "complete-form") do
+                          input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
+                          input(type: "hidden", name: "return_to", value: "/series/#{@series.id}")
+                          button(type: "submit", class: "section-edit-btn") { "Complete" }
+                        end
                       end
+                      dl(class: "detail-fields", "x-data": "dueDateEditor(#{@series.id}, #{active_task[:id]}, '#{active_task[:due_date]}')") do
+                        dt { "Due date" }
+                        dd do
+                          span(
+                            class: "task-history-date",
+                            "x-show": "!editingDate",
+                            "x-on:click": "editingDate = true; $nextTick(() => $refs.dateInput.focus())",
+                            "x-text": "new Date(dueDate + 'T00:00').toLocaleDateString()"
+                          ) { active_task[:due_date].to_s }
+                          input(
+                            type: "date",
+                            class: "detail-input detail-input-date",
+                            "x-show": "editingDate",
+                            "x-cloak": true,
+                            "x-model": "dueDate",
+                            "x-ref": "dateInput",
+                            "x-on:change": "save()",
+                            "x-on:keydown.escape": "dueDate = '#{active_task[:due_date]}'; editingDate = false"
+                          )
+                        end
 
-                      if active_task.urgency > 0
-                        dt(class: "detail-overdue") { "Urgency" }
-                        dd(class: "detail-overdue") { "#{format("%.1f", active_task.urgency)}x" }
+                        if active_task.urgency > 0
+                          dt(class: "detail-overdue") { "Urgency" }
+                          dd(class: "detail-overdue") { "#{format("%.1f", active_task.urgency)}x" }
+                        end
                       end
                     end
                   end
-                end
 
-                unless @series.completed_tasks.empty?
-                  div(class: "task-history") do
-                    div(class: "section-header") do
-                      h2(class: "section-title") do
-                        span(class: "section-title-text") { "History" }
+                  unless @series.completed_tasks.empty?
+                    div(class: "task-history") do
+                      div(class: "section-header") do
+                        h2(class: "section-title") do
+                          span(class: "section-title-text") { "History" }
+                        end
                       end
-                    end
-                    ul do
-                      @series.completed_tasks.each do |ct|
-                        completed_date = ct[:completed_at].strftime("%Y-%m-%d")
-                        li(
-                          class: "task-history-item",
-                          "x-data": "{ ...historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"}), ...completedDateEditor(#{@series.id}, #{ct[:id]}, '#{completed_date}') }"
-                        ) do
-                          div(class: "task-history-row") do
-                            span(class: "task-history-check") { "✓" }
-                            span(
-                              class: "task-history-date",
-                              "x-show": "!editingDate",
-                              "x-on:click": "editingDate = true; $nextTick(() => $refs.dateInput.focus())",
-                              "x-text": "new Date(completedDate + 'T00:00').toLocaleDateString()"
-                            ) { completed_date }
-                            input(
-                              type: "date",
-                              class: "task-history-date-input",
-                              "x-show": "editingDate",
-                              "x-cloak": true,
-                              "x-model": "completedDate",
-                              "x-ref": "dateInput",
-                              "x-on:blur": "save()",
-                              "x-on:keydown.enter": "$el.blur()",
-                              "x-on:keydown.escape": "cancel()"
+                      ul do
+                        @series.completed_tasks.each do |ct|
+                          completed_date = ct[:completed_at].strftime("%Y-%m-%d")
+                          li(
+                            class: "task-history-item",
+                            "x-data": "{ ...historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"}), ...completedDateEditor(#{@series.id}, #{ct[:id]}, '#{completed_date}') }"
+                          ) do
+                            div(class: "task-history-row") do
+                              span(class: "task-history-check") { "✓" }
+                              span(
+                                class: "task-history-date",
+                                "x-show": "!editingDate",
+                                "x-on:click": "editingDate = true; $nextTick(() => $refs.dateInput.focus())",
+                                "x-text": "new Date(completedDate + 'T00:00').toLocaleDateString()"
+                              ) { completed_date }
+                              input(
+                                type: "date",
+                                class: "task-history-date-input",
+                                "x-show": "editingDate",
+                                "x-cloak": true,
+                                "x-model": "completedDate",
+                                "x-ref": "dateInput",
+                                "x-on:blur": "save()",
+                                "x-on:keydown.enter": "$el.blur()",
+                                "x-on:keydown.escape": "cancel()"
+                              )
+                              span(
+                                class: "task-history-add-note",
+                                "x-show": "!hasNote && !editing",
+                                "x-on:click": "edit()"
+                              ) { "add a note..." }
+                            end
+                            div(
+                              class: "task-history-note-editor",
+                              "data-value": ct[:note] || "",
+                              "x-show": "hasNote || editing",
+                              "x-ref": "editor"
                             )
-                            span(
-                              class: "task-history-add-note",
-                              "x-show": "!hasNote && !editing",
-                              "x-on:click": "edit()"
-                            ) { "add a note..." }
                           end
-                          div(
-                            class: "task-history-note-editor",
-                            "data-value": ct[:note] || "",
-                            "x-show": "hasNote || editing",
-                            "x-ref": "editor"
-                          )
                         end
                       end
                     end
@@ -179,16 +181,16 @@ module Views
             end
           end
         end
-      end
 
-      private
+        private
 
-      def note_title
-        @series.note&.lines&.first&.strip || "Series"
-      end
+        def note_title
+          @series.note&.lines&.first&.strip || "Series"
+        end
 
-      def interval_text(count, unit)
-        "#{count} #{count == 1 ? unit : "#{unit}s"}"
+        def interval_text(count, unit)
+          "#{count} #{count == 1 ? unit : "#{unit}s"}"
+        end
       end
     end
   end
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index 7c84427..d550110 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -2,72 +2,74 @@
 
 require "phlex"
 
-module Views
-  class TaskCard < Phlex::HTML
-    def initialize(task:, csrf:, overdue: false)
-      @task = task
-      @csrf = csrf
-      @overdue = overdue
-    end
+module Ketchup
+  module Views
+    class TaskCard < Phlex::HTML
+      def initialize(task:, csrf:, overdue: false)
+        @task = task
+        @csrf = csrf
+        @overdue = overdue
+      end
 
-    def view_template
-      name = @task[:note].lines.first&.strip || @task[:note]
-      complete_path = "/series/#{@task[:series_id]}/tasks/#{@task[:id]}/complete"
+      def view_template
+        name = @task[:note].lines.first&.strip || @task[:note]
+        complete_path = "/series/#{@task[:series_id]}/tasks/#{@task[:id]}/complete"
 
-      div(class: task_classes) do
-        form(method: "post", action: complete_path, class: "complete-form") do
-          input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
-          button(
-            type: "submit", title: "Complete",
-            class: "complete-btn",
-            **{ "aria-label": "Complete #{name}" }
-          ) { "✓" }
-        end
-        div(class: "task-body") do
-          a(
-            href: "/series/#{@task[:series_id]}",
-            class: "task-name stretched-link"
-          ) { name }
-          if @overdue
-            span(class: "task-meta") do
-              plain meta_text
+        div(class: task_classes) do
+          form(method: "post", action: complete_path, class: "complete-form") do
+            input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
+            button(
+              type: "submit", title: "Complete",
+              class: "complete-btn",
+              **{ "aria-label": "Complete #{name}" }
+            ) { "✓" }
+          end
+          div(class: "task-body") do
+            a(
+              href: "/series/#{@task[:series_id]}",
+              class: "task-name stretched-link"
+            ) { name }
+            if @overdue
+              span(class: "task-meta") do
+                plain meta_text
+              end
             end
           end
-        end
-        if @overdue && @task.urgency > 0
-          span(class: "task-urgency") { "#{format("%.1f", @task.urgency)}x" }
+          if @overdue && @task.urgency > 0
+            span(class: "task-urgency") { "#{format("%.1f", @task.urgency)}x" }
+          end
         end
       end
-    end
 
-    private
+      private
 
-    def task_classes
-      classes = ["task-card"]
-      classes << "task-card--overdue" if @overdue
+      def task_classes
+        classes = ["task-card"]
+        classes << "task-card--overdue" if @overdue
 
-      classes
-    end
+        classes
+      end
 
-    def meta_text
-      days = (Date.today - @task[:due_date]).to_i
-      interval_count = @task[:interval_count] || @task.series.interval_count
-      interval_unit = @task[:interval_unit] || @task.series.interval_unit
-      interval = "#{interval_count} #{interval_count == 1 ? interval_unit : "#{interval_unit}s"}"
+      def meta_text
+        days = (Date.today - @task[:due_date]).to_i
+        interval_count = @task[:interval_count] || @task.series.interval_count
+        interval_unit = @task[:interval_unit] || @task.series.interval_unit
+        interval = "#{interval_count} #{interval_count == 1 ? interval_unit : "#{interval_unit}s"}"
 
-      ago = if days == 1
-              "yesterday"
-            elsif days < 7
-              "#{days} days ago"
-            elsif days < 30
-              weeks = days / 7
-              "#{weeks} #{weeks == 1 ? "week" : "weeks"} ago"
-            else
-              months = days / 30
-              "#{months} #{months == 1 ? "month" : "months"} ago"
-            end
+        ago = if days == 1
+                "yesterday"
+              elsif days < 7
+                "#{days} days ago"
+              elsif days < 30
+                weeks = days / 7
+                "#{weeks} #{weeks == 1 ? "week" : "weeks"} ago"
+              else
+                months = days / 30
+                "#{months} #{months == 1 ? "month" : "months"} ago"
+              end
 
-      "every #{interval} · due #{ago}"
+        "every #{interval} · due #{ago}"
+      end
     end
   end
 end
diff --git a/lib/ketchup/views/user/show.rb b/lib/ketchup/views/user/show.rb
index e09c7f5..e094658 100644
--- a/lib/ketchup/views/user/show.rb
+++ b/lib/ketchup/views/user/show.rb
@@ -4,75 +4,77 @@ require "phlex"
 
 require_relative "../layout"
 
-module Views
-  module User
-    class Show < Phlex::HTML
-      def initialize(current_user:, csrf:)
-        @current_user = current_user
-        @csrf = csrf
-      end
+module Ketchup
+  module Views
+    module User
+      class Show < Phlex::HTML
+        def initialize(current_user:, csrf:)
+          @current_user = current_user
+          @csrf = csrf
+        end
 
-      def view_template
-        email_path = "/users/#{@current_user[:id]}/email"
-        email = @current_user[:email]
+        def view_template
+          email_path = "/users/#{@current_user[:id]}/email"
+          email = @current_user[:email]
 
-        render Layout.new(current_user: @current_user, title: "Settings — Ketchup", active_view: nil) do
-          div(class: "dashboard") do
-            div(class: "main-column") do
-              section(class: "section", "x-data": "{ editing: false }") do
-                div(class: "section-header") do
-                  h2(class: "section-title") do
-                    span(class: "section-title-text") { "User Settings" }
-                  end
-                  button(
-                    class: "section-edit-btn",
-                    "x-show": "!editing",
-                    "x-on:click": "editing = true"
-                  ) do
-                    plain "Edit"
-                  end
-                  button(
-                    class: "section-edit-btn section-edit-btn--cancel",
-                    "x-show": "editing",
-                    "x-cloak": true,
-                    "x-on:click": "editing = false; location.reload()"
-                  ) do
-                    plain "Cancel"
-                  end
-                  button(
-                    class: "section-edit-btn",
-                    "x-show": "editing",
-                    "x-cloak": true,
-                    "x-on:click": "editing = false; document.getElementById('user-form').requestSubmit()"
-                  ) do
-                    plain "Save"
+          render Layout.new(current_user: @current_user, title: "Settings — Ketchup", active_view: nil) do
+            div(class: "dashboard") do
+              div(class: "main-column") do
+                section(class: "section", "x-data": "{ editing: false }") do
+                  div(class: "section-header") do
+                    h2(class: "section-title") do
+                      span(class: "section-title-text") { "User Settings" }
+                    end
+                    button(
+                      class: "section-edit-btn",
+                      "x-show": "!editing",
+                      "x-on:click": "editing = true"
+                    ) do
+                      plain "Edit"
+                    end
+                    button(
+                      class: "section-edit-btn section-edit-btn--cancel",
+                      "x-show": "editing",
+                      "x-cloak": true,
+                      "x-on:click": "editing = false; location.reload()"
+                    ) do
+                      plain "Cancel"
+                    end
+                    button(
+                      class: "section-edit-btn",
+                      "x-show": "editing",
+                      "x-cloak": true,
+                      "x-on:click": "editing = false; document.getElementById('user-form').requestSubmit()"
+                    ) do
+                      plain "Save"
+                    end
                   end
-                end
 
-                form(method: "post", action: email_path, id: "user-form", style: "display:none") do
-                  input(type: "hidden", name: "_csrf", value: @csrf.call(email_path))
-                end
+                  form(method: "post", action: email_path, id: "user-form", style: "display:none") do
+                    input(type: "hidden", name: "_csrf", value: @csrf.call(email_path))
+                  end
 
-                dl(class: "detail-fields") do
-                  dt { "Login" }
-                  dd { @current_user[:login] }
+                  dl(class: "detail-fields") do
+                    dt { "Login" }
+                    dd { @current_user[:login] }
 
-                  dt { "Email" }
-                  dd("x-show": "!editing") do
-                    if email
-                      plain email
-                    else
-                      span(class: "detail-placeholder") { "not set" }
+                    dt { "Email" }
+                    dd("x-show": "!editing") do
+                      if email
+                        plain email
+                      else
+                        span(class: "detail-placeholder") { "not set" }
+                      end
+                    end
+                    dd("x-show": "editing", "x-cloak": true) do
+                      input(
+                        type: "email", name: "email",
+                        form: "user-form",
+                        class: "detail-input",
+                        value: email,
+                        placeholder: "for notifications"
+                      )
                     end
-                  end
-                  dd("x-show": "editing", "x-cloak": true) do
-                    input(
-                      type: "email", name: "email",
-                      form: "user-form",
-                      class: "detail-input",
-                      value: email,
-                      placeholder: "for notifications"
-                    )
                   end
                 end
               end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 252ac4a..cfe2111 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -9,207 +9,209 @@ require_relative "views/series/new"
 require_relative "views/series/show"
 require_relative "views/user/show"
 
-class Web < Roda
-  plugin :halt
-  plugin :static, %w[ /css /js /favicon.svg /snapshots ]
-  plugin :all_verbs
-  plugin :sessions, secret: CONFIG.session_secret
-  plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
-  plugin :error_handler do |e|
-    case e
-    when Sequel::NoMatchingRow
-      response.status = 404
-      ""
-    else
-      raise
+module Ketchup
+  class Web < Roda
+    plugin :halt
+    plugin :static, %w[ /css /js /favicon.svg /snapshots ]
+    plugin :all_verbs
+    plugin :sessions, secret: CONFIG.session_secret
+    plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
+    plugin :error_handler do |e|
+      case e
+      when Sequel::NoMatchingRow
+        response.status = 404
+        ""
+      else
+        raise
+      end
     end
-  end
 
-  def current_user
-    rack_header = "HTTP_#{CONFIG.auth_header.upcase.tr("-", "_")}"
-    login = env[rack_header]
-    return unless login
-
-    User.find_or_create(login: login)
-  end
+    def current_user
+      rack_header = "HTTP_#{CONFIG.auth_header.upcase.tr("-", "_")}"
+      login = env[rack_header]
+      return unless login
 
-  route do |r|
-    @user = current_user
-    r.halt 403 unless @user
-
-    check_csrf!
-
-    r.root do
-      flash = session.delete("flash")
-      Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), flash: flash).call
+      User.find_or_create(login: login)
     end
 
-    r.on "users", Integer do |user_id|
-      r.halt 404 unless @user.id == user_id
+    route do |r|
+      @user = current_user
+      r.halt 403 unless @user
 
-      r.get do
-        Views::User::Show.new(current_user: @user, csrf: method(:csrf_token)).call
-      end
+      check_csrf!
 
-      r.post "email" do
-        email = r.params["email"].to_s.strip
-        @user.update(email: email.empty? ? nil : email)
-        r.redirect "/users/#{user_id}"
-      end
-    end
-
-    r.on "series" do
-      r.get "new" do
-        Views::Series::New.new(current_user: @user, csrf: method(:csrf_token)).call
+      r.root do
+        flash = session.delete("flash")
+        Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), flash: flash).call
       end
 
-      r.is 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
-          first_due_date = r.params["first_due_date"].to_s
-
-          r.halt 422 if note.empty?
-          r.halt 422 unless Series::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
+      r.on "users", Integer do |user_id|
+        r.halt 404 unless @user.id == user_id
 
-          series = Series.create_with_first_task(
-            user: @user,
-            note: note,
-            interval_unit: interval_unit,
-            interval_count: interval_count,
-            first_due_date: due_date
-          )
+        r.get do
+          Views::User::Show.new(current_user: @user, csrf: method(:csrf_token)).call
+        end
 
-          r.redirect "/series/#{series.id}"
+        r.post "email" do
+          email = r.params["email"].to_s.strip
+          @user.update(email: email.empty? ? nil : email)
+          r.redirect "/users/#{user_id}"
         end
       end
 
-      r.on Integer do |series_id|
-        @series = @user.series_dataset.where(id: series_id).sole
+      r.on "series" do
+        r.get "new" do
+          Views::Series::New.new(current_user: @user, csrf: method(:csrf_token)).call
+        end
 
         r.is do
-          r.get do
-            Views::Series::Show.new(series: @series, current_user: @user, csrf: method(:csrf_token)).call
-          end
-
-          r.patch do
-            updates = {}
-
-            if r.params.key?("note")
-              note = r.params["note"].to_s.strip
-              r.halt 422 if note.empty?
-              updates[:note] = note
+          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
+            first_due_date = r.params["first_due_date"].to_s
+
+            r.halt 422 if note.empty?
+            r.halt 422 unless Series::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
 
-            if r.params.key?("interval_count") || r.params.key?("interval_unit")
-              interval_count = r.params.key?("interval_count") ? r.params["interval_count"].to_i : @series.interval_count
-              interval_unit = r.params.key?("interval_unit") ? r.params["interval_unit"].to_s : @series.interval_unit
-              r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
-              r.halt 422 unless interval_count >= 1
-              updates[:interval_count] = interval_count
-              updates[:interval_unit] = interval_unit
-            end
+            series = Series.create_with_first_task(
+              user: @user,
+              note: note,
+              interval_unit: interval_unit,
+              interval_count: interval_count,
+              first_due_date: due_date
+            )
 
-            @series.update(updates) unless updates.empty?
-
-            response["content-type"] = "application/json"
-            updates.transform_keys(&:to_s).to_json
+            r.redirect "/series/#{series.id}"
           end
         end
 
-        r.on "tasks", Integer do |task_id|
-          @task = @series.tasks_dataset.where(id: task_id).sole
+        r.on Integer do |series_id|
+          @series = @user.series_dataset.where(id: series_id).sole
 
-          r.on "complete" do
-            r.post do
-              r.halt 422 unless @task[:completed_at].nil?
+          r.is do
+            r.get do
+              Views::Series::Show.new(series: @series, current_user: @user, csrf: method(:csrf_token)).call
+            end
 
-              @task.complete!(completed_on: Date.today)
+            r.patch do
+              updates = {}
 
-              note_title = @series.note.lines.first&.strip || @series.note
-              complete_path = "/series/#{series_id}/tasks/#{@task.id}/complete"
-              session["flash"] = { "message" => "Completed \u201c#{note_title}\u201d", "undo_path" => complete_path }
+              if r.params.key?("note")
+                note = r.params["note"].to_s.strip
+                r.halt 422 if note.empty?
+                updates[:note] = note
+              end
 
-              return_to = r.params["return_to"]
-              if return_to && return_to.start_with?("/")
-                r.redirect return_to
-              else
-                r.redirect "/"
+              if r.params.key?("interval_count") || r.params.key?("interval_unit")
+                interval_count = r.params.key?("interval_count") ? r.params["interval_count"].to_i : @series.interval_count
+                interval_unit = r.params.key?("interval_unit") ? r.params["interval_unit"].to_s : @series.interval_unit
+                r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
+                r.halt 422 unless interval_count >= 1
+                updates[:interval_count] = interval_count
+                updates[:interval_unit] = interval_unit
               end
-            end
 
-            r.delete do
-              r.halt 422 if @task[:completed_at].nil?
-              @task.undo_complete!
-              response.status = 204
-              ""
+              @series.update(updates) unless updates.empty?
+
+              response["content-type"] = "application/json"
+              updates.transform_keys(&:to_s).to_json
             end
           end
 
-          r.is do
-            r.patch do
-              begin
-                body = JSON.parse(r.body.read)
-              rescue JSON::ParserError
-                r.halt 422
-              end
+          r.on "tasks", Integer do |task_id|
+            @task = @series.tasks_dataset.where(id: task_id).sole
 
-              updates = {}
-              result = {}
-
-              if body.key?("due_date")
+            r.on "complete" do
+              r.post do
                 r.halt 422 unless @task[:completed_at].nil?
-                begin
-                  due_date = Date.parse(body["due_date"].to_s)
-                rescue Date::Error
-                  r.halt 422
+
+                @task.complete!(completed_on: Date.today)
+
+                note_title = @series.note.lines.first&.strip || @series.note
+                complete_path = "/series/#{series_id}/tasks/#{@task.id}/complete"
+                session["flash"] = { "message" => "Completed \u201c#{note_title}\u201d", "undo_path" => complete_path }
+
+                return_to = r.params["return_to"]
+                if return_to && return_to.start_with?("/")
+                  r.redirect return_to
+                else
+                  r.redirect "/"
                 end
-                updates[:due_date] = due_date
-                result["due_date"] = due_date.to_s
               end
 
-              if body.key?("note")
+              r.delete do
                 r.halt 422 if @task[:completed_at].nil?
-                note = body["note"].to_s.strip
-                updates[:note] = note.empty? ? nil : note
-                result["note"] = note
+                @task.undo_complete!
+                response.status = 204
+                ""
               end
+            end
 
-              if body.key?("completed_at")
-                r.halt 422 if @task[:completed_at].nil?
+            r.is do
+              r.patch do
                 begin
-                  completed_date = Date.parse(body["completed_at"].to_s)
-                rescue Date::Error
+                  body = JSON.parse(r.body.read)
+                rescue JSON::ParserError
                   r.halt 422
                 end
-                updates[:completed_at] = Time.new(completed_date.year, completed_date.month, completed_date.day)
-                result["completed_at"] = completed_date.to_s
-              end
 
-              unless updates.empty?
-                DB.transaction do
-                  Task.where(id: task_id).update(updates)
+                updates = {}
+                result = {}
 
-                  if completed_date
-                    latest = @series.completed_tasks.first
-                    if latest && latest[:id] == @task[:id]
-                      active = @series.active_task
-                      active.update(due_date: @series.next_due_date(completed_date)) if active
+                if body.key?("due_date")
+                  r.halt 422 unless @task[:completed_at].nil?
+                  begin
+                    due_date = Date.parse(body["due_date"].to_s)
+                  rescue Date::Error
+                    r.halt 422
+                  end
+                  updates[:due_date] = due_date
+                  result["due_date"] = due_date.to_s
+                end
+
+                if body.key?("note")
+                  r.halt 422 if @task[:completed_at].nil?
+                  note = body["note"].to_s.strip
+                  updates[:note] = note.empty? ? nil : note
+                  result["note"] = note
+                end
+
+                if body.key?("completed_at")
+                  r.halt 422 if @task[:completed_at].nil?
+                  begin
+                    completed_date = Date.parse(body["completed_at"].to_s)
+                  rescue Date::Error
+                    r.halt 422
+                  end
+                  updates[:completed_at] = Time.new(completed_date.year, completed_date.month, completed_date.day)
+                  result["completed_at"] = completed_date.to_s
+                end
+
+                unless updates.empty?
+                  DB.transaction do
+                    Task.where(id: task_id).update(updates)
+
+                    if completed_date
+                      latest = @series.completed_tasks.first
+                      if latest && latest[:id] == @task[:id]
+                        active = @series.active_task
+                        active.update(due_date: @series.next_due_date(completed_date)) if active
+                      end
                     end
                   end
                 end
-              end
 
-              response["content-type"] = "application/json"
-              result.to_json
+                response["content-type"] = "application/json"
+                result.to_json
+              end
             end
           end
         end
diff --git a/test/test_db.rb b/test/test_db.rb
index 778456a..65530f0 100644
--- a/test/test_db.rb
+++ b/test/test_db.rb
@@ -8,42 +8,42 @@ require_relative "../lib/ketchup/db"
 
 class TestDB < Minitest::Test
   def setup
-    DB[:tasks].delete
-    DB[:series].delete
+    Ketchup::DB[:tasks].delete
+    Ketchup::DB[:series].delete
     @now = Time.now
-    DB[:users]
+    Ketchup::DB[:users]
       .insert_conflict(target: :login, update: { updated_at: @now })
       .insert(login: "test@example.com", created_at: @now, updated_at: @now)
-    @user_id = DB[:users].first(login: "test@example.com")[:id]
+    @user_id = Ketchup::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)
+      Ketchup::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)
+    Ketchup::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)
+    Ketchup::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
+    assert_equal 1, Ketchup::DB[:tasks].where(series_id: series_id, completed_at: nil).count
   end
 
   private
 
   def create_series
-    series_id = DB[:series].insert(
+    series_id = Ketchup::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)
+    Ketchup::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_seed.rb b/test/test_seed.rb
index c660579..df4d7d6 100644
--- a/test/test_seed.rb
+++ b/test/test_seed.rb
@@ -8,13 +8,13 @@ require_relative "../lib/ketchup/seed"
 
 class TestSeed < Minitest::Test
   def setup
-    DB[:tasks].delete
-    DB[:series].delete
-    DB[:users].delete
+    Ketchup::DB[:tasks].delete
+    Ketchup::DB[:series].delete
+    Ketchup::DB[:users].delete
   end
 
   def test_seed_creates_series_and_tasks
-    user = User.create(login: "test@example.com")
+    user = Ketchup::User.create(login: "test@example.com")
     series_data = [
       {
         note: "Call Mom",
@@ -27,17 +27,17 @@ class TestSeed < Minitest::Test
 
     Ketchup::Seed.call(user: user, series: series_data)
 
-    assert_equal 1, Series.count
-    assert_equal 1, Task.count
-    s = Series.first
+    assert_equal 1, Ketchup::Series.count
+    assert_equal 1, Ketchup::Task.count
+    s = Ketchup::Series.first
     assert_equal "Call Mom", s.note
     assert_equal "week", s.interval_unit
     assert_equal 2, s.interval_count
-    assert_equal Date.new(2026, 3, 1), Task.first.due_date
+    assert_equal Date.new(2026, 3, 1), Ketchup::Task.first.due_date
   end
 
   def test_seed_creates_completed_history
-    user = User.create(login: "test@example.com")
+    user = Ketchup::User.create(login: "test@example.com")
     series_data = [
       {
         note: "Water plants",
@@ -53,7 +53,7 @@ class TestSeed < Minitest::Test
 
     Ketchup::Seed.call(user: user, series: series_data)
 
-    assert_equal 3, Task.count
-    assert_equal 2, Task.exclude(completed_at: nil).count
+    assert_equal 3, Ketchup::Task.count
+    assert_equal 2, Ketchup::Task.exclude(completed_at: nil).count
   end
 end
diff --git a/test/test_sole.rb b/test/test_sole.rb
index 82f415c..1830df0 100644
--- a/test/test_sole.rb
+++ b/test/test_sole.rb
@@ -8,29 +8,29 @@ require_relative "../lib/ketchup/web"
 
 class TestSole < Minitest::Test
   def setup
-    DB[:tasks].delete
-    DB[:series].delete
-    DB[:users].delete
+    Ketchup::DB[:tasks].delete
+    Ketchup::DB[:series].delete
+    Ketchup::DB[:users].delete
   end
 
   def test_sole_returns_single_record
-    User.create(login: "alice@example.com")
-    user = User.where(login: "alice@example.com").sole
+    Ketchup::User.create(login: "alice@example.com")
+    user = Ketchup::User.where(login: "alice@example.com").sole
     assert_equal "alice@example.com", user.login
   end
 
   def test_sole_raises_on_no_records
     assert_raises(Sequel::NoMatchingRow) do
-      User.where(login: "nobody@example.com").sole
+      Ketchup::User.where(login: "nobody@example.com").sole
     end
   end
 
   def test_sole_raises_on_multiple_records
-    User.create(login: "alice@example.com")
-    User.create(login: "bob@example.com")
+    Ketchup::User.create(login: "alice@example.com")
+    Ketchup::User.create(login: "bob@example.com")
 
     assert_raises(Sequel::Plugins::Sole::TooManyRows) do
-      User.dataset.sole
+      Ketchup::User.dataset.sole
     end
   end
 end
diff --git a/test/test_web.rb b/test/test_web.rb
index ea3bedc..68f62fd 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -10,12 +10,12 @@ require_relative "../lib/ketchup/web"
 class TestWeb < Minitest::Test
   include Rack::Test::Methods
 
-  def app = Web.app
+  def app = Ketchup::Web.app
 
   def setup
-    DB[:tasks].delete
-    DB[:series].delete
-    DB[:users].delete
+    Ketchup::DB[:tasks].delete
+    Ketchup::DB[:series].delete
+    Ketchup::DB[:users].delete
   end
 
   def test_new_series_page
@@ -79,7 +79,7 @@ class TestWeb < Minitest::Test
 
   def test_root_creates_user_record
     get "/", {}, auth_headers(login: "bob@example.com")
-    user = DB[:users].first(login: "bob@example.com")
+    user = Ketchup::DB[:users].first(login: "bob@example.com")
     assert user
   end
 
@@ -95,7 +95,7 @@ class TestWeb < Minitest::Test
     )
     assert last_response.redirect?
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     assert_equal "Call Mom", series[:note]
     assert_equal "week", series[:interval_unit]
     assert_equal 2, series[:interval_count]
@@ -108,8 +108,8 @@ class TestWeb < Minitest::Test
       first_due_date: "2026-03-01"
     )
 
-    series = DB[:series].first
-    task = DB[:tasks].first(series_id: series[:id])
+    series = Ketchup::DB[:series].first
+    task = Ketchup::DB[:tasks].first(series_id: series[:id])
     assert_equal Date.new(2026, 3, 1), task[:due_date]
     assert_nil task[:completed_at]
   end
@@ -121,8 +121,8 @@ class TestWeb < Minitest::Test
       headers: auth_headers(login: "dave@example.com")
     )
 
-    series = DB[:series].first
-    user = DB[:users].first(login: "dave@example.com")
+    series = Ketchup::DB[:series].first
+    user = Ketchup::DB[:users].first(login: "dave@example.com")
     assert_equal user[:id], series[:user_id]
   end
 
@@ -131,7 +131,7 @@ class TestWeb < Minitest::Test
       note: "  Trim me  ", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01"
     )
-    assert_equal "Trim me", DB[:series].first[:note]
+    assert_equal "Trim me", Ketchup::DB[:series].first[:note]
   end
 
   def test_create_series_rejects_empty_note
@@ -140,7 +140,7 @@ class TestWeb < Minitest::Test
       first_due_date: "2026-03-01"
     )
     assert_equal 422, last_response.status
-    assert_equal 0, DB[:series].count
+    assert_equal 0, Ketchup::DB[:series].count
   end
 
   def test_create_series_rejects_invalid_interval_unit
@@ -149,7 +149,7 @@ class TestWeb < Minitest::Test
       first_due_date: "2026-03-01"
     )
     assert_equal 422, last_response.status
-    assert_equal 0, DB[:series].count
+    assert_equal 0, Ketchup::DB[:series].count
   end
 
   def test_create_series_rejects_zero_interval_count
@@ -158,7 +158,7 @@ class TestWeb < Minitest::Test
       first_due_date: "2026-03-01"
     )
     assert_equal 422, last_response.status
-    assert_equal 0, DB[:series].count
+    assert_equal 0, Ketchup::DB[:series].count
   end
 
   def test_create_series_rejects_invalid_due_date
@@ -167,26 +167,26 @@ class TestWeb < Minitest::Test
       first_due_date: "not-a-date"
     )
     assert_equal 422, last_response.status
-    assert_equal 0, DB[:series].count
-    assert_equal 0, DB[:tasks].count
+    assert_equal 0, Ketchup::DB[:series].count
+    assert_equal 0, Ketchup::DB[:tasks].count
   end
 
   def test_complete_task
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     complete_path = "/series/#{series[:id]}/tasks/#{task[:id]}/complete"
     csrf_post complete_path, {}, auth_headers
     assert last_response.redirect?
 
     assert_equal "/", URI.parse(last_response["Location"]).path
 
-    old_task = DB[:tasks].first(id: task[:id])
+    old_task = Ketchup::DB[:tasks].first(id: task[:id])
     refute_nil old_task[:completed_at]
 
-    new_task = DB[:tasks].where(completed_at: nil).first
+    new_task = Ketchup::DB[:tasks].where(completed_at: nil).first
     assert_equal Date.today + 14, new_task[:due_date]
   end
 
@@ -194,11 +194,11 @@ class TestWeb < Minitest::Test
     create_series(note: "Dentist", interval_unit: "month", interval_count: "3",
                   first_due_date: "2026-01-31")
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    new_task = DB[:tasks].where(completed_at: nil).first
+    new_task = Ketchup::DB[:tasks].where(completed_at: nil).first
     assert_equal Date.today >> 3, new_task[:due_date]
   end
 
@@ -206,10 +206,10 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    task = Task.first
+    task = Ketchup::Task.first
     task.complete!(completed_on: Date.new(2026, 4, 1))
 
-    new_task = Task.where(completed_at: nil).first
+    new_task = Ketchup::Task.where(completed_at: nil).first
     assert_equal Date.new(2026, 4, 15), new_task.due_date
   end
 
@@ -220,8 +220,8 @@ class TestWeb < Minitest::Test
       headers: auth_headers(login: "alice@example.com")
     )
 
-    task = DB[:tasks].first
-    csrf_post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "bob@example.com")
+    task = Ketchup::DB[:tasks].first
+    csrf_post "/series/#{Ketchup::DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "bob@example.com")
     assert_includes [403, 404], last_response.status
   end
 
@@ -229,8 +229,8 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "day", interval_count: "1",
                   first_due_date: "2026-03-01")
 
-    task = DB[:tasks].first
-    post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete"
+    task = Ketchup::DB[:tasks].first
+    post "/series/#{Ketchup::DB[:series].first[:id]}/tasks/#{task[:id]}/complete"
     assert_equal 403, last_response.status
   end
 
@@ -246,7 +246,7 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     get "/", {}, auth_headers
     assert_includes last_response.body, "href=\"/series/#{series[:id]}\""
     assert_includes last_response.body, "Call Mom"
@@ -256,8 +256,8 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     get "/", {}, auth_headers
     assert_includes last_response.body, "action=\"/series/#{series[:id]}/tasks/#{task[:id]}/complete\""
   end
@@ -274,7 +274,7 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     get "/series/#{series[:id]}", {}, auth_headers
     assert last_response.ok?
     assert_includes last_response.body, "Call Mom"
@@ -288,7 +288,7 @@ class TestWeb < Minitest::Test
       headers: auth_headers(login: "alice@example.com")
     )
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     get "/series/#{series[:id]}", {}, auth_headers(login: "bob@example.com")
     assert_equal 404, last_response.status
   end
@@ -302,52 +302,52 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    completed_task = DB[:tasks].first(id: task[:id])
+    completed_task = Ketchup::DB[:tasks].first(id: task[:id])
     patch_task series[:id], completed_task[:id], { note: "Called, all good" }
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
     assert_equal "Called, all good", body["note"]
-    assert_equal "Called, all good", DB[:tasks].first(id: completed_task[:id])[:note]
+    assert_equal "Called, all good", Ketchup::DB[:tasks].first(id: completed_task[:id])[:note]
   end
 
   def test_patch_task_saves_completed_at
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    completed_task = DB[:tasks].first(id: task[:id])
+    completed_task = Ketchup::DB[:tasks].first(id: task[:id])
     patch_task series[:id], completed_task[:id], { completed_at: "2026-01-15" }
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
     assert_equal "2026-01-15", body["completed_at"]
-    assert_equal Date.new(2026, 1, 15), DB[:tasks].first(id: completed_task[:id])[:completed_at].to_date
+    assert_equal Date.new(2026, 1, 15), Ketchup::DB[:tasks].first(id: completed_task[:id])[:completed_at].to_date
   end
 
   def test_patch_latest_completed_task_updates_active_due_date
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
-    task = DB[:tasks].first
+    series = Ketchup::DB[:series].first
+    task = Ketchup::DB[:tasks].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    active_task = DB[:tasks].where(completed_at: nil).first
+    active_task = Ketchup::DB[:tasks].where(completed_at: nil).first
     original_due = active_task[:due_date]
 
     # Backdate completion by one week — active due date should shift accordingly
     patch_task series[:id], task[:id], { completed_at: "2026-02-21" }
     assert last_response.ok?
 
-    updated_active = DB[:tasks].first(id: active_task[:id])
+    updated_active = Ketchup::DB[:tasks].first(id: active_task[:id])
     assert_equal Date.new(2026, 3, 7), updated_active[:due_date]
   end
 
@@ -355,11 +355,11 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    completed_task = DB[:tasks].first(id: task[:id])
+    completed_task = Ketchup::DB[:tasks].first(id: task[:id])
     patch_task series[:id], completed_task[:id], { note: "Backdated", completed_at: "2026-02-01" }
     assert last_response.ok?
 
@@ -367,7 +367,7 @@ class TestWeb < Minitest::Test
     assert_equal "Backdated", body["note"]
     assert_equal "2026-02-01", body["completed_at"]
 
-    updated = DB[:tasks].first(id: completed_task[:id])
+    updated = Ketchup::DB[:tasks].first(id: completed_task[:id])
     assert_equal "Backdated", updated[:note]
     assert_equal Date.new(2026, 2, 1), updated[:completed_at].to_date
   end
@@ -376,11 +376,11 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    completed_task = DB[:tasks].first(id: task[:id])
+    completed_task = Ketchup::DB[:tasks].first(id: task[:id])
     patch_task series[:id], completed_task[:id], { completed_at: "not-a-date" }
     assert_equal 422, last_response.status
   end
@@ -389,8 +389,8 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
                   first_due_date: "2026-03-01")
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     patch_task series[:id], task[:id], { note: "nope" }
     assert_equal 422, last_response.status
   end
@@ -402,11 +402,11 @@ class TestWeb < Minitest::Test
       headers: auth_headers(login: "alice@example.com")
     )
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "alice@example.com")
 
-    completed_task = DB[:tasks].first(id: task[:id])
+    completed_task = Ketchup::DB[:tasks].first(id: task[:id])
     patch_task series[:id], completed_task[:id], { note: "hacked" }, login: "bob@example.com"
     assert_equal 404, last_response.status
   end
@@ -415,20 +415,20 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     patch "/series/#{series[:id]}", { note: "Call Dad" }, auth_headers
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
     assert_equal "Call Dad", body["note"]
-    assert_equal "Call Dad", DB[:series].first(id: series[:id])[:note]
+    assert_equal "Call Dad", Ketchup::DB[:series].first(id: series[:id])[:note]
   end
 
   def test_patch_series_updates_interval
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     patch "/series/#{series[:id]}", { interval_count: "3", interval_unit: "month" }, auth_headers
     assert last_response.ok?
 
@@ -436,7 +436,7 @@ class TestWeb < Minitest::Test
     assert_equal 3, body["interval_count"]
     assert_equal "month", body["interval_unit"]
 
-    updated = DB[:series].first(id: series[:id])
+    updated = Ketchup::DB[:series].first(id: series[:id])
     assert_equal 3, updated[:interval_count]
     assert_equal "month", updated[:interval_unit]
   end
@@ -445,8 +445,8 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
-    task = DB[:tasks].first(series_id: series[:id])
+    series = Ketchup::DB[:series].first
+    task = Ketchup::DB[:tasks].first(series_id: series[:id])
     patch "/series/#{series[:id]}/tasks/#{task[:id]}",
           JSON.generate(due_date: "2026-04-15"),
           auth_headers.merge("CONTENT_TYPE" => "application/json")
@@ -454,18 +454,18 @@ class TestWeb < Minitest::Test
 
     body = JSON.parse(last_response.body)
     assert_equal "2026-04-15", body["due_date"]
-    assert_equal Date.new(2026, 4, 15), DB[:tasks].first(id: task[:id])[:due_date]
+    assert_equal Date.new(2026, 4, 15), Ketchup::DB[:tasks].first(id: task[:id])[:due_date]
   end
 
   def test_patch_task_due_date_rejects_completed_task
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
-    task = DB[:tasks].first(series_id: series[:id])
+    series = Ketchup::DB[:series].first
+    task = Ketchup::DB[:tasks].first(series_id: series[:id])
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    completed_task = DB[:tasks].first(id: task[:id])
+    completed_task = Ketchup::DB[:tasks].first(id: task[:id])
     refute_nil completed_task[:completed_at]
 
     patch_task series[:id], task[:id], { due_date: "2026-04-15" }
@@ -476,7 +476,7 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     patch "/series/#{series[:id]}", { interval_unit: "fortnight" }, auth_headers
     assert_equal 422, last_response.status
   end
@@ -485,7 +485,7 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     patch "/series/#{series[:id]}", { interval_count: "0" }, auth_headers
     assert_equal 422, last_response.status
   end
@@ -497,7 +497,7 @@ class TestWeb < Minitest::Test
       headers: auth_headers(login: "alice@example.com")
     )
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     patch "/series/#{series[:id]}", { note: "hacked" }, auth_headers(login: "bob@example.com")
     assert_equal 404, last_response.status
   end
@@ -506,22 +506,22 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    series = DB[:series].first
+    series = Ketchup::DB[:series].first
     patch "/series/#{series[:id]}", { note: "Call Dad" }, auth_headers
     assert last_response.ok?
 
-    updated = DB[:series].first(id: series[:id])
+    updated = Ketchup::DB[:series].first(id: series[:id])
     assert_equal "Call Dad", updated[:note]
     assert_equal "week", updated[:interval_unit]
     assert_equal 2, updated[:interval_count]
 
-    task = DB[:tasks].first(series_id: series[:id])
+    task = Ketchup::DB[:tasks].first(series_id: series[:id])
     assert_equal Date.new(2026, 3, 1), task[:due_date]
   end
 
   def test_get_user_page_shows_settings
     get "/", {}, auth_headers  # create user
-    user_id = DB[:users].first(login: "alice@example.com")[:id]
+    user_id = Ketchup::DB[:users].first(login: "alice@example.com")[:id]
 
     get "/users/#{user_id}", {}, auth_headers
     assert last_response.ok?
@@ -532,33 +532,33 @@ class TestWeb < Minitest::Test
 
   def test_post_user_email
     get "/", {}, auth_headers  # create user
-    user_id = DB[:users].first(login: "alice@example.com")[:id]
+    user_id = Ketchup::DB[:users].first(login: "alice@example.com")[:id]
 
     get "/users/#{user_id}", {}, auth_headers
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/users/#{user_id}/email", { "_csrf" => token, email: "alice@example.org" }, auth_headers
     assert last_response.redirect?
 
-    user = DB[:users].first(id: user_id)
+    user = Ketchup::DB[:users].first(id: user_id)
     assert_equal "alice@example.org", user[:email]
   end
 
   def test_post_user_email_clears_empty
     get "/", {}, auth_headers
-    user_id = DB[:users].first(login: "alice@example.com")[:id]
+    user_id = Ketchup::DB[:users].first(login: "alice@example.com")[:id]
 
     get "/users/#{user_id}", {}, auth_headers
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/users/#{user_id}/email", { "_csrf" => token, email: "" }, auth_headers
 
-    user = DB[:users].first(id: user_id)
+    user = Ketchup::DB[:users].first(id: user_id)
     assert_nil user[:email]
   end
 
   def test_get_user_rejects_other_user
     get "/", {}, auth_headers  # create alice
     get "/", {}, auth_headers(login: "bob@example.com")  # create bob
-    bob_id = DB[:users].first(login: "bob@example.com")[:id]
+    bob_id = Ketchup::DB[:users].first(login: "bob@example.com")[:id]
 
     get "/users/#{bob_id}", {}, auth_headers
     assert_equal 404, last_response.status
@@ -611,8 +611,8 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
     assert last_response.redirect?
     assert_equal "/", URI.parse(last_response["Location"]).path
@@ -622,8 +622,8 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
     get "/", {}, auth_headers
@@ -667,8 +667,8 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
     get "/", {}, auth_headers
@@ -682,26 +682,26 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
-    assert_equal 2, DB[:tasks].where(series_id: series[:id]).count
-    refute_nil DB[:tasks].first(id: task[:id])[:completed_at]
+    assert_equal 2, Ketchup::DB[:tasks].where(series_id: series[:id]).count
+    refute_nil Ketchup::DB[:tasks].first(id: task[:id])[:completed_at]
 
     delete "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
     assert_equal 204, last_response.status
 
-    assert_nil DB[:tasks].first(id: task[:id])[:completed_at]
-    assert_equal 1, DB[:tasks].where(series_id: series[:id]).count
+    assert_nil Ketchup::DB[:tasks].first(id: task[:id])[:completed_at]
+    assert_equal 1, Ketchup::DB[:tasks].where(series_id: series[:id]).count
   end
 
   def test_delete_complete_rejects_active_task
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     delete "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
     assert_equal 422, last_response.status
   end
@@ -713,8 +713,8 @@ class TestWeb < Minitest::Test
       headers: auth_headers(login: "alice@example.com")
     )
 
-    task = DB[:tasks].first
-    series = DB[:series].first
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "alice@example.com")
 
     delete "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "bob@example.com")