Modernize Ruby style for 4.0
Ruby 4.0 freezes string literals by default, making the
frozen_string_literal magic comments redundant.

Assisted-by: Claude Opus 5 via Claude Code
change otvxzstlmkwwmurnwtpypyyovwulrmss
commit ae52665f7c1a6cdb5b629b5f1460be18e5885175
author Alpha Chen <alpha@kejadlen.dev>
date
parent pyrsotpw
diff --git a/Rakefile b/Rakefile
index 319ab37..547fb4c 100644
--- a/Rakefile
+++ b/Rakefile
@@ -35,7 +35,7 @@ task :seed do
 end
 
 namespace :snapshots do
-  cache_dir = File.join(ENV.fetch("XDG_CACHE_HOME", File.expand_path("~/.cache")), "ketchup", "snapshots")
+  cache_dir = Pathname(ENV.fetch("XDG_CACHE_HOME", "~/.cache")).expand_path / "ketchup" / "snapshots"
   css_sources = {
     "public/css/reset.css" => "reset.css",
     "public/css/utopia.css" => "utopia.css",
@@ -44,13 +44,13 @@ namespace :snapshots do
 
   directory cache_dir
 
-  css_targets = css_sources.map do |src, basename|
-    target = File.join(cache_dir, basename)
+  css_targets = css_sources.map { |src, basename|
+    target = cache_dir / basename
     file target => [cache_dir, src] do
       cp src, target
     end
     target
-  end
+  }
 
   desc "Capture screenshots of the app in key states"
   task :capture do
@@ -60,7 +60,7 @@ namespace :snapshots do
     ENV["BUILD_DATE"] ||= "2025-01-01"
     require "ketchup/snapshots"
 
-    output_dir = File.join(cache_dir, "current")
+    output_dir = cache_dir / "current"
     Ketchup::Snapshots::Capture.new(output_dir: output_dir).call
 
     if ENV["CI"]
@@ -74,7 +74,7 @@ namespace :snapshots do
     require "erb"
     require "ketchup/snapshots"
 
-    base_dir = Pathname(cache_dir)
+    base_dir = cache_dir
     baseline_dir = base_dir / "baseline"
     current_dir = base_dir / "current"
 
@@ -108,7 +108,7 @@ namespace :snapshots do
 
   desc "Capture, diff, and open the viewer"
   task review: :diff do
-    system("open", (Pathname(cache_dir) / "diff.html").to_s)
+    system("open", (cache_dir / "diff.html").to_s)
   end
 
   desc "Generate gallery HTML from images in a directory"
@@ -116,19 +116,19 @@ namespace :snapshots do
     require "erb"
     require "ketchup/snapshots"
 
-    images_dir = Pathname(args.fetch(:images_dir) { File.join(cache_dir, "current") })
-    output_path = Pathname(args.fetch(:output_path) { File.join(cache_dir, "gallery.html") })
+    images_dir = Pathname(args.fetch(:images_dir) { cache_dir / "current" })
+    output_path = Pathname(args.fetch(:output_path) { cache_dir / "gallery.html" })
 
-    css_sources.each_key { |src| cp src, output_path.dirname.to_s }
+    css_sources.each do |src, _basename|
+      cp src, output_path.dirname.to_s
+    end
 
     title = "Ketchup Snapshots"
-    images_by_viewport = Ketchup::Snapshots::VIEWPORTS.keys.each_with_object({}) do |viewport, result|
+    images_by_viewport = Ketchup::Snapshots::VIEWPORTS.to_h do |viewport, _size|
       viewport_dir = images_dir / viewport
       entries = Ketchup::Snapshots::Entry.read_manifest(viewport_dir)
       images_rel = viewport_dir.relative_path_from(output_path.dirname)
-      result[viewport] = entries.map do |entry|
-        { entry: entry, filename: (images_rel / "#{entry.name}.png").to_s }
-      end
+      [viewport, entries.map { |entry| { entry: entry, filename: (images_rel / "#{entry.name}.png").to_s } }]
     end
 
     template = (Pathname(__dir__) / "templates/snapshot_gallery.erb").read
diff --git a/config.ru b/config.ru
index 2ce7855..67059ef 100644
--- a/config.ru
+++ b/config.ru
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
 
 unless ENV["COMMIT_SHA"]
diff --git a/db/migrate/001_create_users.rb b/db/migrate/001_create_users.rb
index 6bfa8b0..1e70607 100644
--- a/db/migrate/001_create_users.rb
+++ b/db/migrate/001_create_users.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 Sequel.migration do
   change do
     create_table(:users) do
diff --git a/db/migrate/002_create_series.rb b/db/migrate/002_create_series.rb
index 459dd6c..9388f46 100644
--- a/db/migrate/002_create_series.rb
+++ b/db/migrate/002_create_series.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 Sequel.migration do
   change do
     create_table(:series) do
diff --git a/db/migrate/003_create_tasks.rb b/db/migrate/003_create_tasks.rb
index cea2468..2e95663 100644
--- a/db/migrate/003_create_tasks.rb
+++ b/db/migrate/003_create_tasks.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 Sequel.migration do
   change do
     create_table(:tasks) do
diff --git a/db/migrate/004_add_note_to_tasks.rb b/db/migrate/004_add_note_to_tasks.rb
index b406de8..8ba3aed 100644
--- a/db/migrate/004_add_note_to_tasks.rb
+++ b/db/migrate/004_add_note_to_tasks.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 Sequel.migration do
   change do
     alter_table(:tasks) do
diff --git a/db/migrate/005_add_email_remove_name_from_users.rb b/db/migrate/005_add_email_remove_name_from_users.rb
index 69fd3aa..1a268a1 100644
--- a/db/migrate/005_add_email_remove_name_from_users.rb
+++ b/db/migrate/005_add_email_remove_name_from_users.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 Sequel.migration do
   change do
     alter_table(:users) do
diff --git a/db/migrate/006_add_archived_at_to_series.rb b/db/migrate/006_add_archived_at_to_series.rb
index 962c54c..43bc0b4 100644
--- a/db/migrate/006_add_archived_at_to_series.rb
+++ b/db/migrate/006_add_archived_at_to_series.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 Sequel.migration do
   change do
     add_column :series, :archived_at, DateTime
diff --git a/db/migrate/007_add_sharing.rb b/db/migrate/007_add_sharing.rb
index f98e9f8..f65e254 100644
--- a/db/migrate/007_add_sharing.rb
+++ b/db/migrate/007_add_sharing.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 Sequel.migration do
   change do
     alter_table(:series) do
diff --git a/lib/ketchup/config.rb b/lib/ketchup/config.rb
index bf3f77c..82c3614 100644
--- a/lib/ketchup/config.rb
+++ b/lib/ketchup/config.rb
@@ -1,5 +1,4 @@
 # rbs_inline: enabled
-# frozen_string_literal: true
 
 require "securerandom"
 
@@ -52,7 +51,7 @@ module Ketchup
         default_user: env["DEFAULT_USER"],
         commit_sha: env["COMMIT_SHA"],
         change_id: env["CHANGE_ID"]&.slice(0, 8),
-        build_date: env["BUILD_DATE"]
+        build_date: env["BUILD_DATE"],
       )
     end
   end
