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
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")