Blog

Building My Daily Hub
Tool 6: Goals

A Postgres exclusion constraint that polices overlapping date ranges on its own, an icon per goal instead of per habit, and reusing the same confirm modal for the third screen in a row.

Tool 5 split "Goal Definitions" into Habits and Goals. Habits is the identity of the thing you're tracking; Goals is the other half — a period-scoped target against a habit, like "3 times a week" or "walk 10 miles this month." This is that second half.

A Constraint That Catches What the Form Can't

A habit can have more than one goal over its lifetime — a "3x/week" goal that ends and gets replaced by a "5x/week" one, say — but two goals on the same habit can never be active at the same time. If they were, a logged progress entry on progress_entries.goal_id wouldn't have a single unambiguous goal to point to.

The form checks for this before submitting — a client-side rangesOverlap() that compares the new date range against every existing goal on that habit and surfaces a specific, readable error naming the conflicting goal instead of a raw database message. But that check isn't what actually stops it. The real guard is a Postgres exclusion constraint on the goals table:

exclude using gist (habit_id with =, daterange(start_date, coalesce(end_date, 'infinity'), '[]') with &&)

Read sideways, that says: for any given habit, no two rows are allowed to have date ranges that overlap — full stop, enforced by the database itself, not by application logic that has to remember to check. It needed the btree_gist extension enabled to let an exact-match column (habit_id) and a range-overlap check (the daterange) live in the same index. If a bug or a race condition ever let two overlapping inserts through the client-side check, Postgres rejects the second one outright.

Same Backstop as Habits, Applied to Delete and End

Edit and Delete only show up on a goal that has no logged progress yet — checked with a count query against progress_entries before rendering either button. That's the same pattern Tool 5 used for locking a habit's tracking mode once it has goal history, and the reasoning doesn't change: the UI check exists to give a clear answer immediately, not to be the actual thing keeping the data honest.

The actual enforcement is progress_entries_goal_id_fkey, which is ON DELETE NO ACTION. If a delete request somehow reaches the database for a goal that does have progress logged against it, Postgres refuses it with a foreign-key violation (error code 23503), and the app catches that specific code and turns it into "This goal has logged progress and can't be deleted" instead of a stack trace.

Goals don't have an archive option the way habits do, because there isn't a reversible middle ground that makes sense here — a goal is either still open (and you can End it, which just sets its end date and stops it from being "active" going forward) or it's done and safe to delete. Ending a goal doesn't need a confirmation; deleting one does.

One Confirm Modal, Two More Jobs

ConfirmModal — built for Tasks, reused as-is for Habits — now handles End Goal and Delete Goal too, each with its own title, message, and danger styling, same component both times. With this, every destructive confirmation left in the app (Delete Task, End Goal, Delete Goal, Delete Habit, Reset Demo Data) runs through the same modal instead of window.confirm() — closing out a gap that had been sitting open since the design system was first built.

An Icon Per Goal, Not Per Habit

The icon lives on the goal row, not the habit — icon_key references icon_options, a shared table of 83 curated Phosphor icons, and it's picked fresh every time a new goal is created, through a searchable combobox (Radix Popover + cmdk). That means a habit with a history of several goals over time can have each one carry its own icon, which matters more than it sounds like it would when you're scanning a list of goals grouped under one habit name and trying to tell them apart at a glance.

What Editing a Goal Doesn't Let You Touch

The habit a goal belongs to is locked the moment you click Edit — the dropdown is disabled, not hidden, so you can see which habit it's attached to without being able to change it. Reassigning a goal to a different habit mid-life would raise the exact same question Tool 5 ran into with habit tracking modes: what happens to the date-range and progress assumptions that were made under the old context. Rather than design around that, it's simply out of scope — end the goal and start a new one on the right habit instead.

A Small Default That Saves a Click

Switching the period dropdown to "Month" auto-fills the end date to the last day of that month — still editable, just a reasonable starting point instead of an empty field. Day and week goals stay open-ended unless you set an end date yourself, since those are usually meant to keep running until you decide to end them.

What's Next

Tool 7 is Habit / Goal Tracking — the actual daily screen for logging progress against a goal and seeing where you stand for the period. Everything in Habits and Goals so far has been setup; this is the piece that replaces checking a box on the paper tracker, which is the whole reason this app exists in the first place. The backend already exists (progressService.ts: increment, decrement, sum, both modes) — it's just never been called from a screen yet.

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