diff --git a/lib/ketchup/db.rb b/lib/ketchup/db.rb
index 2a74ce0..306b0de 100644
--- a/lib/ketchup/db.rb
+++ b/lib/ketchup/db.rb
@@ -1,5 +1,4 @@
-# frozen_string_literal: true
-
+require "pathname"
 require "sequel"
 
 require_relative "config"
@@ -7,5 +6,5 @@ require_relative "config"
 module Ketchup
   DB = Sequel.sqlite(CONFIG.database_url)
   Sequel.extension :migration
-  Sequel::Migrator.run(DB, File.expand_path("../../db/migrate", __dir__))
+  Sequel::Migrator.run(DB, (Pathname(__dir__) / "../../db/migrate").expand_path)
 end
diff --git a/lib/ketchup/dev_auth.rb b/lib/ketchup/dev_auth.rb
index 4b8ec1a..ceee140 100644
--- a/lib/ketchup/dev_auth.rb
+++ b/lib/ketchup/dev_auth.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 module Ketchup
   class DevAuth
     def initialize(app, login)
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index fc234e2..acd79eb 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require_relative "db"
 
 module Ketchup
@@ -24,7 +22,7 @@ module Ketchup
           Sequel[:series][:note],
           Sequel[:series][:interval_unit],
           Sequel[:series][:interval_count],
-          Sequel[:series][:shared]
+          Sequel[:series][:shared],
         )
     end
 
@@ -69,7 +67,7 @@ module Ketchup
 
       streak = 0
       on_time = 0
-      completed.each_with_index do |t, i|
+      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
@@ -101,12 +99,12 @@ module Ketchup
           note: note,
           interval_unit: interval_unit,
           interval_count: interval_count,
-          shared: shared
+          shared: shared,
         )
 
         Task.create(
           series_id: series.id,
-          due_date: first_due_date
+          due_date: first_due_date,
         )
 
         series
@@ -122,7 +120,7 @@ module Ketchup
     # 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
+      "day" => 1, "week" => 7, "month" => 30, "quarter" => 91, "year" => 365,
     }.freeze
 
     def urgency
