ryer.io

Whose Day Is It? Local Dates and the Cohort Content Index

TL;DR

  • Changing the device date didn’t change the cohort age, because the backend computes from server UTC.
  • The fix is letting the client send an ISO date so content follows the user’s local day, with a fallback.
  • Index-based fetching returned the wrong tiles; sorting first and indexing in JavaScript fixed it.
  • Sorting on createdAt and ID alone wasn’t sufficient for correct results.
  • Avoided as and satisfies by restructuring into a returnable content shape instead of casting.

Two problems that turn out to be the same question: which day is it, and which tile belongs to it.

The backend’s day isn’t the user’s day

Testing cohort content updates, I changed my device’s date to confirm the value changes day to day. The device date changed and the cohort age in days did not, because the calculation runs off a UTC timestamp.

Looking closer, the backend adjusts the server’s UTC by a timezone value. That’s consistent, and consistency is the problem — it assumes one timezone rather than the user’s. A user whose local day has rolled over sees yesterday’s content until the server agrees with them.

The fix is to let the client send a date in ISO format, so content reflects the user’s actual local day, with a fallback for clients that don’t send one.

There was a frontend bug sitting on top of it: on iOS, changing the date didn’t refresh the data because the network call never fired. That traced to useCallback and its memoization — the refresh function wasn’t re-executing when it should have. Adjusting the logic so refresh calls actually go out was necessary before any of the date work could be verified.

Indexing into sorted data

Related: the logic fetching a quote tile and a learn tile when no prescribed content exists wasn’t producing random results. On day two it repeated.

I’d assumed sorting on createdAt and ID would give stable, correctly-ordered results to index into. It wasn’t enough — accessing directly by index in the query didn’t return what I expected.

Retrieving the sorted data and then indexing into it in JavaScript produced correct results. Slightly less elegant than letting the database do it, and it works, which matters more here.

Restructuring instead of casting

The backend and frontend disagreed about shape, and TypeScript said so. The tempting fix was as or satisfies to make the error go away.

I didn’t, because the type error was describing something real: the backend processes data one way and the frontend consumes it another, and casting would have papered over a genuine mismatch rather than resolving it. A cast that silences a true disagreement is a bug waiting for a runtime.

Instead I created a streamlined returnable content structure to bridge the two. The backend produces it, the frontend consumes it, and the types line up honestly because the data actually has that shape.

Randomness works, types hold, and content follows the user’s local day rather than the server’s.