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:
| Step | Reality in the database (verified 1 Aug 2026) |
|---|---|
profiles with non-null latitude/longitude | 0 profiles → exits with {processed: 0} on the first query |
last_active within 30 days | 0 profiles — that column is never written |
events with status = 'active' | 0 events — active was collapsed into published |
categoryScore from profiles.interests | 0 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.intereststo 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
KpiServiceremains 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:
| Signal | Weight | Note |
|---|---|---|
viewed | 1 | only 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 |
filtered | 2 | a stated preference beats an inferred one |
saved | 3 | |
shared | 4 | sharing is stronger than saving: you put your name on it |
attending | 5 | the strongest signal there is |
dismissed | −4 | the 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.
| Term | Weight |
|---|---|
| category | 0.34 |
| venue | 0.22 |
| tags | 0.22 |
| time band | 0.12 |
| price band | 0.10 |
| friends going | 0.45 |
| imminence | 0.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
| Guard | Threshold | Why |
|---|---|---|
hasEnoughSignal | signal mass ≥ 6 | below it the profile is noise and ranking by it would be superstition with a progress bar |
minCandidates | ≥ 8 events | below 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
importedawaiting review, 25draft). 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 theimportedones from the dashboard.
Where the Signals Are Wired
| Action | File | Signal |
|---|---|---|
Detail opened (dwell on dispose) | event_details_screen.dart | viewed |
| Like | event_details_screen.dart → _toggleLike | saved (positive direction only) |
| Share | event_details_screen.dart → _showShareChoice | shared |
| RSVP | event_details_screen.dart | attending |
| Vibe chip | event_discovery_screen.dart | filtered |
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.dart → Personalisation 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
dismissedsignal has a weight but no gesture in the UI (“not for me”). - Visible reason on the card:
RankedEvent.topReasonis 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.
Related
feat-event-discovery-live — feed and candidates
feature-chat-sharing — the shared signal
database-schema — recommendations_cache, event_attendees