@@ -141,7 +139,7 @@ module Ketchup
       DB.transaction do
         update(
           completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day),
-          completed_by_user_id: by.id
+          completed_by_user_id: by.id,
         )
         Task.create(series_id: series.id, due_date: series.next_due_date(completed_on))
       end
diff --git a/lib/ketchup/seed.rb b/lib/ketchup/seed.rb
index 1cb9143..d91f446 100644
--- a/lib/ketchup/seed.rb
+++ b/lib/ketchup/seed.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require_relative "models"
 
 module Ketchup
@@ -15,7 +13,7 @@ module Ketchup
         history: [
           { due_date: Date.today - 9, completed_at: (Date.today - 9).to_time, note: "Done, no issues" },
           { due_date: Date.today - 12, completed_at: (Date.today - 11).to_time, note: nil },
-        ]
+        ],
       },
       # Overdue — moderate urgency
       {
@@ -34,7 +32,7 @@ module Ketchup
         due_date: Date.today - 7,
         history: [
           { due_date: Date.today - 37, completed_at: (Date.today - 36).to_time, note: "All good\n\n- Checked statements\n- Updated budget" },
-        ]
+        ],
       },
       # Overdue — quarterly, just past due
       {
@@ -45,7 +43,7 @@ module Ketchup
         history: [
           { due_date: Date.today - 92, completed_at: (Date.today - 92).to_time, note: "Rescheduled from **last week**" },
           { due_date: Date.today - 183, completed_at: (Date.today - 182).to_time, note: nil },
-        ]
+        ],
       },
       # Upcoming — soon
       {
@@ -56,7 +54,7 @@ module Ketchup
         history: [
           { due_date: Date.today - 12, completed_at: (Date.today - 12).to_time, note: "Had to call back *twice*" },
           { due_date: Date.today - 26, completed_at: (Date.today - 25).to_time, note: nil },
-        ]
+        ],
       },
       # Upcoming — next week
       {
@@ -75,7 +73,7 @@ module Ketchup
         shared: true,
         history: [
           { due_date: Date.today - 73, completed_at: (Date.today - 73).to_time, note: "All good\n\n- Changed filter\n- Reset thermostat" },
-        ]
+        ],
       },
       # Upcoming — far out
       {
@@ -86,7 +84,7 @@ module Ketchup
         history: [
           { due_date: Date.today - 6, completed_at: (Date.today - 6).to_time, note: "Took longer than expected — **2 hours** instead of 1" },
           { due_date: Date.today - 36, completed_at: (Date.today - 35).to_time, note: nil },
-        ]
+        ],
       },
       # Completed history only — daily
       {
@@ -98,7 +96,7 @@ module Ketchup
         history: [
           { due_date: Date.today - 225, completed_at: (Date.today - 225).to_time, note: "Replaced batteries in **hallway** unit" },
           { due_date: Date.today - 590, completed_at: (Date.today - 589).to_time, note: nil },
-        ]
+        ],
       },
       # Simple upcoming with no history
       {
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index 1a3be7c..9e7259f 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -1,8 +1,7 @@
-# frozen_string_literal: true
-
 require "fileutils"
 require "json"
 require "logger"
+require "tempfile"
 require "uri"
 require "pathname"
 
@@ -11,6 +10,7 @@ require "puma"
 require "puma/configuration"
 require "rack/builder"
 
+require_relative "dev_auth"
 require_relative "seed"
 require_relative "web"
 
@@ -28,9 +28,9 @@ module Ketchup
         manifest = dir / "manifest.json"
         return [] unless manifest.exist?
 
-        JSON.parse(manifest.read).map do |e|
+        JSON.parse(manifest.read).map { |e|
           new(name: e.fetch("name"), path: e.fetch("path"), selector: e["selector"], viewport: e.fetch("viewport", "desktop"))
-        end
+        }
       end
     end
 
@@ -44,10 +44,10 @@ module Ketchup
 
       # Returns { "desktop" => [Comparison, ...], "mobile" => [Comparison, ...] }
       def comparisons_by_viewport
-        VIEWPORTS.keys.each_with_object({}) do |viewport, result|
+        VIEWPORTS.to_h do |viewport, _size|
           baseline = read_entries(@baseline_dir / viewport)
           current = read_entries(@current_dir / viewport)
-          result[viewport] = compare(baseline, current)
+          [viewport, compare(baseline, current)]
         end
       end
 
@@ -59,13 +59,12 @@ module Ketchup
       private
 
       def read_entries(dir)
-        Entry.read_manifest(dir).each_with_object({}) { |e, h| h[e.name] = e }
+        Entry.read_manifest(dir).to_h { [it.name, it] }
       end
 
       def compare(baseline, current)
         return current.map { |name, entry| Comparison.new(name: name, baseline: baseline[name], current: entry) } if baseline.keys == current.keys
 
-        require "tempfile"
         baseline_file = Tempfile.new("baseline")
         current_file = Tempfile.new("current")
         baseline_file.write(baseline.keys.join("\n") + "\n")
@@ -73,7 +72,7 @@ module Ketchup
         baseline_file.close
         current_file.close
 
-        `diff -U9999 #{baseline_file.path} #{current_file.path}`.lines.drop(2).filter_map do |line|
+        `diff -U9999 #{baseline_file.path} #{current_file.path}`.lines.drop(2).filter_map { |line|
           name = line[1..].chomp
           next if name.empty?
           case line[0]
@@ -81,7 +80,7 @@ module Ketchup
           when "-" then Comparison.new(name: name, baseline: baseline.fetch(name), current: nil)
           when "+" then Comparison.new(name: name, baseline: nil, current: current.fetch(name))
           end
-        end
+        }
       end
     end
 
@@ -90,8 +89,12 @@ module Ketchup
     class PageCheckError < StandardError
       def initialize(snap_name:, console_errors:, page_errors:)
         lines = ["Page errors during snap #{snap_name.inspect}:"]
-        console_errors.each { |e| lines << "  console.#{e[:type]}: #{e[:text]}" }
-        page_errors.each { |e| lines << "  page error: #{e}" }
+        console_errors.each do |e|
+          lines << "  console.#{e[:type]}: #{e[:text]}"
+        end
+        page_errors.each do |e|
+          lines << "  page error: #{e}"
+        end
         super(lines.join("\n"))
       end
     end
@@ -115,7 +118,7 @@ module Ketchup
         @browser = Ferrum::Browser.new(
           headless: true,
           window_size: VIEWPORTS.fetch("desktop"),
-          browser_options: browser_options
+          browser_options: browser_options,
         )
 
         subscribe_page_errors
@@ -264,7 +267,7 @@ module Ketchup
           interval_unit: "week",
           interval_count: 1,
           first_due_date: Date.today - 1,
-          shared: true
+          shared: true,
         )
 
         Series.create_with_first_task(
@@ -273,7 +276,7 @@ module Ketchup
           interval_unit: "week",
           interval_count: 2,
           first_due_date: Date.today + 2,
-          shared: true
+          shared: true,
         )
 
         entries << snap("dashboard-shared") do
