Summary

compute-recommendations had been deployed and ACTIVE for months. recommendations_cache had 0 rows and never had one. The mobile provider read the empty cache inside a catch that did not log, so it returned [] forever while looking like a feature.

It has been replaced by an on-device profile and a local ranker.

Why Not the Server Recommender

It failed at four independent points against the current schema:

StepReality in the database (verified 1 Aug 2026)
profiles with non-null latitude/longitude0 profiles → exits with {processed: 0} on the first query
last_active within 30 days0 profiles — that column is never written
events with status = 'active'0 eventsactive was collapsed into published
categoryScore from profiles.interests0 profiles have interests → a flat 0.2 for everyone

And nothing invoked it anyway: there is no cron.

Warning

Same shape as get_events_with_friends: a failure that does not announce itself.

Even repaired it would have ranked badly. It selects geo_point and never uses it, reading location.latitude from a jsonb with no such key: with (0, 0) the haversine measures the distance from the Gulf of Guinea, distanceScore goes to 0 for every event, and the score is identical for all of them. A ranking that does not order anything looks fine until you open it.

RecommendationProvider was deleted: zero references in the codebase, and it promised something it could not do.

Note

The Edge Function is still deployed but unused. Removing it from the Supabase project is the owner’s decision; the mobile code no longer calls it anywhere.

What Replaces It

TasteProfileService

lib/core/services/taste_profile_service.dart

The phone. It records what this device sees — event opened and dwelt on, saved, shared, RSVP’d, vibe filter used — into shared_preferences.

Nothing is uploaded and nothing is keyed to the account. That is not a compromise:

  • it works from the first session, with no nightly job and no cold cache;
  • it does not need profiles.interests to ever be populated;
  • it costs nothing to run;
  • the server already knows what you RSVP’d to. It does not also need to know what you looked at and did not click.

Warning

KpiService remains the place for product analytics. This is not analytics: it is a local preference model, and the two must not be confused. If a number here ever needs to reach a dashboard, aggregate it there — do not start uploading this file.

Signal weights

The whole product decision, in one enum:

SignalWeightNote
viewed1only past 3 seconds — a mis-tap is not a preference, and counting it would drift the profile toward whatever sits at the top of the list
filtered2a stated preference beats an inferred one
saved3
shared4sharing is stronger than saving: you put your name on it
attending5the strongest signal there is
dismissed−4the user took the trouble to say so

Unliking teaches nothing: it says “not this one”, not “not this kind”, and treating it as negative would punish a whole category for a change of mind.

Dimensions

category, venue, tag, timeBand, priceBand.

Bands (late_night / evening / afternoon / daytime, free / cheap / mid / premium) rather than raw values: “you like late nights” survives a schedule change, “you like 23:00” does not.

Tags are diluted by their count, both on write and on read. Without that, an event with ten tags would shout over one with two, and organisers would learn to stuff the field.

Decay

Halves every 45 days. Nightlife taste moves with the seasons: what you did in February should not still be steering July. Long enough to survive a quiet month, short enough that a phase ends.

Decay is applied to the whole map on write rather than storing a timestamp per signal: it stays O(dimensions) and the stored blob tiny. Entries below 0.05 are dropped so the file does not grow forever with every venue ever glanced at.

EventRanker

lib/shared/utils/event_ranker.dart

Not a recommender in the machine-learning sense and it does not pretend to be. It is a weighted sum over signals the phone can see, and for a few dozen candidate events in one city that is not merely adequate but preferable: it is inspectable, it has no training loop to go stale, and it explains itself.

TermWeight
category0.34
venue0.22
tags0.22
time band0.12
price band0.10
friends going0.45
imminence0.20

Friends sit outside the taste sum because they are a fact, not a preference — and it is the only signal worth overriding taste for: people go where their people go. It saturates (1 - e^(-n/2)): the difference between nobody and one friend is the whole story, between six and seven nothing.

Imminence decays over ~21 days, the horizon on which people actually make plans.

The ranker never fetches. Candidates come from the caller — nearby, in-viewport, friends-going. Keeping fetch and rank apart is what lets one ranker serve home, map and search without three copies.

The Two Guards, Both Load-Bearing

GuardThresholdWhy
hasEnoughSignalsignal mass ≥ 6below it the profile is noise and ranking by it would be superstition with a progress bar
minCandidates≥ 8 eventsbelow it reordering is theatre: the user sees the same handful in a new sequence and learns nothing

Both fall back to chronological order, which is honest.

Note

Current state: 4 published upcoming events (166 are imported awaiting review, 25 draft). So the second guard is the one that actually fires today, and that is correct — the feature switches itself on when there is enough to rank. To see it working, publish some of the imported ones from the dashboard.

Where the Signals Are Wired

ActionFileSignal
Detail opened (dwell on dispose)event_details_screen.dartviewed
Likeevent_details_screen.dart_toggleLikesaved (positive direction only)
Shareevent_details_screen.dart_showShareChoiceshared
RSVPevent_details_screen.dartattending
Vibe chipevent_discovery_screen.dartfiltered

The TasteTracking on Event extension (lib/shared/utils/taste_tracking.dart) answers once the question “which fields of an event describe taste”. Every call site doing it by hand would eventually disagree with the others — one forgets tags, another passes the enum name instead of the real slug — and the profile would quietly learn nonsense with nothing to point at.

Feed Ordering

event_discovery_screen.dart holds a score map, not a pre-sorted list, so a rebuild never waits on the ranking. The feed paints immediately in server order and re-sorts once the profile answers. Ranking runs in addPostFrameCallback because it awaits shared_preferences, and a build must never wait on disk.

When ranking is active the “Other events” header becomes “For you” (feedForYou).

User Control

privacy_settings_screen.dartPersonalisation section: shows whether the profile is still learning or already personalising, and lets you reset it. It lives there and not in a “recommendations” screen on purpose: a profile the app builds about you belongs where you look when you ask what the app knows about you.

A local profile the user cannot delete is not meaningfully local.

l10n Keys Added

feedForYou, feedForYouWhy, tasteProfileSection, tasteProfileDescription, tasteProfileClear, tasteProfileCleared, tasteProfileLearning, tasteProfileActive. See localization.

Open Items

  • The dismissed signal has a weight but no gesture in the UI (“not for me”).
  • Visible reason on the card: RankedEvent.topReason is already computed and returned, no screen shows it yet.
  • The ranker is only used in discovery. Map and search have the same candidates and do not call it.

feat-event-discovery-live — feed and candidates feature-chat-sharing — the shared signal database-schemarecommendations_cache, event_attendees