Schedule history: how I let users edit a habit without breaking last month's stats
I edited a habit from 'every day' to weekdays, and last month's completion rate jumped to 109%. Here's why that happens, and the small append-only fix that made the old numbers stop moving.
I was four weeks into using my own habit tracker when a number on the screen made me stop. I had a habit called 'Cold Shower' that ran every day in February. In March I switched it to Mon/Wed/Fri, opened the stats tab, and watched February's completion rate climb to 109%. Not 100%. 109%. A number that's supposed to be 'how often did I do this' had quietly gone impossible.
The question I had was: why does editing a habit today change what last month looked like? The short answer is that I was storing the schedule as one mutable value, so the moment I changed it, every past month re-scored itself against a schedule that didn't exist back then. The fix is to treat the schedule as a little history instead of a single value. Everything interesting is in how small that turned out to be.
Why does editing today change last month?
Here's the data model. A habit row has a repeatDays array (which weekdays it fires, 0 to 6), and a separate table of completions: one row per day I ticked it. The stats engine walks each day of a month and asks a single question: was this habit scheduled on day D? If yes, the denominator goes up. If a completion exists for that day, the numerator goes up. Divide, and you get a percentage.
That math is correct under one assumption I never said out loud: that the schedule on the row is the schedule it always had. The assumption held for exactly as long as I never edited a habit. The instant I did, the engine asked 'how many weekdays are in February?' (20) and 'how many completions exist?' (28, because I'd ticked it every day). 28 over 20 clamps and averages into something north of 100. The completions were real. The denominator was a lie I'd written one minute earlier.
Think of it like a receipt. The schedule is the price list, and the completions are line items I already paid for. If I reprint last month's receipt using today's prices, the total is fiction. I need the price list that was active when the purchase happened, not the current one.
What did I try first that didn't work?
My first instinct was to lock the schedule once a habit had completions: if you want a different schedule, archive the old habit and start a fresh one. That's the 'just use Vim' answer to a UX complaint. Editing a habit is a basic verb, and 'archive and re-create' loses the streak and splits the history across two rows. I rolled it back the same evening.
The fix I almost shipped was sneakier. I added a previousRepeatDays field and an edit timestamp, so stats before the edit used the old schedule and stats after used the new one. It worked exactly once. The moment I edited the same habit a second time, previousRepeatDays held the schedule between edits one and two, not the original. I'd built a two-cell sliding window over something that wanted to be a list. That's when it clicked: I was trying to derive a history from a single mutable row, when I should derive the current row from the history.
What does the history actually look like?
It's a list of entries, each with a from date and the schedule that started on that date. A habit created March 1st as daily starts with one entry: { from: '2026-03-01', repeatDays: [0..6] }. Edit it to weekdays on April 3rd and I append { from: '2026-04-03', repeatDays: [1..5] }. Nothing gets overwritten. Asking 'what was the schedule on March 15th?' is a walk through the list, taking the last entry whose start date is on or before March 15th.
Here's the resolver. It lives in one file, and it's the whole idea:
// Get the active schedule for a habit on a specific date
export function getScheduleForDate(habit, dateStr) {
const history = habit.scheduleHistory || []
if (history.length === 0) {
return {
repeatDays: habit.repeatDays || [0, 1, 2, 3, 4, 5, 6],
customDates: habit.customDates || [],
}
}
// Find the last entry where from <= dateStr
let active = history[0]
for (const entry of history) {
if (entry.from <= dateStr) active = entry
else break
}
return active
}The string comparison entry.from <= dateStr works because both sides are YYYY-MM-DD strings, and ISO dates sort the same whether you compare them as text or as dates. That's the one trick worth keeping: store dates as ISO strings and a plain <= just works. The stats engine no longer reads the schedule off the habit row at all. It calls this resolver per day, and the historically correct denominator falls out for free.
How does the edit form append instead of overwrite?
The edit form calls one store action, updateHabit. Before, it was a passthrough to the database. Now it checks whether the schedule changed, and if so, snapshots a new entry onto the history before writing:
updateHabit: async (id, updates) => {
// If schedule changed, append to history
if (updates.repeatDays || updates.customDates) {
const habit = await db.habits.get(id)
if (habit) {
const history = habit.scheduleHistory || []
const today = new Date().toISOString().split('T')[0]
const newEntry = {
from: today,
repeatDays: updates.repeatDays || habit.repeatDays,
customDates: updates.customDates || habit.customDates,
}
updates.scheduleHistory = [...history, newEntry]
}
}
await db.habits.update(id, updates)
await get().loadHabits()
},Two small things in there matter more than they look. The guard only fires on schedule changes, so renaming a habit or changing its color doesn't pollute the history. And the new entry always records both fields, falling back to the habit's current value for whichever one you didn't touch. A snapshot has to be complete, or the period it covers loses half its context.
Things that surprised me
A few bits I had to think about twice before they clicked:
- The migration has to lie a little. For habits that existed before this feature, I can't know when they were edited, so I seed one entry starting at the habit's creation date. It claims the current schedule was always the schedule. It's a lie, but a stable one, and every habit from now on gets a real history.
- The 'from' date is the user's local day, not a UTC timestamp. Editing at 11pm in Copenhagen should start the new schedule today, not tomorrow because UTC already crossed midnight. Habit tracking belongs to the day the person is living in.
- Editing twice in one day just works. Both entries share a 'from', the resolver's loop keeps overwriting and lands on the last one, and the latest schedule wins. No dedupe needed.
- The seed data nearly bit me. If a seeded habit ships without a history entry, it falls through the 'empty history' branch back to the row, which is the exact bug I'd just spent two weeks fixing. I added the entry to the seed so first-run users never hit that path.
Was it worth it?
The test was the same gesture that found the bug: open the stats tab, edit a habit, look again. The old numbers should not move. They didn't. February stayed at 100%, March settled at the rate I'd actually hit, lower than I'd like to admit. I'd been carrying about forty lines of duplicated scheduling logic across three components, and routing everything through one resolver deleted thirty of them.
Here's when I think this is worth caring about. Any field that has historical reporting hanging off it is event sourced whether you write it that way or not. If you don't store the history, you're rewriting the past every time the user edits the row. For a small habit tracker used by me and three friends, the blast radius was tiny. But it ate six hours over two weeks and quietly broke my trust in the one screen the whole app exists to show. If you've got a field whose old values still need to mean something, model it as a list of timed entries before you build the editor. It's cheap early and a refactor later. If the past doesn't matter, a plain mutable field is fine, and you can skip all of this.


