Blog

Building My Daily Hub
Tool 5: Habits

Splitting "Goal Definitions" into two tools, a delete guardrail the database backs up whether the UI cooperates or not, and editing a row in place instead of scrolling back to the top of the page.

The original roadmap had Tool 5 as one combined "Goal Definitions" tool. Once I actually started building it, that didn't hold up. A habit — the thing you're tracking, like "Walk" or "Read" — and a goal — a period-scoped target against that habit, like "3 times a week" — have different lifecycles and different rules about when they can change. Trying to force both into one table and one form was making the edit/delete logic tangled for no real benefit. So I split it: Habits is Tool 5, Goals is Tool 6, and this post is just the first half.

Three Tracking Modes, One Constraint Doing the Enforcing

A habit can be tracked one of three ways: boolean (did you or didn't you), frequency (how many times), or cumulative (adds up to a running total, in whatever unit you pick — "0.5 miles" per walk, "15 minutes" per reading session). Cumulative habits need a unit and an increment amount; the other two don't. Rather than trust the form to always send the right shape of data, the habits table has a CHECK constraint that enforces it directly:

check ((tracking_mode in ('boolean', 'frequency') and unit is null and increment_amount is null) or (tracking_mode = 'cumulative'))

If something upstream ever sends a boolean habit with a stray unit value, or a cumulative habit missing its increment amount, Postgres rejects the insert. The form validates this too, but the form isn't the real guarantee — the constraint is.

Delete vs. Archive, and Why the UI Isn't the Real Guardrail Either

Every habit either gets a Delete button or an Archive button, never both, decided by one thing: has it ever had a goal attached to it. A fresh habit with no goal history can be deleted outright. The moment a goal exists, Delete disappears and Archive takes its place — along with a "Locked" badge next to Edit, since changing a habit's tracking mode out from under goals and progress entries that assumed the old one would be its own kind of mess.

Same lesson as the Delete-button guardrail on Tasks: don't trust the UI alone to be the only thing standing between a user and a bad delete. goals.habit_id references habits.id with no ON DELETE CASCADE. Even if a bug let the Delete button render for a habit that actually has goals, the database would refuse the delete and nothing would get silently orphaned. The UI check is there to give a clear, immediate answer instead of a foreign-key error — it isn't the thing actually keeping the data honest.

Editing a Row in Place

Every earlier tool in this app opens its edit form at the top of the page, above the list. That's fine for three items. It stopped being fine the moment I tested Habits with fifteen rows and had to edit something near the bottom — click Edit, get yanked to the top, lose your place, then scroll back down once you're done.

For Habits, clicking Edit swaps that row for the form right where it sits. Everything below it shifts down to make room, nothing scrolls, and when you cancel or save it collapses back into a row in the same spot. The name field also autofocuses the moment the form appears, for both editing and adding new — a small thing, but it's the difference between clicking "+ New Habit" and immediately typing versus clicking it, then clicking into the field, then typing.

Reusing the Modal Instead of Rebuilding It

The ConfirmModal component I built for Tasks — the one that replaced window.confirm() there — gets reused here as-is for deleting a habit. No copy-pasted modal markup, no second implementation to keep in sync. It's the same component taking a different title and message. That's the payoff of pulling it out as its own component instead of leaving the confirm logic inline in the Tasks file where I first needed it.

Archiving a habit, on the other hand, doesn't ask for confirmation at all — it's reversible with one click (Unarchive), so a confirm step there would just be friction for something that isn't actually risky.

Formatting the Same Detail in Two Places

A cumulative habit's increment detail — "0.5 miles," "15 minutes" — needs to show up both in the habit list and, later, in Goals' own habit picker, so you know what a goal's target count actually means before you set it. Instead of writing that formatting twice, it's a small shared utility, formatIncrementDetail(), that both tools import. Small thing, but it's the kind of duplication that quietly drifts out of sync if you let each component format it independently.

What's Next

Tool 6 is Goals — period-based targets against a habit, with their own icon picker and a database constraint that stops two goals on the same habit from having overlapping active date ranges, which turned out to be a more interesting problem than it sounds. After that, Tool 7 is the actual daily tracking screen: the piece that replaces checking a box on the paper tracker, which is the whole reason this app exists in the first place.

The live app is at dailyhub.christopherkaelin.com — demo mode works without an account if you want to poke around.