Monthly leaderboard

Incremental on-write scoring and scheduled daily close, with a hard floor at 0 and a featured winner.

Goal

Reward consistency, not volume. An author who publishes every day — even one poem — ends up ahead of someone who writes ten in an afternoon and then disappears for a month.

Firestore schema

leaderboards/
  {YYYY-MM}/            ← one document per month (month key)
    entries/
      {uid}/            ← one document per author
        score, displayName, photoURL, isPremium,
        lastCreditedDay, daysCredited, daysMissed

Sharding by month prevents a collection from growing forever: when a month closes, that subset stays as history and the next one starts from zero.

Scoring: two separate moments

+1 on-write

The positive point is applied by the updateLeaderboardOnPoem trigger on poems/{poemId}, but only if:

  • the poem is neither draft nor private,
  • moderationPassed === true.

So the point lands the moment the content actually goes public — not when it was submitted. The lastCreditedDay field prevents double credits on the same day.

−3 on-schedule

A scheduled function runs every day at 00:05 Europe/Rome (closeDailyLeaderboard): it finds who didn’t publish the day before and applies −3. The on-write trigger doesn’t know who the “absentees” are, so the penalty has to be batched.

Score is floored at 0: we never store negative values. A new user or a dormant one doesn’t dig an infinite hole they’d never climb out of.

Concurrency and limits

The daily close iterates entries in batches of 20 with Promise.all to avoid saturating Firestore — a single month can hold hundreds of authors, and the updates are serializable per document. Function timeout set to 5 minutes, with a single retry for transient errors.

Monthly winner

On the same job that closes the day, if we’re crossing midnight on the first of a new month, the function picks the previous month’s winner:

  1. Orders entries by score descending.
  2. Writes the top UID into leaderboards/{monthKey}/winner.
  3. The UI shows the winner at the top of the public feed for the first 7 days of the following month, reading from that document.

Full history remains queryable for building “hall of fame” pages or exporting data for themed seasons.

Profile denormalization

Entries duplicate displayName, photoURL, isPremium instead of joining against users/{uid} on the client. When one of these fields changes, syncUserPublicFields propagates the change to all the user’s historical entries (as well as to poems and comments). The write cost is paid back by the fact that the leaderboard becomes readable with a single query.