Stamping last_active_date — a tiny fix that made admin useful
The admin dashboard has an At-Risk tab. It's supposed to show users who haven't opened the app in 7+ days — the leading indicator of churn before churn actually happens. It's been there since Day 19.
It didn't work.
Not in a loud way. The tab rendered, the table appeared, the styling was correct. Every single user showed "Never" in the "Last active" column. For thirty days, the tab was a beautifully-built piece of furniture that did nothing.
I noticed because I was sitting in admin checking something else and realized every user's last_active_date matched their created_at to the millisecond. That's not "haven't logged in lately" — that's "never updated since signup."
The query was fine. The RLS policies were fine. The column existed and was populated. The actual bug was structural: nothing in the app ever wrote to that column after signup. The schema had a perfect home for the data; the data was never produced.
The decision tree
When you find a problem like this, there are three honest paths.
Path one is the lazy fix: have admin compute last-active from some adjacent table — last review fetched, last AI reply, last login event. This works for some surfaces. The problem is none of those tables update on the action that actually matters: opening the dashboard. A user who logs in every morning to read reviews but never replies looks "inactive" to a query against the replies table. False negative.
Path two is the clean fix: a server-side event log that records every meaningful user interaction. Pageviews, clicks, route changes. This is the "right" answer, the one that scales, the one any analytics-aware product would already have. It is also a two-week build, with a database table I don't need yet, a privacy review I haven't scheduled, and a cost line item I'd rather not start paying for.
Path three is the boring fix: stamp last_active_date = now() whenever the dashboard mounts. Fire-and-forget. One database column, one API route, one client component. Total surface area: about forty lines of code.
Path three wins, because it solves the exact problem in front of me without solving four problems I don't have yet. The lesson I keep relearning at this stage is that "good enough now" beats "perfect later" by a factor of weeks.
The shape of the fix
The implementation is almost embarrassingly small.
A new client component mounts on the dashboard:
'use client'
import { useEffect } from 'react'
export function ActivityPing() {
useEffect(() => {
fetch('/api/activity/ping', { method: 'POST' }).catch(() => {})
}, [])
return null
}
That .catch(() => {}) is intentional. If the ping fails, I don't want a toast, I don't want a log line, I don't want the user to know. The whole point of fire-and-forget is the user never feels it.
The API route uses the cookie-scoped Supabase client, which means the existing "Users can update own profile" RLS policy does all the security work for me. There is no service-role escalation, no admin override, no special permission. The user is updating their own row, which they were already allowed to do.
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return new Response(null, { status: 401 })
await supabase
.from('users')
.update({ last_active_date: new Date().toISOString() })
.eq('id', user.id)
return new Response(null, { status: 204 })
That's the entire route. No JSON response, no body. 204 means "I did the thing, don't expect anything back."
What I want to remember from this
Three things, in descending order of importance.
The first is that a feature with no write path is worse than no feature at all. The At-Risk tab existed for thirty days. Every time I opened admin and glanced at it, my brain registered "yep, the at-risk system is in place" — and moved on. The tab was lying to me by being there. If it hadn't existed, I'd have built it on Day 19 when I needed it. Instead I had a placebo for a month. The cost wasn't the code; the cost was the false sense of completeness. The product itself ships every day; the supporting tools have to ship too.
The second is that fire-and-forget is a real design pattern, not laziness. There's a strong urge as a non-coder building a SaaS to make every action robust — retries, error handling, user-facing feedback, telemetry. For most things that's right. For analytics-grade signals where individual misses don't matter, the right move is the cheap one. If the ping fails, the user is still logged in, still using the product, and tomorrow's ping will succeed. The aggregate signal stays clean.
The third is that RLS is a gift if you build for it. The reason this fix took forty lines instead of four hundred is that the existing "users can update their own row" policy already permitted exactly what I needed. I didn't add a policy, didn't elevate permissions, didn't escalate to service-role. I used the security model that was already there. Every time I reach for service-role first, I'm leaving a foot-gun for future me. Every time I let RLS do the work, the code gets smaller.
What's next
The At-Risk tab now correctly identifies users 7+ days inactive once they hit the threshold. The data starts flowing from Day 30 onward, which means the first real signal will appear on Day 37. That's fine. The tab is no longer lying.
There's a bigger version of this lesson coming as the product scales: every internal tool that's "almost done" is actually broken, because the missing 5% is usually the part that produces the data the other 95% displays. I'll be looking for those now.
Onward.
Tagged
Written by
The founder of Ominvo
Building review management for single-location small businesses. Join the waitlist →