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:

PassoRealtà del DB (verificata il 1 ago 2026)
profiles con latitude/longitude non nulli0 profili → esce con {processed: 0} alla prima query
last_active negli ultimi 30 giorni0 profili — quella colonna non viene mai scritta
events con status = 'active'0 eventiactive è stato fuso in published
categoryScore da profiles.interests0 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

TasteProfileServicelib/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.interests venga 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.

⚠️ KpiService resta 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:

SegnalePesoNota
viewed1solo oltre 3 secondi — un tap sbagliato non è una preferenza, e contarlo farebbe derivare il profilo verso quello che sta in cima alla lista
filtered2una preferenza dichiarata batte una dedotta
saved3
shared4condividere è più forte di salvare: ci metti la faccia
attending5il segnale più forte che esiste
dismissed−4l’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.

EventRankerlib/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.

TerminePeso
categoria0.34
locale0.22
tag0.22
fascia oraria0.12
fascia di prezzo0.10
amici che vanno0.45
imminenza0.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

GuardiaSogliaPerché
hasEnoughSignalmassa segnali ≥ 6sotto, il profilo è rumore e ordinarci sopra sarebbe superstizione con una barra di caricamento
minCandidates≥ 8 eventisotto, 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 imported in attesa di revisione, 25 draft). 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 degli imported dalla dashboard.

Dove sono agganciati i segnali

AzioneFileSegnale
Apertura dettaglio (dwell su dispose)event_details_screen.dartviewed
Likeevent_details_screen.dart_toggleLikesaved (solo in positivo)
Condivisioneevent_details_screen.dart_showShareChoiceshared
RSVPevent_details_screen.dartattending
Chip vibeevent_discovery_screen.dartfiltered

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.

feat-event-discovery-live — feed e candidati feature-chat-sharing — il segnale shared database-schemarecommendations_cache, event_attendees