The Day Rolled Over and Yesterday's Wallpaper Came Back — Designing Date Boundaries Instead of Trusting an Agent's Time Code
A record of redesigning a daily-rotating wallpaper feature that showed yesterday's pick after midnight for some users and switched twice in one night for travelers. I separate the device clock, time zone, and daylight saving time into three distinct failure sources, show how to define a stable day key, defend reward boundaries against a rewound clock, and test it all with an injectable clock — from a solo indie developer's point of view.
I was fixing the feature that surfaces one "wallpaper of the day" when a user wrote in: "The date changed but I'm still seeing yesterday's wallpaper." A different user reported the opposite — "it switched twice overnight." The same feature was producing two contradictory symptoms at once.
I could not reproduce either on my Pixel, no matter how many times I tried. It only showed up when I advanced the device date by hand, or changed the time zone to somewhere overseas. When you maintain the same app for years as a solo developer, you get used to your own device always sitting at the same time in the same region, and you stop imagining a world where the clock drifts. What finally pushed me to look was an AdMob rewarded-ad log: a "once per day" bonus was granting many times over for one particular user. A clock problem was hitting both the display and the revenue at the same time.
The first version of this code came from asking the Antigravity agent to "write logic that picks today's wallpaper, rotating daily." It read naturally and sailed through review. But production clocks don't advance as politely as a development device does.
Where the "natural" code broke
The first implementation looked roughly like this — not the agent's exact output, but the same shape:
// First version (broke in production)fun todaysWallpaperIndex(total: Int): Int { val now = System.currentTimeMillis() val daysSinceEpoch = now / (24 * 60 * 60 * 1000) // ms -> days return (daysSinceEpoch % total).toInt()}
It looks like a clean, region-independent calculation. But System.currentTimeMillis() is milliseconds since the UTC epoch. Dividing by 24*60*60*1000 pins the rollover to midnight UTC. For users in Japan that means the wallpaper changes at 9 a.m. every morning, which felt exactly like "the date changed but nothing happened."
The fix I reached for next caused the opposite failure:
// Second version (broke a different way)fun todaysWallpaperIndex(total: Int): Int { val cal = java.util.Calendar.getInstance() // device default time zone val year = cal.get(java.util.Calendar.YEAR) val day = cal.get(java.util.Calendar.DAY_OF_YEAR) return ((year * 366 + day) % total)}
Now it read the device time zone, so in Japan it rolled over at midnight. But Calendar.getInstance() reads the device's current zone as-is. A user who flew across a time difference saw DAY_OF_YEAR jump forward or back, switching twice in one night or skipping a day. The year * 366 + day key is also sloppy and breaks on leap years and year boundaries.
Both failures come down to one thing: cramming three independent decisions into a single expression. "When does the day roll over," "whose clock do we measure with," and "what happens when the clock moves backward" are separate design questions. The agent writes a tidy line in front of it, but pulling those three apart is the job of the person who knows what the app actually is.
Three failure sources, kept apart
Time bugs almost always come from one of three layers. I write this split out on paper before going back to the code.
First, the device clock itself. Users can set the time by hand, and plenty of devices sit a few minutes off. Any boundary where "moving forward earns something" — like a daily reward — gets attacked here.
Second, the time zone. When "today" starts depends on the region. Using the user's region for the displayed "today" is natural, but that region changes when they travel.
Third, daylight saving time. On a transition day, a day becomes 23 or 25 hours long, and times appear that either don't exist or happen twice. A naive "run at midnight every day" quietly breaks twice a year.
These three need different defenses. The displayed "today" is a time-zone problem, reward fraud is a device-clock problem, scheduling drift is a DST problem. Assigning them this way removes the strain of asking one expression to carry everything.
Time source
What it guarantees
Where to use it
System.currentTimeMillis()
UTC based, not monotonic (can move backward)
Display date math, given an explicit zone
Device time zone setting
The user's felt "today," changes when they travel
Display boundary, but never hard-pinned
SystemClock.elapsedRealtime()
Monotonic since boot, cannot be hand-set
Elapsed-time checks; not for date decisions
Server time
Device independent, needs a network call
Final ruling for rewards you must protect
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Separate the device clock, time zone, and daylight saving time as three independent failure sources and pin a rotating feature's notion of 'today' to a single day key rather than guessing
✦Close the path where a rewound device clock breaks daily rewards and AdMob reward boundaries, recording the last shown day monotonically to stop double grants, with concrete Kotlin
✦Build tests with an injected Clock that reproduce DST transitions and the midnight rollover, so you can decide where to lean on the agent and where to hold the invariant yourself
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start with the display side. The requirement for a daily wallpaper is plain: it should change on the user's local calendar "today." The important move is not to read the device's current zone on every calculation, but to build a "day key" — one abstraction level up — in a single place.
import java.time.LocalDateimport java.time.ZoneIdimport java.time.Clock// A stable key for "today." Display, selection, and caching all reference this.data class DayKey(val isoDate: String) { // e.g. "2026-06-25" companion object { fun of(clock: Clock, zone: ZoneId): DayKey = DayKey(LocalDate.now(clock.withZone(zone)).toString()) }}// Resolve an index from the key deterministically (no device or locale dependence)fun wallpaperIndexFor(key: DayKey, total: Int): Int { // Use epoch days for a stable sequence, not a string hash val epochDay = LocalDate.parse(key.isoDate).toEpochDay() return Math.floorMod(epochDay, total)}
Three points matter: take Clock as an argument, pass the zone explicitly, and reduce "today" to a LocalDate (a date with no time of day) before turning it into a key. I use floorMod because % returns negatives for negative inputs — a spot I got bitten by once before.
For the display zone I default to "the device's current zone" while letting users pin it in settings. That gives an escape hatch to anyone annoyed by the wallpaper changing twice while traveling. I lean on this "default automatic, override manual" shape well beyond time handling. When automation gets too clever, users can't correct it once it drifts.
Protect reward boundaries from a clock that moves backward
Fixing the display doesn't fix the "once per day" of an AdMob reward or a daily bonus. If you keep thinking "grant once when the date changes," a user can advance the device clock and collect repeatedly. That was exactly the path visible in the logs.
The fix was to hold one invariant: the last granted day key may only move forward.
class DailyRewardGate( private val store: PreferenceStore, private val clock: Clock, private val zone: ZoneId,) { // Returns true only when a grant is allowed, advancing internal state. fun tryClaim(): Boolean { val today = DayKey.of(clock, zone).isoDate val lastClaimed = store.getString("last_reward_day") // e.g. "2026-06-24" // Device clock rewound: if today <= lastClaimed, do not grant. if (lastClaimed != null && today <= lastClaimed) return false // Guard against double-grant on simultaneous taps: write in the same compare. return store.compareAndSet("last_reward_day", expected = lastClaimed, newValue = today) }}
The crucial line is refusing to grant when today <= lastClaimed. A string date comparison closes both the "jump forward to earn more" attack and the "rewind and replay the same day" attack at once. The compareAndSet is there because some devices genuinely fire the ad-completion callback twice, and a naive read-then-write leaves the double grant in place.
If you need more rigor, push the final ruling to server time. At my solo-development scale, keeping the rewind defense on the client and reconciling on the server just once a month was enough. Moving everything server-side costs more — users couldn't even see a wallpaper offline. How far to defend is a judgment call set by the cost of fraud and your assumptions about connectivity.
DST and "run at midnight"
The third layer is scheduling. I was running wallpaper prefetch and notifications "at midnight every day," but on a DST transition day that midnight either doesn't exist or arrives twice. Asking something like WorkManager for "every 24 hours" also drifts cumulatively once the anchor slips.
What I settled on is rescheduling each time toward "the next date boundary," not a fixed interval.
import java.time.ZonedDateTimeimport java.time.Duration// Time until the start (midnight) of the next local date, DST includedfun durationUntilNextLocalMidnight(clock: Clock, zone: ZoneId): Duration { val now = ZonedDateTime.now(clock.withZone(zone)) val nextMidnight = now.toLocalDate().plusDays(1).atStartOfDay(zone) return Duration.between(now, nextMidnight)}
atStartOfDay(zone) returns a valid "start of that day" even in regions where midnight is skipped by DST. Because java.time carries the regional rules, the right move here is to not add or subtract offsets by hand. Conversely, computing "the next midnight" by adding milliseconds or hours yourself drifts by one hour twice a year. Ask an agent to "rerun 24 hours later" and it usually walks straight into this trap.
Test it with an injectable clock
The payoff of this design showed up in how testable it became. Taking Clock as an argument lets you reproduce DST edges and the midnight rollover without touching the real device clock.
import java.time.Instantimport java.time.ZoneIdimport kotlin.test.Testimport kotlin.test.assertFalseimport kotlin.test.assertTrueclass DailyRewardGateTest { private val tokyo = ZoneId.of("Asia/Tokyo") @Test fun rewoundClockDoesNotGrantTwice() { var instant = Instant.parse("2026-06-25T15:30:00Z") // JST 6/26 00:30 val clock = object : java.time.Clock() { override fun instant() = instant override fun getZone() = tokyo override fun withZone(z: ZoneId) = this } val gate = DailyRewardGate(InMemoryStore(), clock, tokyo) assertTrue(gate.tryClaim()) // grant for 6/26 instant = Instant.parse("2026-06-24T15:30:00Z") // rewind the clock assertFalse(gate.tryClaim()) // no grant for a past day }}
Once I wrote this test, my fear of touching time code dropped sharply. Swap in ZoneId.of("America/New_York") and you can reproduce a DST transition day; ask the agent to "add tests for this gate's boundary conditions" and the injectable clock keeps it from writing irrelevant device-dependent tests. Let the agent build the scaffolding for measurement and tests; decide the invariants — which boundaries to defend — yourself. With time handling, that line is especially worth drawing.
After the release, the same class of reports — "didn't change after midnight," "changed twice at night" — went from a dozen or so over four weeks to zero. The double reward grants disappeared from the logs too. It isn't a flashy feature, but removing a small daily irritation from a place users touch every day matters quietly when you want them to stay for the long run.
If you have a feature that handles "today" the same way, start by writing down which parts of your code depend on the device clock, the time zone, or DST. The moment you split the three apart, the one line you need to fix comes into focus.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.