← Back to blog

17 April 2026 · 520 words

Cluster-aware hero: when the front page yields to a neighbor

technicalproduct
A small change to how FOMO Sun picks its headline destination — and why the seam between "best" and "most interesting" matters.

The problem with ranked lists

FOMO Sun sorts Switzerland by sunshine every morning. The ranking engine is good at its job: it knows that today, if you're in Zurich, Lugano is the fastest sunny escape, Locarno is the warmest, and the default-sort winner is probably somewhere along that Ticino corridor. So we put the top of that list on the front page as a hero card.

Problem: "top of the list" is a stable function of (origin, weather, travel time). If two days have similar conditions and you're in the same city, you see the same hero. The ranking is correct but the front page starts feeling like a podium.

We already had a chance-factor pick for this — a seeded weighted draw over the top-3 so #2 and #3 surface ~50% of the time combined. It helps, but the top-3 is itself a narrow slice. Across Swiss origins with similar travel profiles, the same three destinations kept taking turns at the front.

Clusters, not rankings

The fix shipped in v1.8.1 is a second, independent coin flip. After the chance-factor picks a winner, we draw rCluster ∈ [0, 1). If it lands under 0.20, we try to swap the winner with a nearby sibling.

"Sibling" is defined geographically and categorically:

  • Within 15km (haversine distance from the winner)
  • Same altitude band, bucketed at 600m (so a 1800m peak and a 2100m peak cluster; a 400m lake and a 2400m ridge do not)
  • At least one overlapping type — mountains pair with mountains, lakes with lakes

The pool we search is the top-15 candidates, not just the top-3 — so the sibling can come from well down the ranking, as long as it's geographically and categorically adjacent to the winner.

If at least one sibling qualifies, we pick from the top-2 with weights [0.7, 0.3]. If none do, the chance-factor winner stands. The whole thing falls back silently.

Why this is better than reranking

The obvious alternative is to just rerank — penalize destinations that are too similar to higher-ranked ones. We didn't do that because the ranking is actually useful signal. We don't want to hide that Locarno is objectively sunnier than a 1600m ridge 8km north of it. What we want is to occasionally surface the ridge because Locarno is already well-established in people's heads.

Cluster injection does that. 80% of the time, the ranking wins. 20% of the time, the ridge wins — and when it does, it's because the ridge is there, in the same neighborhood, not from a random reshuffle.

The seed matters

One constraint we kept: the hero must be stable within a Zurich day. If you share a link at 10am and your friend opens it at 2pm, they need to see the same hero. We already had FNV-1a hashing keyed on zurichDayKey() | origin | dayFocus. For clustering we added a second seed with a cluster| prefix, so the two draws (chance-factor and cluster-swap) are independent but both deterministic per-day.

Stay-home hero and the forced St. Moritz demo (?demo=stmoritz) are bypassed — those are explicit, not emergent.

What this costs

Thirty lines in src/app/page.tsx, inside the existing heroEscape useMemo. No new helpers (haversine was already there for fallback nearest-city logic). No schema changes. No API changes. No measurable render cost.

The user doesn't see a toggle. They just notice, over weeks, that the front page feels more like Switzerland and less like a ranking algorithm.


v1.9.0 shipped 2026-04-17. The full diff is PR #36.