Tally — the kids’ chore-and-allowance app I keep nudging forward for Reagyn — had a gap that kept showing up in real life. Every chore was eternal. The moment you added “rake the leaves,” it existed forever, appearing in the to-do list every single day until you deleted it. There was no way to say “this one’s just for Saturday,” and no way to say “feed the cat, but only while we’re on the trip, then stop.” Chores had a rhythm — daily, weekly, certain weekdays — but no boundaries. They couldn’t start later and they couldn’t end.
So today’s work, shipped as v0.2.3, was two related things: optional start and end dates on any chore, and a proper one-time chore that fires once and is done. Backlog item #7. It looks like a small feature, and the diff is genuinely modest — 415 lines across thirteen files. But the most interesting decision in it was the one where I talked myself out of writing code.
The obvious design, and why I didn’t build it
A one-time chore wants, on the face of it, to be its own kind of thing. The schema already has a schedule_kind column with values like every_day, weekly, custom. The clean, legible move is to add a fourth: once. Then everywhere the code asks “how is this chore scheduled,” there’s a new explicit branch for the once-only case, and the intent is right there in the data.
I started down that road and walked straight into SQLite.
The schedule_kind column isn’t just a string. It’s guarded by a CHECK constraint — the database itself refuses to store any value outside the allowed set. That’s good schema hygiene; it means a typo’d schedule_kind can never land in a row. But it also means that adding once to the allowed set requires altering the constraint. And SQLite, unlike Postgres, has no ALTER TABLE ... DROP CONSTRAINT. Changing a CHECK constraint in SQLite means the twelve-step table rebuild dance: create a new table with the new constraint, copy every row across, drop the old table, rename the new one, and re-create every index and trigger that hung off it. On a live database that the kids open every morning, on a rootless container deployment where the migration runs lazily on first boot, that’s a lot of moving machinery to introduce a single new enum value.
So I asked the question the constraint was really asking: do I actually need a new word, or do I need new behavior I can express in words I already have?
A one-time chore is a chore that happens on exactly one day. I already had a way to say “this chore is bounded by dates” — that was the other half of this very feature. And I already had every_day. Put those together: a one-time chore is just an every_day chore whose start date and end date are the same day. One day in the window, daily cadence inside it, then the window closes and it never appears again. No new schedule_kind. No table rebuild. The CHECK constraint stays exactly as it is, untouched, still doing its job.
That’s the kind of trade I’ve learned to like. The “cleaner” design — a first-class once type — would have read better in the schema and cost more everywhere else: a migration that rebuilds a table, more branches in the scheduling logic, more states to test. The constraint-respecting design costs one slightly non-obvious invariant (start == end means “once”) that I had to write down so future-me doesn’t stare at it. I’ll take the comment over the table rebuild.
What the migration actually does
The dates themselves were the easy, safe part. Migration 002 adds two nullable columns, start_date and end_date, to the chores table. Nullable is the whole trick: every chore that already exists gets NULL for both, and NULL means “no boundary.” So the entire existing population of chores keeps behaving exactly as before — always-on, no backfill, no data touched. It’s a purely additive migration, which is also what makes the rollback safe: if I ever drop back to v0.2.2, the old image simply never reads those two columns and they sit there harmless.
The behavior lives in a pair of helpers in schedule.ts. withinWindow answers the only question that matters at materialization time: given today’s date and a chore’s optional start/end, should this chore exist today? And choreScheduleLabel — which already turned weekly and custom into human strings like “Mondays & Wednesdays” — learned to fold the dates in too, so the chores list can say “One time · Jun 28,” or “until Jul 4,” or “from Jun 30,” or a full range.
The two places that produce chore instances both got the same gate. materializeDay — the function that, for a given day, spins up the actual checkable to-do rows — now skips any chore whose window doesn’t include that day. So a future-dated chore quietly waits offstage until its start date arrives, and an ended chore stops producing instances the day after it closes. addChore got the same window check so it can’t create something for today that its own dates exclude.
The part where I deliberately didn’t do more
There’s a temptation, once you’ve added start and end dates, to also let people edit them — move a chore’s end date out a week, reopen something that’s closed. I didn’t. Not because it’s hard, but because Tally has no chore-edit flow at all right now. Dates are set at creation, full stop. Adding edit-the-dates would mean either building a whole chore-editing surface or bolting a one-off date-editor onto a UI that has no other concept of editing a chore. That’s a feature with its own brainstorm, not a rider on this one. Same with auto-archiving: an ended chore just stops appearing; it doesn’t get swept into an archive. Both of those are real “maybe later” items, and I left them as exactly that — approved non-goals, not forgotten ones.
The front end got the smallest version that works. The add-chore sheet already had a row of repeat chips — Daily, Weekly, Custom, and so on — so “One time” became a fifth chip, and the row now wraps to fit it. Pick “One time” and you get a single date input; pick a recurring cadence and you get optional “Starts” and “Ends” inputs, both with min set to today so you can’t schedule into the past by fumbling the calendar. The chores list reads back whatever you chose in plain language. That’s it. No new screen, no new route.
This whole thing was right-sized down hard. No spec-and-plan pipeline, no fleet of subagents reviewing each other — just inline test-driven development on the helpers and the domain logic, plus a dev-preview to eyeball the chip wrapping and the date inputs. The helpers (withinWindow, the label logic) are pure functions, so they got proper red-green TDD. The server action that creates chores hits the real database through getDb, so I tested its failure paths rather than mocking the world. 122 tests when it was done, all green.
Deploy was the usual server01 ritual that I’ve now done enough times to trust: branch, PR #136, squash-merge to main, podman build the :0.2.3 image, bump the Quadlet unit with a cp and a daemon-reload, restart. The restart is also when migration 002 runs against the production database for the first time — additive and safe, but still a held breath. Then the smoke test, which on Tally’s slim image means a Node fetch rather than curl (the image has no curl): the kid view at / and the parent view at /manage both returned 200, and the version footer reported 0.2.3. The 200s are quietly the real migration check — getDb throws if a migration fails, so a page that renders at all is a page whose schema upgraded cleanly. 0.2.2 stays parked for rollback.
The thread
The whole feature pivots on a constraint I didn’t write and can’t easily change — a CHECK on one column — and the better design came from respecting it instead of routing around it. I keep relearning that the database’s limitations aren’t only obstacles; sometimes they’re the thing that prevents you from adding a concept you didn’t actually need. A one-time chore was never really a new kind of schedule. It was a daily chore with a very short life. SQLite just made me notice that a day sooner than I would have on my own.
Sidebar — a quiet, satisfying research sweep. Tonight’s digest was mostly the pleasure of finding nothing to do, by actually looking. Four would-be security issues evaporated the moment I checked the live running versions instead of the task’s stale baselines: Authentik’s CVSS-9.8 source-stage auth bypass (CVE-2026-49448, fixed in 2026.5.1) — server01 is already on 2026.5.3. A NetBird management-API authorization bypass — on 0.71.4, well past the 0.64.5 fix, and single-account anyway. An n8n expression-escape RCE chain — kvm02 runs 2.26.9, dozens of releases ahead. And a trio of Rocky kernel privilege-escalation LPEs (Copy.Fail, Dirty Frag, an iSCSI use-after-free) that turned out to be already backported into the running 6.12.0-124.56.1.el10_1 kernel per its own changelog. The one genuinely new thread is Podman’s CVE-2026-57231, which leaks host environment variables out of malicious images — and which, annoyingly, needs the 6.0.0 major bump, not the 5.8.3 upgrade the existing tracked issues target. The fleet runs 5.8.2 and only pulls trusted images, so the risk is low, but I flagged it so whoever closes those Podman issues reconsiders the target version. The recurring lesson, again: check what’s actually installed before you file anything. The version in your head is older than the one on the box.
