Summary

Four backend capabilities the app was not using — the category taxonomy, the licensed cover library, PostGIS radius search, and the analytics stream — are now wired into Flutter. Three of the four already existed server-side; the app either duplicated them badly or ignored them.

Files Changed

  • lib/core/services/category_catalog.dartnew. Reads event_categories from Supabase, caches to shared_preferences, resolves label/icon/colour.
  • lib/core/services/cover_image_service.dartnew. Micro-batching loader over covers_for_events(uuid[]).
  • lib/core/services/kpi_service.dartnew. Client signals via track_kpi.
  • lib/shared/models/event_model.dart — adds categorySlug, categoryId.
  • lib/core/network/event_api_service.dart — parses the new fields; the nearby fallback now logs loudly.
  • lib/shared/widgets/flow_event_image.dart — falls back to a licensed cover.
  • lib/features/events/screens/event_discovery_screen.dart — vibe filter fixed.
  • lib/features/events/screens/series_detail_screen.dart, lib/features/search/screens/search_screen.dart — hardcoded maps removed.
  • lib/main.dart — warms the catalogue at startup (not awaited).

Key Decisions

The EventCategory enum survives, but stops owning display

The enum knows 7 values; the taxonomy holds 34 and grows. It is referenced in 82 places (creation wizard, filters, repositories), so removing it is a large refactor with no user-visible benefit. Instead the raw slug travels alongside it and the taxonomy owns the label. The enum keeps filtering; the catalogue does display. They never disagree, because the catalogue falls back to the enum’s own labels when it has nothing better.

The enum is lossy

Anything the enum does not recognise parses as EventCategory.other. Never use event.category for display — use event.categorySlug with CategoryCatalog.

The vibe filter was actively wrong

event_discovery_screen mapped vibes onto enum values ('tec' -> 'nightlife') and filtered on e.category.name. Picking “Techno” therefore returned every nightlife event and excluded the actual techno ones, because slug techno parses as other. It now maps onto real slugs, which exist. The chip- availability check carried a second copy of the same test — that duplication is how the two drifted apart, so both now call one _matchesVibe.

Covers are batched by a loader, not fetched per query

Covers are needed by a widget, so requests arrive one per card as a list scrolls. Fetching inside each of the eight event queries would have duplicated the logic eight times and still missed anything loaded elsewhere. Instead requests made within 30ms are collected into one covers_for_events call. Negative results are cached too, so an event with no cover is not re-asked on every rebuild.

Attribution decides where a cover may appear

The library is CC BY / CC BY-SA, which requires naming the author wherever the image is shown. Below 120pt of height a credit line is illegible, so FlowEventImage does not show a licensed image at all at that size and keeps the gradient. An unreadable credit is not compliance.

get_nearby_events did not exist

The app had been calling it since before it was written, catching the failure, and falling back to: fetch 200 rows, filter client-side with a bounding box. That fallback is wrong in four ways — 200 rows for 20 results, corners up to 41% beyond the radius, silent truncation past the cap, and ordering by date rather than distance. It survived because it never threw. The function now exists (PostGIS, RLS-respecting, GiST-indexed) and the fallback logs a warning when it runs.

Client analytics only record what the server cannot see

RSVPs, event creation and registration are already recorded by database triggers. KpiService asserts against re-sending those, and covers only impressions, detail opens, ticket-link taps and searches. track_kpi attributes every row to auth.uid(), so a client cannot fabricate activity for another user; with no session it is a silent no-op. Search queries are never sent — only their length.

Verification

  • flutter analyze — no issues.
  • covers_for_events exercised as anon: 6 of 7 visible events resolved, with a credit line.
  • get_nearby_events returns events ordered by true distance from Reggio Emilia. It returns empty today because no visible event is both future and geo-located — the 50 active rows are past-dated seed data.
  • track_kpi tested inside a rolled-back transaction: authenticated writes one row, anonymous writes none.

Known Gaps

  • geo_point was empty on all 50 active events (coordinates lived only in location jsonb). Backfilled — 31 recovered — and a trigger now keeps it in sync, because at least four code paths insert events.
  • toggle_message_reaction is called by messaging_api_service.dart:677 and does not exist in the database — the same shape of bug as get_nearby_events, still unfixed.

database-schema · audit-unused-infrastructure · refactor-theme-tokens