@@ -297,8 +300,6 @@ module Ketchup
       end
 
       def default_server(browser)
-        require_relative "dev_auth"
-
         app = Rack::Builder.app do
           use Ketchup::DevAuth, "snapshot@example.com"
           run Web.freeze.app
@@ -378,7 +379,7 @@ module Ketchup
       def fill_new_series_form(note:, interval_count: 1, interval_unit: "day")
         textarea = wait_for("#series-note-editor textarea")
         textarea.focus
-        note.each_line(chomp: true).with_index do |line, i|
+        note.lines(chomp: true).each.with_index do |line, i|
           @browser.keyboard.type(:Enter) if i > 0
           @browser.keyboard.type(line) unless line.empty?
         end
@@ -432,7 +433,7 @@ module Ketchup
       def subscribe_page_errors
         @browser.on("Runtime.consoleAPICalled") do |params|
           type = params["type"]
-          text = params["args"].map { |a| a["value"].to_s }.join(" ")
+          text = params["args"].map { it["value"].to_s }.join(" ")
           @console_messages << { type: type, text: text }
         end
 
@@ -451,11 +452,11 @@ module Ketchup
 
         yield
 
-        console_errors = @console_messages.select { |m| %(error warning).include?(m[:type]) }
+        console_errors = @console_messages.select { %(error warning).include?(it[:type]) }
 
         unless console_errors.empty? && @page_errors.empty?
           raise PageCheckError.new(
-            snap_name:, console_errors:, page_errors: @page_errors.dup
+            snap_name:, console_errors:, page_errors: @page_errors.dup,
           )
         end
       end
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index fcb6183..d02b979 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "phlex"
 
 require_relative "layout"
@@ -8,7 +6,7 @@ require_relative "task_card"
 
 module Ketchup
   module Views
-    INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
+    INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { [it, "#{it}(s)"] }.freeze
 
     AGENDA_DAYS = 7
 
@@ -20,7 +18,7 @@ module Ketchup
       end
 
       def view_template
-        overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
+        overdue = @current_user.overdue_tasks.all.sort_by { -it.urgency }
         upcoming = @current_user.upcoming_tasks.all
 
         render Layout.new(current_user: @current_user, flash: @flash) do
