DEV Community

Cover image for Your daily reminder needs three states, not a boolean
Akram Sakib
Akram Sakib

Posted on

Your daily reminder needs three states, not a boolean

I built a checklist that opens itself at 8 am. The hard part wasn't opening it. It was working out when to stop.

The version that fails

The obvious model is a boolean. One flag per day:

{ "2026-09-20": { opened: false } }
Enter fullscreen mode Exit fullscreen mode

A timer checks every thirty seconds. Is it past 8am, and is opened still false? Show the window. When the person presses Start My Day, set it true and go quiet until tomorrow.

It works, right up until someone closes the window instead.

Closing doesn't set opened. So the day is still "not opened". Thirty seconds later the timer fires, sees an unanswered day, and puts the window back. Thirty seconds after that, again.

The only escape from a window you were trying to escape is to press the button that says you did the work. That's not a reminder. That's a hostage situation with a checkbox.

The fix isn't a better flag

I spent a while trying to patch around it. A closedAt timestamp. A snooze counter. A grace period. Every one of them was a workaround for asking the wrong question.

The flag asks: has this opened?

What the app actually needs to know is: has this been answered?

Those aren't the same question, and one boolean can't hold both.

Three states

done        you pressed the button
dismissed   you closed the window
owed        neither — and the only one that opens anything
Enter fullscreen mode Exit fullscreen mode

In the store, a slot starts empty and settles one of two ways:

function blankSlot() {
  return { done: false, dismissed: false, at: null };
}

// A slot is settled once it has been done OR deliberately dismissed.
function settled(slot) {
  return Boolean(slot && (slot.done || slot.dismissed));
}
Enter fullscreen mode Exit fullscreen mode

owed isn't stored. It's the absence of the other two, which is exactly what it is in real life. And the timer gets one line shorter, because it now asks the right question:

slotDue(name, now = new Date()) {
  const day = this.ensureToday(now);
  if (settled(day[name])) return false;
  // ...time checks
}
Enter fullscreen mode Exit fullscreen mode

Closing the window is an answer. It just isn't the same answer as doing the work.

That sentence is the whole post. Everything else is consequence.

What fell out for free

Two problems I hadn't solved yet solved themselves once the third state existed.

Opening the app at 11 pm. Answer the morning slot, and the evening check-in is due immediately — two windows for one sitting. With three states, answering one slot can quietly dismiss any other whose time has already passed:

settleOverdue(answered, now = new Date()) {
  ['morning', 'evening'].forEach((name) => {
    if (name === answered) return;
    if (this.slotDue(name, now)) this.slot(name, now).dismissed = true;
  });
}
Enter fullscreen mode Exit fullscreen mode

Minimising. It looks like closing, but it isn't an answer — the person put the window somewhere; they didn't respond to it. So the slot stays owed, and the timer leaves a minimised window alone instead of popping it back up. Under a boolean, I'd have had no way to say that.

The general shape

If a boolean is forcing you to invent workarounds, it's usually not the wrong value. It's the wrong question. Ask what the user actually did, not what your feature wanted them to do — and count declining as a real answer, because that's what it is.

The store is one file if you want to read it: src/store.js. Slot logic is about a third of the way down.


It's a free, open-source morning checklist for Windows. No account, no server, no network calls of any kind — one JSON file in %APPDATA% you can open in Notepad.

https://daily-start-sigma.vercel.app/?utm_content=post_2&utm_source=dev.to

Top comments (1)

Collapse
 
beusebiu profile image
Eusebiu Balan

Skipped and not answered yet collapsing into one value is the bug I keep reintroducing. A habit day has three outcomes and only two of them are the person actually saying something.

Once skip became its own state the reminders stopped nagging, and a pile of queries that had been quietly excluding those rows started returning them.