Closing the Day — Building the Daily Review Reminder
A look at the decisions behind a 22:00 reminder to do today's review — local vs server push, what 'completed' means, and a hidden gap in language settings.
Closing the Day
Why add this
Fecit has had a daily review feature for a while. At the end of the day, you open it, leave a description, and set a satisfaction score. But there was a problem: you had to remember to open it yourself. Reviewing is exactly the kind of thing that gets pushed to “later” and never comes back, and the app had no mechanism to intervene in that postponement.
So a simple addition: at a user-set time, push “Let’s look back on your day.” Default 22:00, opt-in.
Sounds trivial. While building it, the direction changed four or five times.
Fork #1: Local vs server push
Intuitively, local scheduled notifications look cleanest. expo-notifications fires at 22:00 on the device every day, done. Works offline, no server load.
But the deeper I went, the more local’s problems piled up:
- “Don’t fire if already reviewed” requires the client to know that state. The local DB cache exists but sync-timing issues show up.
- iOS caps scheduled notifications at 64 per app. Other features share that quota — this would burn one more.
- Six triggers: app startup, foreground, preference change, review edit, logout, midnight boundary. Each has to trigger a reschedule.
- The client has to manage timezone changes too.
Decisively, Fecit already had an APScheduler cron pinging the API every minute — jobs like create_reserved and notify_start_date already running on this cadence. “Use what’s already there” tipped it.
Going server-push:
- The client only needs a setter API and a push-response handler — zero scheduling code.
- The server is authoritative — satisfaction check is a direct DB query, always accurate.
- All the timezone / midnight / app-state edge cases disappear.
- No iOS quota concerns.
Fork #2: What does “done” mean?
First I defined it as “a daily review exists for today → completed.” But a user could open the review screen, type a description, and leave without setting satisfaction. The DB has a record but the review is effectively half-done.
I changed the completion signal to satisfaction != null. Description is optional; satisfaction is the thing that signals “I actually evaluated my day.” The push filter uses the same rule:
review = await daily_review_collection.find_one(
{
'created_by': achiever_id,
'date': local_midnight,
'satisfaction': {'$ne': None},
},
{'_id': 1},
)
if review is not None:
return # already done → skip
Fork #3: Preventing duplicate fires
The per-minute cron fires a push when a user’s local time hits their configured time. But if the cron hiccups or the server restarts, we risk sending the same user multiple pushes.
I reused the fire lock pattern already used for the create_reservation flow:
- Collection:
daily_review_prompt_fire_lock - Key:
(achiever_id, local_date)with a unique index fired_atfield with a 25-hour TTL — auto-deletes after a day
# Idempotency: only the first insert per local date succeeds
try:
await daily_review_prompt_fire_lock_collection.insert_one({
'achiever_id': achiever_id,
'local_date': local_date_str,
'fired_at': datetime.now(timezone.utc),
})
except DuplicateKeyError:
return # already fired today → skip
The DB itself guarantees “once per day.” No application-level bookkeeping.
Fork #4: A hidden gap in language
To push in the user’s language, the server has to know what the user’s language is. Fecit already had achiever_preference.language, so “just read that.”
Except when I looked, most users had preference.language set to null. The reason was simple — the client never auto-recorded device locale into this preference. It was only populated when a user explicitly changed language in settings; otherwise, i18next displayed Korean/English by falling back to the device locale.
The app worked fine because i18next handled it. But when the server needs to send something to the user, it had no reliable signal — push bodies, emails, outbound links all affected.
The fix was small. On app start, if preference.language is null and the device locale is ko or en, record it once:
if (!preference.language) {
const deviceLang = Localization.getLocales()[0]?.languageCode;
if (deviceLang === "ko" || deviceLang === "en") {
setAchieverPreferenceLanguage(deviceLang)
.then((updated) => setAchieverPreference(updated))
.catch(() => {});
}
}
This fix wasn’t only for the daily review reminder — it’s foundation for every future case where the server needs to speak to the user in their language.
Naming — “Wrap-up” to “Review”
My first draft labeled it "Daily Wrap-up Reminder" in English. “Review” felt heavy, so I reached for something lighter. But the existing feature was already "Daily Review". A new setting using different wording invites the user to ask “is this something different?”
Aligned them:
Daily Review Reminder/일일 회고 알림
The push body stayed soft though — “Let’s look back on your day” / “오늘 하루 돌아볼까요?”. Setting labels are feature names and need to be precise; push bodies are invitations and can be gentle. Different jobs, different tones.
What the implementation looks like
Server:
achiever_preferencegets adaily_review_prompt_time: Optional[str]field (e.g.,"22:00"). null = off.- PUT setter endpoint
- Batch endpoint called every minute:
POST /api/achiever/daily-review/notify-prompt/batch - Fire lock collection with a TTL index
Cron:
- One line added to existing APScheduler:
register_minutely("notify_daily_review_prompt", ...)
Client:
dailyReviewPromptTimefield on shared model- Toggle + TimePicker component in the settings screen
- Push-response handler with a branch on
data.type === "daily-review-prompt"→ routes to the edit-daily-review screen - Language auto-set on app start
After shipping
For such a “simple” feature, there were four quiet forks. Each looks minor alone, but swap any one and the UX would grate.
- Go local, and “why did it ping me when I already reviewed?”
- Define “done” as “review exists,” and days where only a description was typed don’t get reminders
- Skip the fire lock, and a cron restart sends two pushes
- Skip the language auto-set, and Korean users get English pushes
The shape of a product is the sum of these small decisions. Any one of them alone is trivial, but together they create the feeling that the app understands you.