@@ -79,8 +77,7 @@ module Ketchup
       end
 
       def render_agenda(upcoming, overdue_count: 0)
-        tasks_by_date = {}
-        upcoming.each { |t| (tasks_by_date[t[:due_date]] ||= []) << t }
+        tasks_by_date = upcoming.group_by { it[:due_date] }
 
         today = Date.today
 
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 8d9e785..4847bfe 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -1,21 +1,18 @@
-# frozen_string_literal: true
-
 require "digest"
+require "pathname"
 require "phlex"
 
 module Ketchup
   module Views
     class Layout < Phlex::HTML
       ASSET_VERSIONS = begin
-        root = File.expand_path("../../../public", __dir__)
+        root = Pathname(__dir__) / "../../../public"
         %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
+        ].to_h { [it, Digest::MD5.file(root / it.delete_prefix("/")).hexdigest[0, 10]] }.freeze
       end
 
       def initialize(current_user:, title: "Ketchup", active_view: nil, flash: nil)
diff --git a/lib/ketchup/views/series/new.rb b/lib/ketchup/views/series/new.rb
index 0c698f4..6c67f42 100644
--- a/lib/ketchup/views/series/new.rb
+++ b/lib/ketchup/views/series/new.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "phlex"
 
 require_relative "../layout"
@@ -25,7 +23,7 @@ module Ketchup
                     button(
                       class: "section-edit-btn",
                       id: "create-series-btn",
-                      disabled: true
+                      disabled: true,
                     ) { "Create" }
                   end
 
@@ -41,10 +39,12 @@ module Ketchup
                       div(class: "interval") do
                         input(
                           type: "number", id: "interval_count", name: "interval_count",
-                          min: 1, value: 1, required: true
+                          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 } }
+                          Views::INTERVAL_OPTIONS.each do |val, label|
+                            option(value: val) { label }
+                          end
                         end
                       end
                     end
@@ -53,7 +53,7 @@ module Ketchup
                       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
+                        value: Date.today.to_s, required: true,
                       )
                     end
 
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index 50fca78..4caba65 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "phlex"
 
 require_relative "../layout"
@@ -32,7 +30,7 @@ module Ketchup
                     button(
                       class: "section-edit-btn",
                       "x-show": "!editing",
-                      "x-on:click": "startEditing()"
+                      "x-on:click": "startEditing()",
                     ) do
                       plain "Edit"
                     end
@@ -40,7 +38,7 @@ module Ketchup
                       class: "section-edit-btn section-edit-btn--cancel",
                       "x-show": "editing",
                       "x-cloak": true,
-                      "x-on:click": "cancel()"
+                      "x-on:click": "cancel()",
                     ) do
                       plain "Cancel"
                     end
@@ -48,7 +46,7 @@ module Ketchup
                       class: "section-edit-btn",
                       "x-show": "editing",
                       "x-cloak": true,
-                      "x-on:click": "save()"
+                      "x-on:click": "save()",
                     ) do
                       plain "Save"
                     end
@@ -71,7 +69,7 @@ module Ketchup
                     dd(
                       class: "detail-edit-interval",
                       "x-show": "editing",
-                      "x-cloak": true
+                      "x-cloak": true,
                     ) do
                       input(
                         type: "number",
@@ -83,7 +81,9 @@ module Ketchup
                         class: "detail-input detail-input-unit",
                         "x-model": "unit"
                       ) do
-                        INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
+                        INTERVAL_OPTIONS.each do |val, label|
+                          option(value: val) { label }
+                        end
                       end
                     end
 
@@ -91,7 +91,7 @@ module Ketchup
                     dd("x-show": "!editing") { @series.shared ? "Yes" : "No" }
                     dd(
                       "x-show": "editing",
-                      "x-cloak": true
+                      "x-cloak": true,
                     ) do
                       label(class: "detail-checkbox-label") do
                         input(
@@ -124,7 +124,7 @@ module Ketchup
                             class: "task-history-date",
                             "x-show": "!editingDate",
                             "x-on:click": "editingDate = true; $nextTick(() => $refs.dateInput.focus())",
-                            "x-text": "new Date(dueDate + 'T00:00').toLocaleDateString()"
+                            "x-text": "new Date(dueDate + 'T00:00').toLocaleDateString()",
                           ) { active_task[:due_date].to_s }
                           input(
                             type: "date",
@@ -134,7 +134,7 @@ module Ketchup
                             "x-model": "dueDate",
                             "x-ref": "dateInput",
                             "x-on:change": "save()",
-                            "x-on:keydown.escape": "dueDate = '#{active_task[:due_date]}'; editingDate = false"
+                            "x-on:keydown.escape": "dueDate = '#{active_task[:due_date]}'; editingDate = false",
                           )
                         end
 
