Bootstrap ketchup: recurring-task tracker for family use
Roda/Sequel/SQLite3 stack chosen for simplicity and
single-file deploy to a Tailscale-authed environment.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..b8626c4
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+4
diff --git a/BACKLOG.md b/BACKLOG.md
new file mode 100644
index 0000000..8166f45
--- /dev/null
+++ b/BACKLOG.md
@@ -0,0 +1,45 @@
+# Backlog
+
+## Backlog
+
+### 1. Set up domain layer with Task model and persistence
+
+**What:** Create the Task model (note, interval unit, interval count, due date, personal/shared, owner) with Sequel migration and a repository/service layer separated from the web layer.
+**Who:** Foundation for everything else.
+**Why now:** Nothing works without this.
+**Done when:**
+- Sequel migration creates the tasks table
+- Task domain object exists independent of web layer
+- Can create and retrieve a task via the domain layer
+- Tests pass
+
+### 2. Create a task via the web UI
+
+**What:** A form to create a new task — note (textarea), interval unit, interval count, first due date, personal/shared toggle. Submitted task is saved to the DB for the current user (identified by Tailscale header).
+**Who:** You.
+**Why now:** Can't use the app without tasks.
+**Done when:**
+- Form at a route (e.g., `/tasks/new`) with all fields
+- Submitting saves the task tied to the current user
+- User is identified from the Tailscale remote user header
+- Redirects somewhere sensible after creation
+
+### 3. View the task list (main view)
+
+**What:** The main screen shows all tasks the current user can see (their personal tasks + all shared tasks), sorted: overdue first by relative lateness, then upcoming by due date.
+**Who:** You and your family.
+**Why now:** This is the core of the app — the "catch up" view.
+**Done when:**
+- Main page shows tasks sorted correctly
+- Overdue tasks are visually distinct from upcoming ones
+- Task name (first line of note) is displayed
+- Personal tasks only show for their owner; shared tasks show for everyone
+
+## Icebox
+
+- Mark a task as done (completes it, schedules next occurrence)
+- Edit a task
+- Delete a task
+- Per-completion notes
+- Fixed-schedule recurrence
+- CLI interface
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..11d36dd
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
+
+gem "roda"
+gem "sequel"
+gem "sqlite3"
+
+group :development do
+ gem "minitest"
+ gem "rake"
+ gem "rbs-inline", require: false
+ gem "steep"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..bf1ecc8
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,190 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activesupport (8.1.2)
+ base64
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ json
+ logger (>= 1.4.2)
+ minitest (>= 5.1)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ uri (>= 0.13.1)
+ ast (2.4.3)
+ base64 (0.3.0)
+ bigdecimal (4.0.1)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
+ csv (3.3.5)
+ drb (2.2.3)
+ ffi (1.17.3)
+ ffi (1.17.3-aarch64-linux-gnu)
+ ffi (1.17.3-aarch64-linux-musl)
+ ffi (1.17.3-arm-linux-gnu)
+ ffi (1.17.3-arm-linux-musl)
+ ffi (1.17.3-arm64-darwin)
+ ffi (1.17.3-x86-linux-gnu)
+ ffi (1.17.3-x86-linux-musl)
+ ffi (1.17.3-x86_64-darwin)
+ ffi (1.17.3-x86_64-linux-gnu)
+ ffi (1.17.3-x86_64-linux-musl)
+ fileutils (1.8.0)
+ i18n (1.14.8)
+ concurrent-ruby (~> 1.0)
+ json (2.18.1)
+ language_server-protocol (3.17.0.5)
+ listen (3.10.0)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ logger (1.7.0)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ mutex_m (0.3.0)
+ parser (3.3.10.1)
+ ast (~> 2.4.1)
+ racc
+ prism (1.9.0)
+ racc (1.8.1)
+ rack (3.2.4)
+ rainbow (3.1.1)
+ rake (13.3.1)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
+ rbs (3.10.3)
+ logger
+ tsort
+ rbs-inline (0.12.0)
+ prism (>= 0.29)
+ rbs (>= 3.8.0)
+ roda (3.100.0)
+ rack
+ securerandom (0.4.1)
+ sequel (5.101.0)
+ bigdecimal
+ sqlite3 (2.9.0-aarch64-linux-gnu)
+ sqlite3 (2.9.0-aarch64-linux-musl)
+ sqlite3 (2.9.0-arm-linux-gnu)
+ sqlite3 (2.9.0-arm-linux-musl)
+ sqlite3 (2.9.0-arm64-darwin)
+ sqlite3 (2.9.0-x86-linux-gnu)
+ sqlite3 (2.9.0-x86-linux-musl)
+ sqlite3 (2.9.0-x86_64-darwin)
+ sqlite3 (2.9.0-x86_64-linux-gnu)
+ sqlite3 (2.9.0-x86_64-linux-musl)
+ steep (1.10.0)
+ activesupport (>= 5.1)
+ concurrent-ruby (>= 1.1.10)
+ csv (>= 3.0.9)
+ fileutils (>= 1.1.0)
+ json (>= 2.1.0)
+ language_server-protocol (>= 3.17.0.4, < 4.0)
+ listen (~> 3.0)
+ logger (>= 1.3.0)
+ mutex_m (>= 0.3.0)
+ parser (>= 3.1)
+ rainbow (>= 2.2.2, < 4.0)
+ rbs (~> 3.9)
+ securerandom (>= 0.1)
+ strscan (>= 1.0.0)
+ terminal-table (>= 2, < 5)
+ uri (>= 0.12.0)
+ strscan (3.1.7)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ tsort (0.2.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
+ uri (1.1.1)
+
+PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ x86-linux-gnu
+ x86-linux-musl
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ minitest
+ rake
+ rbs-inline
+ roda
+ sequel
+ sqlite3
+ steep
+
+CHECKSUMS
+ activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae
+ ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
+ concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
+ csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
+ drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
+ ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
+ ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068
+ ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2
+ ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668
+ ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053
+ ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f
+ ffi (1.17.3-x86-linux-gnu) sha256=868a88fcaf5186c3a46b7c7c2b2c34550e1e61a405670ab23f5b6c9971529089
+ ffi (1.17.3-x86-linux-musl) sha256=f0286aa6ef40605cf586e61406c446de34397b85dbb08cc99fdaddaef8343945
+ ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5
+ ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
+ ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
+ fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+ i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
+ json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
+ language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
+ listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
+ mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
+ parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
+ rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
+ rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+ rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
+ rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
+ rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6
+ rbs-inline (0.12.0) sha256=0b84ec69fa5b3deb89e60f08d9b47966c9e26a222c7c65b5014857a3b10c62b2
+ roda (3.100.0) sha256=35d36f43c68d2bd1974dc77ade8c873558265a191018e1ada88ca662f1fa8e62
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+ sequel (5.101.0) sha256=d2ae3fd997a7c4572e8357918e777869faf90dc19310fcd6332747122aed2b29
+ sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473
+ sqlite3 (2.9.0-aarch64-linux-musl) sha256=56a35cb2d70779afc2ac191baf2c2148242285ecfed72f9b021218c5c4917913
+ sqlite3 (2.9.0-arm-linux-gnu) sha256=a19a21504b0d7c8c825fbbf37b358ae316b6bd0d0134c619874060b2eef05435
+ sqlite3 (2.9.0-arm-linux-musl) sha256=fca5b26197c70e3363115d3faaea34d7b2ad9c7f5fa8d8312e31b64e7556ee07
+ sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3
+ sqlite3 (2.9.0-x86-linux-gnu) sha256=47317ba230f6c2c361981aa5fc1bf9de1b99727317171393ba90abab092c5b5f
+ sqlite3 (2.9.0-x86-linux-musl) sha256=b627f3a2ca59aaaa5e10b8666cdbd7122469b49afa4bd895133cecb7b5c1368d
+ sqlite3 (2.9.0-x86_64-darwin) sha256=59fe51baa3cb33c36d27ce78b4ed9360cd33ccca09498c2ae63850c97c0a6026
+ sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5
+ sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90
+ steep (1.10.0) sha256=1b295b55f9aaff1b8d3ee42453ee55bc2a1078fda0268f288edb2dc014f4d7d1
+ strscan (3.1.7) sha256=5f76462b94a3ea50b44973225b7d75b2cb96d4e1bee9ef1319b99ca117b72c8c
+ terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+ tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
+ unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
+ unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+
+BUNDLED WITH
+ 4.0.3
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a76b2d1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,44 @@
+# ketchup
+
+A personal/family tool for tracking recurring tasks and catching up on what's overdue.
+
+## Overview
+
+- **Users:** Me and my family
+- **Auth:** Handled via Tailscale — rely on remote user header (no in-app auth)
+- **Interface:** Web app (Roda), architected so a CLI can be added later
+- **Stack:** Ruby, Roda, Sequel, SQLite3
+
+## Key concepts
+
+- **Tasks** are recurring, with a configurable interval (day/week/month) and interval count
+ - Recurrence is based on interval+count from last completion (not fixed schedule — but may add fixed schedule later)
+- Each user has their own tasks
+- Tasks can also be **shared** — visible to all users, anyone can mark them done, overdue shows for everyone
+- One-off tasks are out of scope for now
+- No task assignment — shared tasks are not assigned to a specific person
+- Each task has a **note** (free text) — the first line is the task name
+ - Per-completion notes may be added later
+
+## Task fields
+
+- **Note** — free text; first line serves as the task name/title
+- **Interval unit** — day, week, or month
+- **Interval count** — e.g., 2 (combined with unit: "every 2 weeks")
+- **First due date** — user picks this on creation
+- **Personal or shared**
+
+## Primary use case
+
+Catching up with friends and family — some weekly, some quarterly, some yearly, etc.
+
+## Main view
+
+A single list of tasks in priority order, automatically sorted:
+
+1. **Overdue tasks first** — ordered by how late they are *relative to their interval* (most proportionally overdue at top)
+2. **Upcoming tasks after** — ordered by due date (soonest first)
+
+## Architecture
+
+Domain logic should be separated from the web layer so a CLI can be added later.
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..b0ec822
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,3 @@
+require "minitest/test_task"
+
+Minitest::TestTask.create