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
change nvmmzxmxulqvxtzwuxpkqqpywouwxxun
commit 63f80038b3ce8dd770dbc727843db6ca660dbd42
author Alpha Chen <alpha@kejadlen.dev>
date
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