@@ -158,7 +158,7 @@ module Ketchup
                           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}') }"
+                            "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") { "✓" }
@@ -166,7 +166,7 @@ module Ketchup
                                 class: "task-history-date",
                                 "x-show": "!editingDate",
                                 "x-on:click": "editingDate = true; $nextTick(() => $refs.dateInput.focus())",
-                                "x-text": "new Date(completedDate + 'T00:00').toLocaleDateString()"
+                                "x-text": "new Date(completedDate + 'T00:00').toLocaleDateString()",
                               ) { completed_date }
                               input(
                                 type: "date",
@@ -177,7 +177,7 @@ module Ketchup
                                 "x-ref": "dateInput",
                                 "x-on:blur": "save()",
                                 "x-on:keydown.enter": "$el.blur()",
-                                "x-on:keydown.escape": "cancel()"
+                                "x-on:keydown.escape": "cancel()",
                               )
                               span(
                                 class: "task-history-add-note",
@@ -189,7 +189,7 @@ module Ketchup
                               class: "task-history-note-editor",
                               "data-value": ct[:note] || "",
                               "x-show": "hasNote || editing",
-                              "x-ref": "editor"
+                              "x-ref": "editor",
                             )
                           end
                         end
diff --git a/lib/ketchup/views/shared_icon.rb b/lib/ketchup/views/shared_icon.rb
index 27eb791..593e147 100644
--- a/lib/ketchup/views/shared_icon.rb
+++ b/lib/ketchup/views/shared_icon.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "phlex"
 
 module Ketchup
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index c8cb682..346bea4 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "phlex"
 
 require_relative "shared_icon"
@@ -24,13 +22,13 @@ module Ketchup
             button(
               type: "submit", title: "Complete",
               class: "complete-btn",
-              **{ "aria-label": "Complete #{name}" }
+              **{ "aria-label": "Complete #{name}" },
             ) { "✓" }
           end
           div(class: "task-body") do
             a(
               href: "/series/#{@task[:series_id]}",
-              class: "task-name stretched-link"
+              class: "task-name stretched-link",
             ) { name }
             if @shared || @overdue
               div(class: "task-meta") do
diff --git a/lib/ketchup/views/user/show.rb b/lib/ketchup/views/user/show.rb
index e094658..84868a8 100644
--- a/lib/ketchup/views/user/show.rb
+++ b/lib/ketchup/views/user/show.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "phlex"
 
 require_relative "../layout"
@@ -28,7 +26,7 @@ module Ketchup
                     button(
                       class: "section-edit-btn",
                       "x-show": "!editing",
-                      "x-on:click": "editing = true"
+                      "x-on:click": "editing = true",
                     ) do
                       plain "Edit"
                     end
@@ -36,7 +34,7 @@ module Ketchup
                       class: "section-edit-btn section-edit-btn--cancel",
                       "x-show": "editing",
                       "x-cloak": true,
-                      "x-on:click": "editing = false; location.reload()"
+                      "x-on:click": "editing = false; location.reload()",
                     ) do
                       plain "Cancel"
                     end
@@ -44,7 +42,7 @@ module Ketchup
                       class: "section-edit-btn",
                       "x-show": "editing",
                       "x-cloak": true,
-                      "x-on:click": "editing = false; document.getElementById('user-form').requestSubmit()"
+                      "x-on:click": "editing = false; document.getElementById('user-form').requestSubmit()",
                     ) do
                       plain "Save"
                     end
@@ -72,7 +70,7 @@ module Ketchup
                         form: "user-form",
                         class: "detail-input",
                         value: email,
-                        placeholder: "for notifications"
+                        placeholder: "for notifications",
                       )
                     end
                   end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index ea2eedd..8c537cb 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "json"
 require "roda"
 
@@ -112,7 +110,7 @@ module Ketchup
                 "message" => "Archived",
                 "title" => note_title,
                 "path" => "/series/#{series_id}",
-                "undo_path" => archive_path
+                "undo_path" => archive_path,
               }
               r.redirect "/"
             end
@@ -179,7 +177,7 @@ module Ketchup
                   "message" => "Completed",
                   "title" => note_title,
                   "path" => "/series/#{series_id}",
-                  "undo_path" => complete_path
+                  "undo_path" => complete_path,
                 }
 
                 return_to = r.params["return_to"]
diff --git a/lib/sequel/plugins/sole.rb b/lib/sequel/plugins/sole.rb
index 2765190..92cb2e4 100644
--- a/lib/sequel/plugins/sole.rb
+++ b/lib/sequel/plugins/sole.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 module Sequel
   module Plugins
     # The sole plugin adds a +sole+ dataset method that returns the
