feature/taste-profile — raccomandazioni sul dispositivo
Data: 1 agosto 2026 Priorità: 3 — Feature nuove
Perché non la funzione che esisteva già
compute-recommendations è deployata e ACTIVE da mesi.
recommendations_cache ha 0 righe e non ne ha mai avuta una.
La funzione fallisce in quattro punti indipendenti contro lo schema attuale:
| Passo | Realtà del DB (verificata il 1 ago 2026) |
|---|---|
profiles con latitude/longitude non nulli | 0 profili → esce con {processed: 0} alla prima query |
last_active negli ultimi 30 giorni | 0 profili — quella colonna non viene mai scritta |
events con status = 'active' | 0 eventi — active è stato fuso in published |
categoryScore da profiles.interests | 0 profili con interessi → 0.2 fisso per tutti |
E comunque non la invoca nessuno: non c’è nessun cron.
RecommendationProvider leggeva la cache vuota dentro un catch che non
logga, quindi restituiva [] per sempre sembrando una feature. È la stessa
forma di get_events_with_friends: un fallimento che non si annuncia.
Anche riparata avrebbe ordinato male. Seleziona geo_point e poi non lo usa
mai, leggendo location.latitude da un jsonb che non ha quella chiave: con
(0, 0) l’haversine misura la distanza dal Golfo di Guinea, distanceScore va
a 0 per tutti gli eventi e il punteggio resta identico. Un ranking che non
ordina niente sembra funzionante finché non lo apri.
RecommendationProvider è stato eliminato: zero riferimenti nel codice, e
prometteva una cosa che non poteva fare.
La Edge Function resta deployata ma inutilizzata. Non è stata cancellata perché la decisione di rimuoverla dal progetto Supabase è tua; il codice mobile non la chiama più in nessun punto.
Cosa la sostituisce
TasteProfileService — lib/core/services/taste_profile_service.dart
Il telefono. Registra quello che questo dispositivo vede — evento aperto e
guardato, salvato, condiviso, RSVP, filtro vibe usato — in
shared_preferences.
Niente viene caricato sul server e niente è legato all’account. Non è un ripiego:
- funziona dalla prima sessione, senza job notturno e senza cache fredda;
- non ha bisogno che
profiles.interestsvenga mai riempito; - non costa niente far girare;
- il server sa già a cosa hai fatto RSVP. Non ha bisogno di sapere anche cosa hai guardato senza cliccare.
⚠️
KpiServiceresta il posto per l’analytics di prodotto. Questo non è analytics: è un modello locale di preferenze, e i due non vanno confusi. Se un numero di qui deve mai arrivare a una dashboard, va aggregato lì — non si comincia a caricare questo file.
Pesi dei segnali
Sono la decisione di prodotto per intero, e stanno tutti in un enum:
| Segnale | Peso | Nota |
|---|---|---|
viewed | 1 | solo oltre 3 secondi — un tap sbagliato non è una preferenza, e contarlo farebbe derivare il profilo verso quello che sta in cima alla lista |
filtered | 2 | una preferenza dichiarata batte una dedotta |
saved | 3 | |
shared | 4 | condividere è più forte di salvare: ci metti la faccia |
attending | 5 | il segnale più forte che esiste |
dismissed | −4 | l’utente si è preso il disturbo di dirlo |
Togliere il like non insegna niente: dice “non questo”, non “non questo genere”, e trattarlo come negativo punirebbe un’intera categoria per un ripensamento.
Dimensioni
category, venue, tag, timeBand, priceBand.
Le fasce (late_night / evening / afternoon / daytime,
free / cheap / mid / premium) invece dei valori grezzi: “ti piacciono
le nottate” sopravvive a un cambio di orario, “ti piacciono le 23:00” no.
I tag vengono diluiti per il loro numero, sia in scrittura che in lettura. Senza, un evento con dieci tag urlerebbe sopra uno con due, e gli organizzatori imparerebbero a riempire quel campo di spazzatura.
Decadimento
Metà ogni 45 giorni. Il gusto nella nightlife si muove con le stagioni: quello che facevi a febbraio non deve ancora guidare a luglio. Abbastanza lungo da sopravvivere a un mese fermo, abbastanza corto perché una fase finisca.
Il decadimento si applica a tutta la mappa in scrittura invece di salvare un timestamp per segnale: resta O(dimensioni) e il blob salvato minuscolo. Le voci sotto 0.05 vengono buttate, così il file non cresce all’infinito con ogni locale mai sfiorato.
EventRanker — lib/shared/utils/event_ranker.dart
Non è un recommender in senso machine-learning e non finge di esserlo. È una somma pesata su segnali che il telefono può vedere, e per qualche decina di eventi candidati in una città non è solo sufficiente ma preferibile: è ispezionabile, non ha un training loop che invecchia, e si spiega.
| Termine | Peso |
|---|---|
| categoria | 0.34 |
| locale | 0.22 |
| tag | 0.22 |
| fascia oraria | 0.12 |
| fascia di prezzo | 0.10 |
| amici che vanno | 0.45 |
| imminenza | 0.20 |
Gli amici stanno fuori dalla somma dei termini di gusto perché sono un fatto,
non una preferenza — ed è l’unico segnale per cui vale la pena scavalcare il
gusto: la gente va dove va la sua gente. Satura (1 - e^(-n/2)): la
differenza tra nessuno e un amico è tutta la storia, tra sei e sette niente.
L’imminenza decade su ~21 giorni, l’orizzonte su cui si fanno davvero i piani.
Il ranker non fa fetch. I candidati arrivano dal chiamante — nearby, in-viewport, friends-going. Tenere separati fetch e ordinamento è quello che permette allo stesso ranker di servire home, mappa e ricerca senza tre copie.
Le due guardie, entrambe portanti
| Guardia | Soglia | Perché |
|---|---|---|
hasEnoughSignal | massa segnali ≥ 6 | sotto, il profilo è rumore e ordinarci sopra sarebbe superstizione con una barra di caricamento |
minCandidates | ≥ 8 eventi | sotto, riordinare è teatro: l’utente vede gli stessi quattro eventi in ordine diverso e non impara niente |
In entrambi i casi si torna all’ordine cronologico, che è onesto.
Stato attuale: ci sono 4 eventi pubblicati futuri (166 sono
importedin attesa di revisione, 25draft). Quindi la seconda guardia è quella che scatta davvero adesso, e va bene così: la feature si accende da sola quando c’è abbastanza da ordinare. Se vuoi vederla in azione, pubblica parte degliimporteddalla dashboard.
Dove sono agganciati i segnali
| Azione | File | Segnale |
|---|---|---|
Apertura dettaglio (dwell su dispose) | event_details_screen.dart | viewed |
| Like | event_details_screen.dart → _toggleLike | saved (solo in positivo) |
| Condivisione | event_details_screen.dart → _showShareChoice | shared |
| RSVP | event_details_screen.dart | attending |
| Chip vibe | event_discovery_screen.dart | filtered |
L’estensione TasteTracking on Event
(lib/shared/utils/taste_tracking.dart) risponde una volta sola alla
domanda “quali campi di un evento descrivono il gusto”. Ogni punto che lo
avesse fatto a mano avrebbe finito per non essere d’accordo con gli altri — uno
dimentica i tag, un altro passa il nome dell’enum invece dello slug vero — e il
profilo avrebbe imparato sciocchezze senza niente da indicare. Quella modalità
di guasto è già costata diversi giri a questo codebase.
Ordinamento nel feed
event_discovery_screen.dart tiene una mappa di punteggi, non una lista
già ordinata: così un rebuild non aspetta mai il ranking. Il feed dipinge
subito nell’ordine del server e si riordina quando il profilo ha risposto. Il
ranking gira in addPostFrameCallback perché aspetta shared_preferences, e
un build non deve mai aspettare il disco.
Quando l’ordinamento è attivo l’intestazione “Altri eventi” diventa
“Per te” (feedForYou).
Controllo utente
privacy_settings_screen.dart → sezione Personalizzazione: mostra se il
profilo sta ancora imparando o sta già personalizzando, e permette di azzerarlo.
Sta lì e non in una schermata “raccomandazioni” di proposito: un profilo che
l’app costruisce su di te va dove guardi quando ti chiedi cosa sa l’app di te.
Un profilo locale che l’utente non può cancellare non è locale in senso
utile.
Chiavi l10n aggiunte
feedForYou, feedForYouWhy, tasteProfileSection,
tasteProfileDescription, tasteProfileClear, tasteProfileCleared,
tasteProfileLearning, tasteProfileActive.
Vedi localization.
Da fare
- Segnale
dismissed: il peso c’è, manca il gesto nella UI (“non fa per me”). - Motivo visibile sulla card (
RankedEvent.topReasonè già calcolato e restituito, nessuna schermata lo mostra ancora). - Il ranker è usato solo nella discovery. Mappa e ricerca hanno gli stessi candidati e non lo chiamano.
Related
feat-event-discovery-live — feed e candidati
feature-chat-sharing — il segnale shared
database-schema — recommendations_cache, event_attendees