···6565 updated_at: String,
6666 count: usize,
6767 },
6868+ #[serde(rename = "grouped_lfg")]
6969+ GroupedLfg {
7070+ handles: Vec<String>,
7171+ dids: Vec<String>,
7272+ updated_at: String,
7373+ count: usize,
7474+ },
6875}
69767077#[derive(Serialize)]
···7784 pub activities: Vec<ActivityDisplay>,
7885}
79868787+/// LFG activities grouped by H3 cell location
8888+#[derive(Serialize)]
8989+pub struct LfgGroup {
9090+ pub activities: Vec<ActivityDisplay>,
9191+}
9292+8093#[derive(Serialize)]
8194pub struct DateGroup {
8295 pub date: String, // e.g., "November 10, 2025"
8396 pub date_short: String, // e.g., "Nov 10"
8497 pub event_groups: Vec<EventGroup>,
9898+ pub lfg_groups: Vec<LfgGroup>,
8599}
8610087101impl fmt::Display for HomeTab {
···145159 .take(page_size as usize + 1)
146160 .collect();
147161148148- // Step 1: Group by date, then by event
162162+ // Step 1: Group by date, then by event (for events/RSVPs) or by h3_cell (for LFG)
149163 // Use BTreeMap with Reverse ordering to get newest dates first
150150- let mut date_groups: BTreeMap<std::cmp::Reverse<chrono::NaiveDate>, HashMap<String, Vec<&crate::storage::event::model::ActivityItem>>> = BTreeMap::new();
164164+ let mut date_event_groups: BTreeMap<std::cmp::Reverse<chrono::NaiveDate>, HashMap<String, Vec<&crate::storage::event::model::ActivityItem>>> = BTreeMap::new();
165165+ let mut date_lfg_groups: BTreeMap<std::cmp::Reverse<chrono::NaiveDate>, HashMap<String, Vec<&crate::storage::event::model::ActivityItem>>> = BTreeMap::new();
151166152167 for activity in &activity_list {
153168 let date = activity.discovered_at.date_naive();
154154- let event_aturi = activity.event_aturi.clone();
155169156156- date_groups
157157- .entry(std::cmp::Reverse(date))
158158- .or_default()
159159- .entry(event_aturi)
160160- .or_default()
161161- .push(activity);
170170+ if activity.activity_type == "lfg_created" {
171171+ // Group LFG activities by h3_cell
172172+ let h3_cell = activity.h3_cell.clone().unwrap_or_default();
173173+ date_lfg_groups
174174+ .entry(std::cmp::Reverse(date))
175175+ .or_default()
176176+ .entry(h3_cell)
177177+ .or_default()
178178+ .push(activity);
179179+ } else {
180180+ // Group event/RSVP activities by event_aturi
181181+ let event_aturi = activity.event_aturi.clone();
182182+ date_event_groups
183183+ .entry(std::cmp::Reverse(date))
184184+ .or_default()
185185+ .entry(event_aturi)
186186+ .or_default()
187187+ .push(activity);
188188+ }
162189 }
163190164191 // Step 2: Collect all needed DIDs
···173200 let handles = handles_by_did(&web_context.pool, needed_dids.into_iter().collect()).await?;
174201175202 // Step 3: Build date groups with activities
203203+ // Collect all unique dates from both event and LFG groups
204204+ let mut all_dates: std::collections::BTreeSet<std::cmp::Reverse<chrono::NaiveDate>> = std::collections::BTreeSet::new();
205205+ for key in date_event_groups.keys() {
206206+ all_dates.insert(*key);
207207+ }
208208+ for key in date_lfg_groups.keys() {
209209+ all_dates.insert(*key);
210210+ }
211211+176212 let mut date_group_displays: Vec<DateGroup> = Vec::new();
177213178178- for (rev_date, event_map) in date_groups {
214214+ for rev_date in all_dates {
215215+ let event_map = date_event_groups.remove(&rev_date).unwrap_or_default();
216216+ let lfg_map = date_lfg_groups.remove(&rev_date).unwrap_or_default();
179217 let date = rev_date.0; // Extract date from Reverse wrapper
180218181219 // Format date for display
···320358 let a_time = match a {
321359 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(),
322360 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
361361+ ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
323362 };
324363 let b_time = match b {
325364 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(),
326365 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
366366+ ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
327367 };
328368 b_time.cmp(&a_time) // Descending order: newer timestamps first
329369 });
···350390 }
351391 }
352392393393+ // Sort event groups by their first activity's timestamp, newest first
353394 if !event_groups.is_empty() {
354354- // Sort event groups by their first activity's timestamp, newest first
355395 event_groups.sort_by(|a, b| {
356396 let a_time = a.activities.first().and_then(|act| match act {
357397 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(),
358398 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
399399+ ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
359400 });
360401 let b_time = b.activities.first().and_then(|act| match act {
361402 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(),
362403 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
404404+ ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
363405 });
364406 b_time.cmp(&a_time) // Descending order: newer timestamps first
365407 });
408408+ }
366409410410+ // Process LFG groups (grouped by H3 cell location)
411411+ let mut lfg_groups: Vec<LfgGroup> = Vec::new();
412412+413413+ for (_h3_cell, lfg_activities) in lfg_map {
414414+ // Sort LFG activities by timestamp, newest first
415415+ let mut sorted_activities: Vec<_> = lfg_activities.into_iter().collect();
416416+ sorted_activities.sort_by(|a, b| {
417417+ let a_time = a.created_at.unwrap_or(a.discovered_at);
418418+ let b_time = b.created_at.unwrap_or(b.discovered_at);
419419+ b_time.cmp(&a_time)
420420+ });
421421+422422+ let mut lfg_activity_displays: Vec<ActivityDisplay> = Vec::new();
423423+424424+ if sorted_activities.len() > 1 {
425425+ // Group multiple LFG profiles at same location
426426+ let dids: Vec<String> = sorted_activities.iter().map(|a| a.did.clone()).collect();
427427+ let group_handles: Vec<String> = dids
428428+ .iter()
429429+ .map(|did| {
430430+ handles
431431+ .get(did)
432432+ .map(|profile| profile.handle.clone())
433433+ .unwrap_or_else(|| did.clone())
434434+ })
435435+ .collect();
436436+437437+ let first_activity = sorted_activities[0];
438438+ let newest_timestamp = first_activity.created_at.unwrap_or(first_activity.discovered_at);
439439+440440+ lfg_activity_displays.push(ActivityDisplay::GroupedLfg {
441441+ handles: group_handles,
442442+ dids,
443443+ updated_at: newest_timestamp.to_rfc3339(),
444444+ count: sorted_activities.len(),
445445+ });
446446+ } else if let Some(activity) = sorted_activities.first() {
447447+ // Single LFG profile
448448+ let handle = handles
449449+ .get(&activity.did)
450450+ .map(|profile| profile.handle.clone())
451451+ .unwrap_or_else(|| activity.did.clone());
452452+453453+ let timestamp = activity.created_at.unwrap_or(activity.discovered_at);
454454+ lfg_activity_displays.push(ActivityDisplay::Individual(ActivityView {
455455+ activity_type: activity.activity_type.clone(),
456456+ did: activity.did.clone(),
457457+ handle,
458458+ event_aturi: String::new(),
459459+ event_url: String::new(),
460460+ event_name: String::new(),
461461+ event_organizer_did: String::new(),
462462+ event_organizer_handle: String::new(),
463463+ rsvp_status: None,
464464+ updated_at: timestamp.to_rfc3339(),
465465+ }));
466466+ }
467467+468468+ if !lfg_activity_displays.is_empty() {
469469+ lfg_groups.push(LfgGroup {
470470+ activities: lfg_activity_displays,
471471+ });
472472+ }
473473+ }
474474+475475+ // Sort LFG groups by their first activity's timestamp, newest first
476476+ if !lfg_groups.is_empty() {
477477+ lfg_groups.sort_by(|a, b| {
478478+ let a_time = a.activities.first().and_then(|act| match act {
479479+ ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(),
480480+ ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
481481+ _ => None,
482482+ });
483483+ let b_time = b.activities.first().and_then(|act| match act {
484484+ ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(),
485485+ ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(),
486486+ _ => None,
487487+ });
488488+ b_time.cmp(&a_time)
489489+ });
490490+ }
491491+492492+ // Only add date group if there are any activities
493493+ if !event_groups.is_empty() || !lfg_groups.is_empty() {
367494 date_group_displays.push(DateGroup {
368495 date: date_formatted,
369496 date_short,
370497 event_groups,
498498+ lfg_groups,
371499 });
372500 }
373501 }
374502375503 let params: Vec<(&str, &str)> = vec![("tab", &tab_name)];
376504377377- // Calculate total activities for pagination
505505+ // Calculate total activities for pagination (event groups + LFG groups)
378506 let total_activities: usize = date_group_displays
379507 .iter()
380380- .map(|dg| dg.event_groups.iter().map(|eg| eg.activities.len()).sum::<usize>())
508508+ .map(|dg| {
509509+ let event_count: usize = dg.event_groups.iter().map(|eg| eg.activities.len()).sum();
510510+ let lfg_count: usize = dg.lfg_groups.iter().map(|lg| lg.activities.len()).sum();
511511+ event_count + lfg_count
512512+ })
381513 .sum();
382514383515 let pagination_view =
···390522 if remaining == 0 {
391523 return false;
392524 }
525525+ // Truncate event groups
393526 date_group.event_groups.retain_mut(|event_group| {
394527 if remaining == 0 {
395528 return false;
···404537 true
405538 }
406539 });
407407- !date_group.event_groups.is_empty()
540540+ // Truncate LFG groups
541541+ date_group.lfg_groups.retain_mut(|lfg_group| {
542542+ if remaining == 0 {
543543+ return false;
544544+ }
545545+ let activities_count = lfg_group.activities.len();
546546+ if activities_count <= remaining {
547547+ remaining -= activities_count;
548548+ true
549549+ } else {
550550+ lfg_group.activities.truncate(remaining);
551551+ remaining = 0;
552552+ true
553553+ }
554554+ });
555555+ !date_group.event_groups.is_empty() || !date_group.lfg_groups.is_empty()
408556 });
409557 }
410558
+24-4
src/storage/event.rs
···74747575 #[derive(Clone, FromRow, Debug, Serialize)]
7676 pub struct ActivityItem {
7777- pub activity_type: String, // "event_created", "event_updated", or "rsvp"
7777+ pub activity_type: String, // "event_created", "event_updated", "rsvp", or "lfg_created"
7878 pub did: String,
7979 pub event_aturi: String,
8080 pub event_name: String,
8181 pub rsvp_status: Option<String>,
8282 pub discovered_at: DateTime<Utc>, // When record was first seen by the system
8383 pub created_at: Option<DateTime<Utc>>, // From the AT Protocol record metadata
8484+ pub h3_cell: Option<String>, // H3 cell for LFG location grouping
8485 }
8586}
8687···1246124712471248 let offset = (page - 1) * page_size;
1248124912491249- // Query combines events and RSVPs, determining if event was created or updated
12501250+ // Query combines events, RSVPs, and LFG profile creations
12501251 let query = r#"
12511252 WITH activity AS (
12521253 -- Events: check if created_at equals updated_at (from JSON)
···12621263 name as event_name,
12631264 NULL::text as rsvp_status,
12641265 discovered_at,
12651265- (record->>'created_at')::timestamptz as created_at
12661266+ (record->>'created_at')::timestamptz as created_at,
12671267+ NULL::text as h3_cell
12661268 FROM events
12671269 WHERE discovered_at IS NOT NULL
12681270···12761278 e.name as event_name,
12771279 r.status as rsvp_status,
12781280 r.discovered_at,
12791279- NULL::timestamptz as created_at
12811281+ NULL::timestamptz as created_at,
12821282+ NULL::text as h3_cell
12801283 FROM rsvps r
12811284 INNER JOIN events e ON e.aturi = r.event_aturi
12821285 WHERE r.discovered_at IS NOT NULL
12861286+12871287+ UNION ALL
12881288+12891289+ -- LFG profile creations (only active, only new creations)
12901290+ SELECT
12911291+ 'lfg_created' as activity_type,
12921292+ did,
12931293+ aturi as event_aturi,
12941294+ '' as event_name,
12951295+ NULL::text as rsvp_status,
12961296+ indexed_at as discovered_at,
12971297+ (record->>'createdAt')::timestamptz as created_at,
12981298+ record->'location'->>'value' as h3_cell
12991299+ FROM atproto_records
13001300+ WHERE collection = 'events.smokesignal.lfg'
13011301+ AND (record->>'active')::boolean = true
13021302+ AND ABS(EXTRACT(EPOCH FROM (indexed_at - (record->>'createdAt')::timestamptz))) < 60
12831303 )
12841304 SELECT * FROM activity
12851305 ORDER BY discovered_at DESC
+21
templates/en-us/index.common.html
···169169 </div>
170170 {% endfor %}
171171 {% endfor %}
172172+173173+ {% for lfg_group in date_group.lfg_groups %}
174174+ {% for activity in lfg_group.activities %}
175175+ <div class="mb-2">
176176+ <p>
177177+ {% if activity.type == "individual" and activity.activity_type == "lfg_created" %}
178178+ <a href="/{{ activity.did }}">@{{ activity.handle }}</a> is <a href="/lfg">looking for activities</a>.
179179+ {% elif activity.type == "grouped_lfg" %}
180180+ {% set handle_count = activity.handles | length %}
181181+ {% if handle_count == 2 %}
182182+ <a href="/{{ activity.dids[0] }}">@{{ activity.handles[0] }}</a> and <a href="/{{ activity.dids[1] }}">@{{ activity.handles[1] }}</a> are <a href="/lfg">looking for activities</a>.
183183+ {% elif handle_count == 3 %}
184184+ <a href="/{{ activity.dids[0] }}">@{{ activity.handles[0] }}</a>, <a href="/{{ activity.dids[1] }}">@{{ activity.handles[1] }}</a>, and <a href="/{{ activity.dids[2] }}">@{{ activity.handles[2] }}</a> are <a href="/lfg">looking for activities</a>.
185185+ {% else %}
186186+ <a href="/{{ activity.dids[0] }}">@{{ activity.handles[0] }}</a>, <a href="/{{ activity.dids[1] }}">@{{ activity.handles[1] }}</a>, and {{ handle_count - 2 }} other{% if handle_count - 2 > 1 %}s{% endif %} are <a href="/lfg">looking for activities</a>.
187187+ {% endif %}
188188+ {% endif %}
189189+ </p>
190190+ </div>
191191+ {% endfor %}
192192+ {% endfor %}
172193 </div>
173194 {% endfor %}
174195