diff --git a/test/test_db.rb b/test/test_db.rb
index 83fa433..7898646 100644
--- a/test/test_db.rb
+++ b/test/test_db.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require_relative "test_helper"
 
 require "minitest/autorun"
@@ -41,7 +39,7 @@ class TestDB < Minitest::Test
     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
+      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
diff --git a/test/test_helper.rb b/test/test_helper.rb
index b53fc76..c860abe 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require "simplecov"
 SimpleCov.start
 
diff --git a/test/test_seed.rb b/test/test_seed.rb
index df4d7d6..2ec3816 100644
--- a/test/test_seed.rb
+++ b/test/test_seed.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require_relative "test_helper"
 
 require "minitest/autorun"
@@ -21,8 +19,8 @@ class TestSeed < Minitest::Test
         interval_unit: "week",
         interval_count: 2,
         due_date: Date.new(2026, 3, 1),
-        history: []
-      }
+        history: [],
+      },
     ]
 
     Ketchup::Seed.call(user: user, series: series_data)
@@ -46,9 +44,9 @@ class TestSeed < Minitest::Test
         due_date: Date.new(2026, 3, 1),
         history: [
           { due_date: Date.new(2026, 2, 26), completed_at: Time.new(2026, 2, 26, 10, 0, 0), note: "Done" },
-          { due_date: Date.new(2026, 2, 23), completed_at: Time.new(2026, 2, 23, 10, 0, 0), note: nil }
-        ]
-      }
+          { due_date: Date.new(2026, 2, 23), completed_at: Time.new(2026, 2, 23, 10, 0, 0), note: nil },
+        ],
+      },
     ]
 
     Ketchup::Seed.call(user: user, series: series_data)
diff --git a/test/test_sole.rb b/test/test_sole.rb
index 1830df0..fa4b9cf 100644
--- a/test/test_sole.rb
+++ b/test/test_sole.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require_relative "test_helper"
 
 require "minitest/autorun"
diff --git a/test/test_web.rb b/test/test_web.rb
index b5644fa..b6a93e8 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
 require_relative "test_helper"
 
 require "minitest/autorun"
@@ -58,13 +56,13 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "week", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     create_series(
       note: "Bob task", interval_unit: "day", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
-      headers: auth_headers(login: "bob@example.com")
+      headers: auth_headers(login: "bob@example.com"),
     )
 
     get "/", {}, auth_headers(login: "alice@example.com")
@@ -91,7 +89,7 @@ class TestWeb < Minitest::Test
   def test_create_series
     create_series(
       note: "Call Mom", interval_unit: "week", interval_count: "2",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     )
     assert last_response.redirect?
 
@@ -105,7 +103,7 @@ class TestWeb < Minitest::Test
   def test_create_series_creates_first_task
     create_series(
       note: "Call Mom", interval_unit: "week", interval_count: "2",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     )
 
     series = Ketchup::DB[:series].first
@@ -118,7 +116,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Dentist", interval_unit: "quarter", interval_count: "1",
       first_due_date: "2026-06-01",
-      headers: auth_headers(login: "dave@example.com")
+      headers: auth_headers(login: "dave@example.com"),
     )
 
     series = Ketchup::DB[:series].first
@@ -129,7 +127,7 @@ class TestWeb < Minitest::Test
   def test_create_series_strips_whitespace
     create_series(
       note: "  Trim me  ", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     )
     assert_equal "Trim me", Ketchup::DB[:series].first[:note]
   end
@@ -137,7 +135,7 @@ class TestWeb < Minitest::Test
   def test_create_series_rejects_empty_note
     create_series(
       note: "  ", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     )
     assert_equal 422, last_response.status
     assert_equal 0, Ketchup::DB[:series].count
@@ -146,7 +144,7 @@ class TestWeb < Minitest::Test
   def test_create_series_rejects_invalid_interval_unit
     create_series(
       note: "Nope", interval_unit: "fortnight", interval_count: "1",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     )
     assert_equal 422, last_response.status
     assert_equal 0, Ketchup::DB[:series].count
@@ -155,7 +153,7 @@ class TestWeb < Minitest::Test
   def test_create_series_rejects_zero_interval_count
     create_series(
       note: "Nope", interval_unit: "day", interval_count: "0",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     )
     assert_equal 422, last_response.status
     assert_equal 0, Ketchup::DB[:series].count
@@ -164,7 +162,7 @@ class TestWeb < Minitest::Test
   def test_create_series_rejects_invalid_due_date
     create_series(
       note: "Nope", interval_unit: "day", interval_count: "1",
-      first_due_date: "not-a-date"
+      first_due_date: "not-a-date",
     )
     assert_equal 422, last_response.status
     assert_equal 0, Ketchup::DB[:series].count
