import InfoBox from '../components/InfoBox.astro';

Two days turned the daily challenge from a puzzle that simply existed into one with an actual rhythm: a reset boundary tied to where its players actually are, a wait that counts down instead of dead-ending, cross-promotion in both directions between daily and duel, and web push notifications so a player does not have to remember to come back on their own.

## Whose midnight

The challenge had rolled over at UTC midnight since the first server deploy, tolerated rather than designed: a [two-day grace window](/posts/design-system-v2-first-attempt#a-harder-line-on-what-a-leaderboard-entry-can-prove) already existed just to cover a game spanning that boundary. The day before, the boundary itself moved to where the audience actually is, Oslo midnight, deliberately computed as a fixed point rather than tracking daylight saving. A single pure utility now owns that calculation, and everything that used to compute a day boundary on its own, the challenge seed, the client's stale-cache check, the cache header capping how long a response can be reused, reads it from the same place instead.

## Waiting becomes part of the design

The already-played screen stopped being a dead end and started counting down: a minute-accurate "3h 42m" ticking toward the same Oslo-midnight instant, labelled plainly as "midnight (Oslo time)" for every visitor regardless of their own timezone, an honest simplification over guessing where in the world someone actually is. The same countdown, in miniature, now also appears the other way around, in the duel game-over screen, pointing back at the daily the moment it becomes available again instead of showing a link that goes nowhere useful outside that window.

## A reminder that does not need an open tab

Push notifications were the deep part of the two days, and the design decision underneath it was about delivery, not urgency: a browser-standard push subscription, keyed per player, delivers a notification through the browser vendor's own infrastructure even with the game closed, which ruled out anything requiring an open connection to the server.

<InfoBox title="A cron that can only run a command">
  The one piece of infrastructure this depended on could not do the obvious thing. Railway's
  scheduled-job feature runs a service's start command on a timer, it has no way to call
  a URL directly, so the dispatch step that decides who to notify each hour lives behind a tiny,
  self-contained Node script in its own service, one whose only job is to invoke the real app's
  dispatch endpoint with the credential that authorizes it. The dispatch logic itself takes sending, marking
  as sent, and removing a stale subscription as injected functions, so the whole decide-who-and-what
  path is tested without a real push client or a live database, and a subscription the browser's push
  service reports as gone gets cleaned up automatically instead of failing silently forever.
</InfoBox>

On the settings side, a small state machine tracks whether notifications are active, paused, or denied at the browser level, reconciled against the real permission every time the page loads so a permission revoked outside the app does not go unnoticed. A soft nudge appears once a daily game finishes, offering to turn notifications on, and stops asking after being dismissed a few times rather than repeating the same request forever.

## Where this leaves things

None of it reaches a real player yet without the one-time setup still ahead: a key pair to generate and hand to Railway, and the scheduled job wired up to actually run. The daily challenge now has an actual shape to its day, though, reset, wait, and a reason to come back, rather than a page that is simply there or not.