@@ -217,7 +215,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     task = Ketchup::DB[:tasks].first
@@ -237,7 +235,7 @@ class TestWeb < Minitest::Test
   def test_create_series_requires_auth
     post "/series", {
       note: "Nope", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     }
     assert_equal 403, last_response.status
   end
@@ -285,7 +283,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     series = Ketchup::DB[:series].first
@@ -399,7 +397,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     task = Ketchup::DB[:tasks].first
@@ -494,7 +492,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     series = Ketchup::DB[:series].first
@@ -579,7 +577,7 @@ class TestWeb < Minitest::Test
     get "/", {}, auth_headers  # establish session
     post "/series", {
       note: "No token", interval_unit: "day", interval_count: "1",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     }, auth_headers
     assert_equal 403, last_response.status
   end
@@ -710,7 +708,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     task = Ketchup::DB[:tasks].first
@@ -777,7 +775,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     series = Ketchup::DB[:series].first
@@ -821,7 +819,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     series = Ketchup::DB[:series].first
@@ -845,7 +843,7 @@ class TestWeb < Minitest::Test
   def test_create_shared_series
     create_series(
       note: "Team standup", interval_unit: "week", interval_count: "1",
-      first_due_date: "2026-03-01", shared: true
+      first_due_date: "2026-03-01", shared: true,
     )
     assert last_response.redirect?
 
@@ -856,7 +854,7 @@ class TestWeb < Minitest::Test
   def test_create_private_series_by_default
     create_series(
       note: "Call Mom", interval_unit: "week", interval_count: "2",
-      first_due_date: "2026-03-01"
+      first_due_date: "2026-03-01",
     )
 
     series = Ketchup::DB[:series].first
@@ -868,7 +866,7 @@ class TestWeb < Minitest::Test
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
       headers: auth_headers(login: "alice@example.com"),
-      shared: true
+      shared: true,
     )
 
     get "/", {}, auth_headers(login: "bob@example.com")
@@ -879,7 +877,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Secret task", interval_unit: "week", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     get "/", {}, auth_headers(login: "bob@example.com")
@@ -891,7 +889,7 @@ class TestWeb < Minitest::Test
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
       headers: auth_headers(login: "alice@example.com"),
-      shared: true
+      shared: true,
     )
 
     task = Ketchup::DB[:tasks].first
@@ -910,7 +908,7 @@ class TestWeb < Minitest::Test
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
       headers: auth_headers(login: "alice@example.com"),
-      shared: true
+      shared: true,
     )
 
     series = Ketchup::DB[:series].first
@@ -925,7 +923,7 @@ class TestWeb < Minitest::Test
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
       headers: auth_headers(login: "alice@example.com"),
-      shared: true
+      shared: true,
     )
 
     series = Ketchup::DB[:series].first
@@ -940,7 +938,7 @@ class TestWeb < Minitest::Test
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
       headers: auth_headers(login: "alice@example.com"),
-      shared: true
+      shared: true,
     )
 
     series = Ketchup::DB[:series].first
@@ -957,7 +955,7 @@ class TestWeb < Minitest::Test
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
       headers: auth_headers(login: "alice@example.com"),
-      shared: true
+      shared: true,
     )
 
     series = Ketchup::DB[:series].first
@@ -972,7 +970,7 @@ class TestWeb < Minitest::Test
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
       headers: auth_headers(login: "alice@example.com"),
-      shared: true
+      shared: true,
     )
 
     series = Ketchup::DB[:series].first
@@ -985,7 +983,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Private", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: auth_headers(login: "alice@example.com")
+      headers: auth_headers(login: "alice@example.com"),
     )
 
     series = Ketchup::DB[:series].first
@@ -997,7 +995,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: (Date.today - 1).to_s,
-      shared: true
+      shared: true,
     )
 
     get "/", {}, auth_headers
@@ -1007,7 +1005,7 @@ class TestWeb < Minitest::Test
   def test_task_card_no_shared_icon_for_private_series
     create_series(
       note: "Private task", interval_unit: "week", interval_count: "1",
-      first_due_date: (Date.today - 1).to_s
+      first_due_date: (Date.today - 1).to_s,
     )
 
     get "/", {}, auth_headers
@@ -1018,7 +1016,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Team standup", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
-      shared: true
+      shared: true,
     )
 
     series = Ketchup::DB[:series].first
@@ -1091,7 +1089,7 @@ class TestWeb < Minitest::Test
       _csrf: token,
       note: note, interval_unit: interval_unit, interval_count: interval_count,
       first_due_date: first_due_date,
-      shared: shared ? "1" : nil
+      shared: shared ? "1" : nil,
     }.compact, headers
   end